main image

Understanding between Angular & React / Working with DOM

Understanding between Angular & React. Part 4: Working with DOM

Simple examples for managing the DOM, creating Custom Events, and ways to isolate this logic into reusable entities

Maksim Dolgikh
ITNEXT
Published in
15 min readMay 14, 2024

--

Previous part: Understanding between Angular & React. Part 3: Services & Context API

Introduction

Working with DOM using Javascript is one of the core aspects of a front-end developer’s job.

The main reasons for interacting with DOM elements are:

  1. Style manipulation. You can modify the CSS styles of elements, which allows you to dynamically change the appearance and layout of the page based on user actions or other events.
  2. Modify page content. You can dynamically modify the contents of HTML elements on a page by adding, removing, or replacing them
  3. Event Handling. The DOM allows you to bind event handlers to various elements on the page. This can be a mouse click, a keystroke, a form submission, and more.
  4. Animations and effects. JavaScript combined with the DOM can be used to create animations and effects on web pages. You can change element properties such as position, size, transparency and others

As developers, we are used to creating abstract functions that can be reused for other scenarios in our application if the DOM logic is repetitive or we want to share our solution

In this article, I would like to explore what mechanisms and methods Angular and React provide for creating reusable DOM logic based on two examples

Handling autofill event

Task

We have some application that has an email input field. Our analysts have tasked us to add an autofill event listener to analyze and improve conversion.

But the problem is that there is no native autofill event, and we need to develop our solution to understand when a user enters data manually and when data is entered using a hint.

example of work
Example of work

Angular implementation

Angular provides several ways to manage and access HTML elements. You can choose any way you like, from native to reactive.

For this example, I will be using the native API for event listening

  1. You can, but you don’t need to

According to the experience of previous articles of working with hooks and getting references to elements, we could make this solution for the current problem

@Component({
selector: 'ng-wrong-autofill',
template: `
<label for="autofill-example">Email&nbsp;</label>
<input
#inputRef
type="email"
name="email"
id="autofill-example"
/>
`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WrongAutofillShowcaseComponent
implements AfterViewInit, OnDestroy
{
@ViewChild('inputRef')
public inputRef!: ElementRef<HTMLInputElement>;

public ngAfterViewInit(): void {
this.inputRef.nativeElement.addEventListener(
'change',
this.onChangeEvent.bind(this)
);
}

public ngOnDestroy(): void {
this.inputRef.nativeElement.removeEventListener(
'change',
this.onChangeEvent.bind(this)
);
}

public onAutoFill(event: Event): void {
// TODO analytics callback
console.log(event);
}

private onChangeEvent(event: Event): void {
if (!this.isAutoFillEvent(this.inputRef.nativeElement)) {
return;
}

this.onAutoFill(event);
}

private isAutoFillEvent(elRef: HTMLElement): boolean {
return [':autofill', ':-webkit-autofill'].some((s) => elRef.matches(s));
}
}

This code works perfectly and we get the desired result. The analysts are satisfied and have improved our business performance

However, after some time, the analysts asked us to do autofill event handling for another field on another page of the app, which is responsible for phone entry for the same conversion improvement purposes. Will you re-create a similar component or adapt the current component for the phone only? — I think you won’t.

What shall we do? — attribute directives.

“Attribute directive” is an entity in Angular that allows you to change the appearance or extend the behaviour of HTML elements by applying attributes to those elements.

The goal for our future directive is to add autofill event behaviour for any input field to which the directive is applied

2. Autofill Directive

@Directive({
selector: '[autofill]',
standalone: true,
})
export class AutofillDirective
implements AfterViewInit, OnDestroy
{
private readonly elRef: HTMLElement = inject(
ElementRef<HTMLElement>
).nativeElement;

@Output()
public readonly autofill: EventEmitter<Event> = new EventEmitter<Event>();

public ngAfterViewInit(): void {
this.elRef.addEventListener('change', this.onChangeEvent.bind(this));
}

public ngOnDestroy(): void {
this.elRef.removeEventListener('change', this.onChangeEvent.bind(this));
}

private onChangeEvent(event: Event): void {
if (!this.isAutoFillEvent(this.elRef)) {
return;
}

this.autofill.emit(event);
}

private isAutoFillEvent(elRef: HTMLElement): boolean {
return [':autofill', ':-webkit-autofill'].some((s) => elRef.matches(s));
}
}

Let’s break it down in order:

  • Everything starts with the directive selector. Directive and component selectors are identical — they are the usual query selectors (class, id, tag or attribute) for Angular to understand where to create a directive or component

Since the directive is “attribute”, the selector will be an attribute with an appropriate name

selector: '[autofill]'
  • If the directive is bound to a native HTML element, we can access that element via ElementRef using the familiar DI mechanism and the inject() function

ElementRef is a separate and safe Angular layer to get a reference to the current DOM element. ElementRef always references the current HTML element to which the directive is applied.

private readonly elRef: HTMLElement = inject(
ElementRef<HTMLElement>
).nativeElement;
  • A directive is the same component but without a template. Lifecycle hooks and property creation for binding — inputs and outputs — are available in the directive.

I’ll say more, the component is inherited from the directive

So it is not a problem for us to declare an output event autofill that repeats the name of the selector

@Output()
public readonly autofill: EventEmitter<Event> = new EventEmitter<Event>();

If any property of a class with @Input() or @Output() decorators repeats the attribute name in the selector, we can apply data binding and event handler syntax to it

  • We can also declare all the necessary hooks to create and remove event listeners on an element
public ngAfterViewInit(): void {
this.elRef.addEventListener('change', this.onChangeEvent.bind(this));
}

public ngOnDestroy(): void {
this.elRef.removeEventListener('change', this.onChangeEvent.bind(this));
}

private onChangeEvent(event: Event): void {
if (!this.isAutoFillEvent(this.elRef)) {
return;
}

this.autofill.emit(event);
}
  • And the last part is the logic to identify that it was the autofill event that happened, and not normal user input
private isAutoFillEvent(elRef: HTMLElement): boolean {
return [':autofill', ':-webkit-autofill'].some((s) => elRef.matches(s));
}

3. Component

Let’s replace the hard-implemented logic in the component by using our directive

@Component({
- selector: 'ng-wrong-autofill',
+ selector: 'ng-correct-autofill',
template: `
<label for="autofill-example">Email&nbsp;</label>
<input
- #inputRef
type="email"
name="email"
id="autofill-example"
+ (autofill)="onAutoFill($event)"
/>
`,
standalone: true,
+ imports: [AutofillDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WrongAutofillShowcaseComponent
- implements AfterViewInit, OnDestroy
{
- @ViewChild('inputRef')
- public inputRef!: ElementRef<HTMLInputElement>;

- public ngAfterViewInit(): void {
- this.inputRef.nativeElement.addEventListener(
- 'change',
- this.onChangeEvent.bind(this)
- );
- }

- public ngOnDestroy(): void {
- this.inputRef.nativeElement.removeEventListener(
- 'change',
- this.onChangeEvent.bind(this)
- );
- }

public onAutoFill(event: Event): void {
// TODO analytics callback
console.log(event);
}

- private onChangeEvent(event: Event): void {
- if (!this.isAutoFillEvent(this.inputRef.nativeElement)) {
- return;
- }
- this.onAutoFill(event);
- }

- private isAutoFillEvent(elRef: HTMLElement): boolean {
- return [':autofill', ':-webkit-autofill'].some((s) => elRef.matches(s));
- }
}

The component has become much simpler. Now the directive can be used in other parts of the application for similar purposes

I’ve already written a story on creating a directive to identify an autofill event. It uses a simplified syntax and considers binding for FormControl

React implementation

React doesn’t have any dedicated entities for working with HTML elements. Our toolkit remains the same — hooks and functions.

They are enough to make the logic for detecting the autofill event reusable for a React application. I suggest choosing a proven scheme of creating functions for React components — custom hooks

  1. Custom hook useAutofillDetect
export function useAutofillDetect(
elRef: React.RefObject<HTMLElement> | null,
onDetect: (event: Event) => void
): void {

useLayoutEffect(() => {
const onChangeEvent = (event: Event) => {
if (!isAutoFillEvent(event.currentTarget as HTMLElement)) {
return;
}

onDetect(event);
};

elRef?.current?.addEventListener('change', onChangeEvent);

return () => elRef?.current?.removeEventListener('change', onChangeEvent);
}, [elRef?.current, onDetect]);
}

function isAutoFillEvent(elRef: HTMLElement): boolean {
return [':autofill', ':-webkit-autofill'].some((s) => elRef.matches(s));
}

Let’s take it one by one:

  • Let’s declare arguments for the custom hook useAutofillDetect. The first argument elRef — get a reference to the element that will detect the autofill event. The second argument onDetect — get a callback for its call, in case the event is an autofill event
export function useAutofillDetect(
elRef: React.RefObject<HTMLElement> | null,
onDetect: (event: Event) => void
): void {...}
  • Let’s add a useLayoutEffect hook that will listen for the change event and filter it for the autofill event. It will have the input arguments of our function as dependencies.
useLayoutEffect(() => {
const onChangeEvent = (event: Event) => {
if (!isAutoFillEvent(event.currentTarget as HTMLElement)) {
return;
}

onDetect(event);
};

elRef?.current?.addEventListener('change', onChangeEvent);

return () => elRef?.current?.removeEventListener('change', onChangeEvent);
}, [elRef?.current, onDetect]);

If the elRef reference of an element changes, the hook will restart, delete the old listener for the previous element and create a new listener for the new element.

The same applies to the onDetect callback and the creation of the onChangeEvent function to work with it.

2. Component

All we have to do is apply the hook to the element of interest in the component

const AutofillShowcase = () => {
const inputRef = useRef<HTMLInputElement>(null);

const handleAutoFillEvent = ($event: Event) => {
// TODO analytics
console.log($event);
};

useAutofillDetect(inputRef, handleAutoFillEvent);

return (
<>
<label htmlFor="autofill-example">Email&nbsp;</label>
<input
type="email"
name="email"
id="autofill-example"
style={{ minWidth: '200px' }}
ref={inputRef}
/>
</>
);
};

Unlike Angular, all bindings between the useAutofillDetect hook and the element have to be done manually.

Therefore:

  • we will need to create a variable inputRef using the hook useRef
  • pass this variable to the required element via ref
  • provide inputRef to useAutofillDetect hook with the created reference to the element

We managed to get a reused logic for handling the autofill event. The hook does not create any entities of its own and is only responsible for listening to the event, which can also be customised in the future.

This implementation has an obvious disadvantage — useLayoutEffect in our custom hook is restarted every time a component is re-rendered because of a new reference to onDetect. But ways to avoid this will be discussed in the next article.

Rendering elements by condition

Task

One of the goals of business is not just to create a product, but to make it as good as it can be. But how do you know which changes would improve the product, and which ones might be ineffective or even harmful? That’s where A/B testing comes in. By randomly distributing users between options A and B and analysing the results, you can find out which changes lead to the best results.

We as developers don’t set ourselves global goals of what exactly should be shown to a user option A or option B, our aim is to provide logic in the code “how to show option A or option B?”.

Obviously, we are not going to create a copy of the application that will be distributed to users. We need tools that will help us bring the ability to switch between different features in the current application.

The scheme of using A/B testing in an application looks like this

A/B testing in an app scheme

Let’s say we have already implemented stages 1 through 2, and our task as a developer is to do the last ones — stages 3 and 4.

Angular implementation

1. Service

First, let’s create a simple service that will provide an A/B test attribute for the entire application

export type TAbTestFeature = 'a' | 'b';

@Injectable({ providedIn: 'root' })
export class AbTestService {
// TODO fetch feature settings
public feature: TAbTestFeature = 'b'

public isCurrentFeature(candidate: TAbTestFeature): boolean {
return this.feature === candidate;
}
}

We know that the feature is set on application startup, and for simplicity, I’ll set the required value right away since our backend is not conditionally ready yet.

The service is global and only has a method to check isCurrentFeature to determine if the A/B test feature is activated.

2. You can, but you don’t need to

Up to this point, in all my articles in this series, I have avoided using structural directives or using “control-flow” (Angular 17+) to manipulate the DOM in Angular, trying to keep my examples simple

But the first thought a developer might come up with is to hardwire the logic of showing certain A/B test features in a component.

@Component({
selector: 'ng-wrong-ab-test',
standalone: true,
template: `
<p *ngIf="abTestService.isCurrentFeature('a')">Feature A</p>
<p *ngIf="abTestService.isCurrentFeature('b')">Feature B</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf]
})
export class WrongAbTestComponent {
public abTestService = inject(AbTestService);
}

This code will work fine if you don’t need to consider A/B testing anywhere else in the application. But our component is already doing a bit more than it should be doing.

If there are more than 2 such places for A/B testing, can we avoid duplicate logic in each component? — Yes, by using structural directives

Structural directives in Angular are a special type of directives which allows you to modify the structure of the DOM based on conditions or iterations. They are used to control how DOM elements are created, deleted, or modified based on the data provided. They improve code readability and make it easier to manage DOM elements.

3. Structural directive — ABTestDirective

Let’s create our first structural directive to show features within A/B-testing

@Directive({
selector: '[abtest]',
standalone: true,
})
export class AbTestDirective {
private readonly vcr = inject(ViewContainerRef)
private readonly templateRef = inject(TemplateRef)
private readonly abTestService = inject(AbTestService);

@Input({ required: true, alias: 'abtest'})
public feature!: TAbTestFeature;

public ngOnInit(): void {
this.vcr.clear();
if(!this.abTestService.isCurrentFeature(this.feature)){
return
}

this.vcr.createEmbeddedView(this.templateRef)
}
}

Let’s break it down in order:

  • It is required to set a selector for the directive, as for an attribute directive
selector: '[abtest]'
  • In order for a directive to become a structural directive, TemplateRef and ViewContainerRef dependencies must be injected into it, otherwise it will be an ordinary attribute directive. (Why? — I will show you later)
private readonly vcr = inject(ViewContainerRef)
private readonly templateRef = inject(TemplateRef)
  • Unlike the previous example, where the selector attribute name matched the output name of a class property, here I’ll take the opportunity to reassign the input to a different class property for ease of readability.
@Input({ required: true, alias: 'abtest'})
public feature!: TAbTestFeature;
  • Main logic

When the directive is ready, I clear the container.
If the feature condition is met - I create the necessary template in the container.
If not — the container remains empty and components and other directives of this template are not created at all. Thus, our “feature” isn’t visible for the user

private readonly abTestService = inject(AbTestService);

public ngOnInit(): void {
this.vcr.clear();
if(!this.abTestService.isCurrentFeature(this.feature)){
return
}

this.vcr.createEmbeddedView(this.templateRef)
}

It’s simple from the outside, but how do you apply it in a component?

4. Component

Let’s apply the newly created directive to the component

@Component({
selector: 'ng-directive-ab-test',
standalone: true,
template: `
<p *abtest="'a'">Feature A</p>
<p *abtest="'b'">Feature B</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AbTestDirective]
})
export class AbTestComponent {}

From the look of it, it may seem that nothing has changed and the number of lines has been reduced by only 1 line. Was it really worth creating a separate directive just for this? — Yes.

Now we have a single point as an attribute, to control the element and its content. We don’t need to request the AbTestService and know the methods of identifying this or that feature. Everything is now done with one abtest attribute with the desired feature.

This is extremely useful when your application is created within a mono-repository, where the display of features must be consistent and regulated between all applications and team members.

5. Syntactical Sugar

Do you remember that we used TemplateRef and ViewContainerRef when we created the directive? How are they involved in our current example?

The whole point is that the syntax via *[selector] is a simplification of Angular for a better development experience.

But in fact, you can use the low-level way with <ng-template>. These 2 entries are equivalent for Angular. This is not an obvious point that many developers miss

<p *abtest="'a'">Feature A</p>

<ng-template abtest="a">
<p>Feature A</p>
</ng-template>

Now let’s analyze this code again:

  • At the moment of inject(ViewContainerRef) call — we get API for controlling and rendering the current element by our own hands, instead of Angular
  • At the moment of inject(TemplateRef) call — we get a reference to the current ng-template element.
private readonly vcr = inject(ViewContainerRef)
private readonly templateRef = inject(TemplateRef)

To use the structure directive you already know 2 options:

  • Use the simplified syntax via *[selector] and Angular will create the container for you
  • directly create the container via <ng-template [selector]></ng-template>

The last one is optional, and it’s better always to use the abbreviated syntax. However, understanding that this directive is an abstract container that maps a pattern to a condition will help a lot in the implementation of React

React implementation

I suggest not deviating from the implementation pattern of using A/B testing in an application

  1. Provider

Based on the experience of the previous article, let’s create a provider to provide data and methods for working with A/B testing

type TAbTestFeature = 'a' | 'b';

const AbTestFeatureContext = createContext<{
feature: TAbTestFeature,
isCurrentFeature: (candidate: TAbTestFeature) => boolean
} | null>(null);

const AbTestFeatureProvider: React.FC<PropsWithChildren> = ({ children }) => {
// TODO fetch feature settings
const feature: TAbTestFeature = 'b';

const isCurrentFeature = (candidate: TAbTestFeature): boolean => {
return feature === candidate;
}

return (
<AbTestFeatureContext.Provider value={{feature, isCurrentFeature}}>{children}</AbTestFeatureContext.Provider>
);
};

const useAbTestFeatureContext = () => useContext(AbTestFeatureContext)!;

export { useAbTestFeatureContext, AbTestFeatureProvider, TAbTestFeature };

Let’s assume that the provider declaration in the application has been done, similar to providedIn: ‘root’

2. Container component for A/B testing

We know from the previous implementation that we need a container that will test the condition “whether the template is required to be rendered or not”.

Since all component tags in React are abstract containers instead of which the template will be rendered, we should have no trouble creating a component container for our purposes

type Props = { abtest: TAbTestFeature };

export const AbTestFeatureContainer: React.FC<
PropsWithChildren<Props>
> = ({ abtest: feature, children }) => {
const { isCurrentFeature } = useAbTestFeatureContext();

if (isCurrentFeature(feature)) {
return children;
}

return null;
};

Let’s break it down in order:

  • For maximum similarity, I’ll declare abtest as the input parameter, and later resave it to the feature variable. (But nothing prevents you from choosing more correct names)
type Props = { abtest: TAbTestFeature };

export const AbTestFeatureContainer: React.FC<
PropsWithChildren<Props>
> = ({ abtest: feature, children }) => {...}
  • We get a “service” by hook useAbTestFeatureContext() to work with A/B testing, we are only interested in the isCurrentFeature method
const { isCurrentFeature } = useAbTestFeatureContext();
  • Check a condition. If it is activated, the contents of the children container will be displayed. If not activated — return null, and no DOM will be created instead of the container
if (isCurrentFeature(feature)) {
return children;
}

return null;

It’s that simple 🚀

3. Component

Using this container is extremely simple and intuitive

const AbTestShowcase: FC = () => {
return (
<>
<AbTestFeatureContainer abtest='a'>
<p>Feature A</p>
</AbTestFeatureContainer>

<AbTestFeatureContainer abtest='b'>
<p>Feature B</p>
</AbTestFeatureContainer>
</>
)
}

export default AbTestShowcase

In these two examples, I haven’t used any reactive variables because A/B testing is never a dynamic feature for the application, and all settings for it are only created 1 time at startup

In Conclusion

I have only covered a small fraction of possible real-world development examples where you will need to work with DOM elements.

Angular

Attribute directives are one of the best features of this technology due to the simple API of adding logic to the current element. Their potential is much greater than just allocating logic to the element operation.

But there is another side

Structural directives are used to work with a template in a component whose functionality is very limited. To create dynamic forms or customizable components, you have to add too much logic for each action.

You can’t just insert the HTML markup of another component, you need a separate Angular entity for that. You have no flexibility in these approaches, and the code then becomes heavily overloaded with multiple logic.

The problem with Angular is not the complexity of the learning curve, where you need to know all the types of built-in directives and functions, but that it requires too much maintenance code for simple logic or actions

React

React will always have 2 main advantages — simplicity and flexibility

What’s special about React is that you can use JS syntax directly in the template. You don’t need to know about *ngIf or @if (control-flow) directives to optionally show an element. Want to use switch-case ? — no problem, return the required template from the function and take advantage of Typescript narrowing.

Also, you don’t need to worry about splitting into ngTemplateOutlet or ngComponentOutlet to customize a component, just declare an input parameter as a function that will return a ready template or React component

The advantage of using separate custom hooks in a component over attribute directives is that you work with the component indirectly through a reference, without adding new attributes or events to it.

Why ? — some developers neglect the native API of elements and may create the same name inputs and events for an element, although the area of responsibility may be different

Chapters

It’s time to talk about ways to optimize and reduce the number of re-renderers

Next

TBD: Understanding between Angular & React. Part 5: Optimization

Reading List

Understanding between Angular & React

4 stories
main image
title image
cover of story

My content is often saved to favourites, but unfortunately, Medium’s algorithms also look at the number of claps a story has.

If my content was useful, not only save it but also give your “clap” as well. This helps promote the content

--

--