The controllers of component concept in Angular: part II

Post Editor

The second part of how we organized the system of settings for all textfields in Taiga UI with the power of Dependency Injection in Angular.

7 min read
2 comments
post

The controllers of component concept in Angular: part II

The second part of how we organized the system of settings for all textfields in Taiga UI with the power of Dependency Injection in Angular.

post
post
7 min read
2 comments
2 comments

Some time ago I published an article “How we make our base components more flexible: controllers concept in Angular”. The article explored our process of how we make our components more flexible and reduce code repetition with some Dependency Injection interesting techniques.

This article is a direct sequel to that one. This time we’ll take a look at even more interesting techniques that we use directives-controllers for to make our libraries and apps more flexible, light-weight and powerful.

In this article we’ll try to design a metacontroller that consists of other controllers. Get ready to explore infinite possibilities of Dependency Injection in Angular.

Be very attentive: in this article “input” means a textfield component to input data and “@Input” means Angular decorator to pass properties into the component.

Let’s refresh the context of the task

It all started with the case when we had a lot of specific input components that were based on one basic component — PrimitiveTextfield.

All these high-level components have @Inputs just to provide them directly into @Inputs of the textfield. Nevertheless, some of the @Inputs needed to be reassigned. So, we just made many of the same @Inputs in each of them although it might be needed in one project out of ten. And each new input component made the support of that scheme more complicated.

I decided to group all @Inputs of the base component:

The first group is dynamic @Inputs that are often reassigned in runtime: at some moment the field is disabled, after a minute it is not.

The last group with tooltip settings was explored in the first article. We solved it with directives-controllers and not only for the textfield component but also for all the components with a tooltip.

In this article I will discuss the second group. It consists of @Inputs that are set once at the start and are not going to change later. Still, we want to allow component users to set them at any time and on any level of application.

What do we want
Link to this section

Let’s take a look at InputTime component:

It sets to PrimitiveTextfield two inputs: [customContent] with a clock icon and  [filler] for understanding time format. They are set in InputTime. But other settings of PrimitiveTextfield remain intect, the user of the component can still set a cleaner, size, etc.

We want to have the ability to combine different settings from different levels of components hierarchy. For example, we set the clock icon inside InputTime, one developer uses it and adds a cleaner, the other one sets “L” size for all inputs of the form. It would be cool to combine all these settings and get them as one entity from DI in the PrimitiveTextfield.

Let’s create directive-controller
Link to this section

Each directive will be responsible for adding one setting to the textfield. This is a sample of directive that controls adding cross of cleaner:

<>Copy
@Directive({ selector: '[tuiTextfieldCleaner]', providers: [ { provide: TUI_TEXTFIELD_CLEANER, useExisting: forwardRef(() => TuiTextfieldCleanerDirective), }, ], }) export class TuiTextfieldCleanerDirective extends Controller { @Input('tuiTextfieldCleaner') cleaner = false; }

The directive extends Controller class that we created in the previous article:

<>Copy
export abstract class Controller implements OnChanges { readonly change$ = new Subject<void>(); ngOnChanges() { this.change$.next(); } }

Pay attention, directive provides itself into DI with a personal token. The token is declared in the same file:

<>Copy
export const TUI_TEXTFIELD_CLEANER = new InjectionToken<TuiTextfieldCleanerDirective>( 'tuiTextfieldCleaner', {factory: cleanerDirectiveFactory}, ); export function cleanerDirectiveFactory(): TuiTextfieldCleanerDirective { return new TuiTextfieldCleanerDirective(); }

We’ll use such tokens later to combine them all into one big controller. Why do we need a token instead of injecting a directive directly? The main reason is that token has a factory. If there is no directive in the whole DI tree, we get a directive instance with default values.

Organizing controllers
Link to this section

I like to separate each controller into its own file. It allows me to find any entity very fast.

They all are exported from one Secondary Entry Point and are declared in TextfieldControllerModule to simplify its usage by developers.

If we want to add one new setting to all the inputs, we just need to create a new file and declare a couple of entities inside.

Creating a metacontroller

It is not practical to inject each particular controller into Textfield and manage its change detection. That is why let’s combine them all into one big metacontroller for Textfield that will have actual values of each setting and take care of its refreshing inside a private provider.

<>Copy
export class TuiTextfieldController { constructor( readonly change$: Observable<void>, private readonly autocompleteDirective: TuiTextfieldAutocompleteDirective, private readonly cleanerDirective: TuiTextfieldCleanerDirective, // other directives... ) {} get autocomplete(): TuiAutofillFieldName | null { return this.autocompleteDirective.autocomplete; } get cleaner(): boolean { return this.cleanerDirective.cleaner; } // other directives... }

Metacontroller is just a class that injects all directives and a stream of their change detections. If someone passes new value into an @Input in one of directives, we recalculate getters and actualize settings.

We create an instance of the class in DI provider with a factory:

<>Copy
export const TUI_TEXTFIELD_WATCHED_CONTROLLER = new InjectionToken<TuiTextfieldController>( 'watched textfield controller', ); export const TEXTFIELD_CONTROLLER_PROVIDER: Provider = [ TuiDestroyService, { provide: TUI_TEXTFIELD_WATCHED_CONTROLLER, deps: [ ChangeDetectorRef, TuiDestroyService, TUI_TEXTFIELD_AUTOCOMPLETE, TUI_TEXTFIELD_CLEANER, TUI_TEXTFIELD_CUSTOM_CONTENT, TUI_TEXTFIELD_EXAMPLE_TEXT, TUI_TEXTFIELD_INPUT_MODE, TUI_TEXTFIELD_LABEL_OUTSIDE, TUI_TEXTFIELD_MAX_LENGTH, TUI_TEXTFIELD_SIZE, TUI_TEXTFIELD_TYPE, ], useFactory: textfieldWatchedControllerFactory, }, ];

We collect the values of tokens and send them into the factory. It does nothing unusual either:

<>Copy
export function textfieldWatchedControllerFactory( changeDetectorRef: ChangeDetectorRef, destroy$: Observable<void>, ...controllers: [ TuiTextfieldAutocompleteDirective, TuiTextfieldCleanerDirective, TuiTextfieldCustomContentDirective, TuiTextfieldExampleTextDirective, TuiTextfieldInputModeDirective, TuiTextfieldLabelOutsideDirective, TuiTextfieldMaxLengthDirective, TuiTextfieldSizeDirective, TuiTextfieldTypeDirective, ] ): TuiTextfieldController { const change$ = merge(...controllers.map(({change$}) => change$)).pipe( takeUntil(destroy$), tap(() => changeDetectorRef.markForCheck()), ); change$.subscribe(); return new TuiTextfieldController(change$, ...controllers); }

We then merge change streams of all controllers and subscribe to them. It is cool that we can also add a safe unsubscription after destroy thanks to TuiDestroyService from taiga-ui/cdk.

Using in Textfield
Link to this section

Now we need to connect the metacontroller to PrimitiveTextfield component:

<>Copy
@Component({ // …, providers: [TEXTFIELD_CONTROLLER_PROVIDER], }) export class TuiPrimitiveTextfieldComponent { constructor( @Inject(TUI_TEXTFIELD_WATCHED_CONTROLLER) readonly controller: TuiTextfieldController, ) {} get hasCleaner(): boolean { return ( this.controller.cleaner && this.hasValue && !this.disabled && !this.readOnly ); } // ... }

We’ve already solved all change detection issues. So, now we can use the controller as usual service from DI. Textfield does not need to know about how it works inside and we always have the ability to replace it in DI if we need it.

Textfield component gets an entity that is collected from nearest directive-controllers found in the DI tree. It means that we can set some setting on the whole form level and then reassign it for the particular input. DI resolves it from bottom to top and finds a particular input setting first.

Benefits of this approach
Link to this section

We created many new entities and, at first glance, overcomplicated the architecture of the library a bit, but let’s take a look at new possibilities that it adds to library users.


Flexibility
Link to this section

Now we have very flexible customization. For example, we want to make a form with five inputs of “L” size and without labels ([labelOutside]=“true”).

Before: we add controls into form and pass two @Inputs “size” and “labelOutside” for each of them with the same value. Controls do not need them and that is why they just pass them further into Textfield. It does not add any new logic, just makes the bundle size bigger.

After: we can set directives for “size” and “labelOutside” on the whole form and they will be applied for all the controls in the form thanks to DI hierarchical structure. Or we can set the directive straight to the tui-root component and all the controls of the application will have “L” size by default.

Controls know nothing about their size or label. Data from DI is used only by basic components in the place where we need it.

DI tricks
Link to this section

Angular DI is very powerful. We can set entities, replace, remove or shuffle them. We can make reusable providers that will modify default controller behavior.

For example, we have a controller with dropdown settings. Dropdown has a width of its content by default. But there are also some components where we want a dropdown to take the size of its host. We can just create a provider for such components that reassigns default value:

<>Copy
export function fixedDropdownControllerFactory( directive: TuiDropdownControllerDirective | null, ): TuiDropdownControllerDirective { directive = directive || new TuiDropdownControllerDirective(); directive.limitWidth = 'fixed'; return directive; } export const FIXED_DROPDOWN_CONTROLLER_PROVIDER: Provider = [ { provide: TUI_DROPDOWN_CONTROLLER, deps: [[new Optional(), TuiDropdownControllerDirective]], useFactory: fixedDropdownControllerFactory, }, ];

If we add such a provider into the component's providers, its factory will be called when the component asks for a dropdown controller. It modifies the default value of the setting or creates a directive instance (if there is still no such directive in DI tree). The idea is that we reassign only the default value. If the user binds the directive somewhere and passes this property, Angular will resolve @Input after all DI is structured, so, the user will see their value.

Lightweight
Link to this section

Dozens of our components had the same typical @Inputs that they didn’t even need. Now all these @Inputs are hidden in DI. Components are easy to read because they have only their own properties. Moreover, removing these @Inputs reduces the bundle size of the library itself and of all applications that use it. It even makes the amount of used RAM in runtime just a little bit smaller.

Show me the code
Link to this section

If you want to explore all the shown code in detail, you can do it on real cases of Taiga UI:

Finally
Link to this section

Such a tricky combination of entities may look difficult but you easily get used to it after a couple of real use cases of controllers. Ultimately we do not even use any unusual tools, just Angular directive, DI-tokens and factories.

Such a pattern will be excessive for small applications or libraries but it can give you tangible benefits and help to save high speed of growth without accumulating technical debt on some level of scale.

Follow me on Twitter to see more content, tips & tricks about Angular: @marsibarsi

Comments (2)

authoralexsherekin
8 November 2021

Thank you for a great article Roman.

Maybe I'm wrong and miss some context or don't know about some specific use cases, but the whole mechanism looks for me similar to how CSS cascade and CSS custom properties work. If the bundle size is so critical, didn't you consider using more CSS capabilities provided by browsers?

authorMarsiBarsi
15 November 2021

Hey 👋

Yes, we can compare the hierarchy with CSS custom properties and it's a good idea to use CSS custom properties if we need to customize CSS only. And such directives replace Angular @Inputs: if you can replace your Angular @Input with CSS property, it is better to do it anyway

authorrip222
15 November 2021

I followed the whole guide and copy pasted all of the code, created a component and injected the controller into it, but it's not doing anything. Also in the template I added <my-component tuiHintContent="test"></my-component>.

It does not even seem to run. Maybe I'm missing something? Should I bundle this controller with specific type of components like textfield?

authorMarsiBarsi
15 November 2021

Hey 👋

Could you create a stackblitz of this case? I'll take a look and explain the problem :)

Share

About the author

author_image

Frontend Developer at Tinkoff.ru

author_image

About the author

Roman Sedov

Frontend Developer at Tinkoff.ru

About the author

author_image

Frontend Developer at Tinkoff.ru

Looking for a JS job?
Job logo
Senior Full-Stack Developer (Node+Angular)

A-listware

Ukraine
Remote
$60k - $66k
Job logo
Full Stack Java/Angular Developer

Black Knight

Worldwide
Remote
$70k - $90k
Job logo
Angular Developer

Ziras Technologies

United States
Remote
$58k - $145k
More jobs
NxAngularCli
NxAngularCli
NxAngularCli

Featured articles