How we make our base components more flexible: controllers concept in Angular

Post Editor

I want to show how we organized the system of settings for all textfields in Taiga UI with this concept and the power of Dependency Injection in Angular.

6 min read
post

How we make our base components more flexible: controllers concept in Angular

I want to show how we organized the system of settings for all textfields in Taiga UI with this concept and the power of Dependency Injection in Angular.

post
post
6 min read
6 min read

After the long development of our component library Taiga UI we noticed that some of our significant components have Angular @Inputs just to provide them into @Inputs of other base components inside. Sometimes such nesting could even be three layers deep.

We solved this problem with tricky directives that we called Controllers. They helped to remove the nesting and reduced the weight of the library.

In this article I am going to show how we organized the system of settings for all textfields in our library with this concept and the power of Dependency Injection in Angular.

Textfield in old Taiga version: a good case to try ControllersLink to this section

We have a Primitive Textfield component.

It is a styled native input with a wrapper. It does not work with Angular forms and we need it only to build other input components.

The first version of Textfield was pretty simple and was used as a base for several complicated components. But soon it started to become more complex: we added additional features and the number of properties passed down through @Inputs increased.

All @Inputs of Textfield can be separated into three groups. The first one is dynamic @Inputs that we change often: disabling the form or changing an eye icon with a closed eye icon for input password. The second one is settings that customize Textfield type: customizing size or does it have cleaner or not. And there are also tooltip settings and value two-way binding.

And we had 17 different components based on PrimitiveTextfield in our library when we started thinking about open sourcing it.

So, here came two fundamental problems:

High level components have some inputs only to provide them into Primitive Textfield without any transformations. It turned out that if we add a new @Input into Textifeld, we also need to expand all 17 components that based on it with this @Input.

Some @Inputs are used rarely, but nevertheless all components have them. And it increases the bundle weight: we add one @Input into Textfield and one for each component that is based on it. Ten projects that use our library now have an extra property that is needed only in one of them.

Well, let’s redesign it!

Splitting inputs into directives and applying them as neededLink to this section

Let’s consider @Inputs of old Textfield. There were three sets of @Inputs for showing a tooltip: [tooltipContent], [tooltipDirection] and [tooltipMode].

It is kind of separated logic and I think it’s a good sample to be refactored first. We provide content that we want to show into these inputs and Textfield has logic inside for this by hovering or focusing (accessibility for users who do not use a mouse).

So, these three inputs are provided into Textfield from other components and are used not as often. Moreover, such hints can be also used in other components, so we could make a separated Controller directive for settings of hints in our library.

<>Copy
@Directive({ selector: '[tuiHintContent]' }) export class TuiHintControllerDirective { @Input('tuiHintContent') content: PolymorpheusContent = ’’; @Input('tuiHintDirection') direction: TuiDirection = 'bottom-left'; @Input('tuiHintMode') mode: TuiHintMode | null = null; }

This is the simplest version of Controller: just three @Inputs with information that we need. Directive selector only has “tuiHintContent” because if it has no content, there is no sense to change its direction or mode.

We can already bind this directive to Textfield or any of its parent elements. Then we need to inject a directive with DI and get its data in our Textfield.

<>Copy
constructor( @Optional() @Inject(TuiHintControllerDirective) readonly hintController: TuiHintControllerDirective | null, ) { }

But there are still a couple of aspects that I would like to consider.

Now there is no change detection in Textfield component working with OnPush when we change an @Input of directive because the directive is declared above in DI tree and knows nothing about Textfield. Such @Inputs do not follow ordinary Angular behavior. Let’s make an RxJS stream that emits every time @Input of controller changes. I also like the idea to separate this stream into an abstract class Controller that all other controllers will extend.

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

Now we need to handle this change$ inside the component. The simplest way is to inject our directive and ChangeDetectorRef to call its markForCheck method after each change$ emit. It is a good option if we need a Controller for one particular component.

<>Copy
constructor( private readonly changeDetectorRef: ChangeDetectorRef, @Optional() @Inject(TuiHintControllerDirective) readonly hintController: TuiHintControllerDirective | null, ) { if (!hintController) { return; } hintController.change$ .pipe(takeUntil(this.destroy$)) .subscribe(() => { changeDetectorRef.markForCheck(); }); }

It can be used in such a way. Attention: it is not a final solution, we’ll refactor and abstract it later.

Now if we want to show a hint in Textfield, we just need to bind the directive “tuiHintContent” to component or any of its parent elements.

At this stage we cleaned up our bundle: all Textfield wrapper components do not have @Inputs needed for hint functionality to pass down. And every instance of these components does not contain extra properties.

But now it becomes harder to reuse our controller in other base components because then we need to remember and repeat the same code with change detection and unsubscription in each component. For example, I want to add HintController support for TextArea (that is not based on Textfield) too: now I need to write exactly the same code in the constructor that we see in the previous sample.

Okay, let’s abstract change detection in providersLink to this section

So, we want our component to get a controller as an object with data without extra subscriptions with change detection issues and null checking with @Optional. And we can do it with the power of DI providers in Angular.

This is what we want to get in the Textfield component:

<>Copy
constructor( @Inject(TUI_HINT_WATCHED_CONTROLLER) readonly hintController: TuiHintControllerDirective, ) {}

Let’s add TUI_HINT_WATCHED_CONTROLLER and its provider:

<>Copy
export const TUI_HINT_WATCHED_CONTROLLER = new InjectionToken('watched hint controller'); export const HINT_CONTROLLER_PROVIDER: Provider = [ TuiDestroyService, { provide: TUI_HINT_WATCHED_CONTROLLER, deps: [[new Optional(), TuiHintControllerDirective], ChangeDetectorRef, TuiDestroyService], useFactory: hintWatchedControllerFactory, }, ]; export function hintWatchedControllerFactory( controller: TuiHintControllerDirective | null, changeDetectorRef: ChangeDetectorRef, destroy$: Observable<void>, ): Controller { if (!controller) { return new TuiHintControllerDirective(); } controller.change$.pipe(takeUntil(destroy$)).subscribe(() => { changeDetectorRef.markForCheck(); }); return controller; }

When we inject such a token into our component, it will automatically add a subscription to changes inside the factory. We’ll add this HINT_CONTROLLER_PROVIDER into “providers” of the Textfield component so “deps” get the actual ChangeDetectorRef and TuiDestroyService. This is a simple service that we provided above hint provider that binds with ngOnDestroy of the component injector and calls the “next” method of itself being also a Subject (if you didn’t get it, just visit a link with implementation).

We just need to add the provider and inject our new token:

<>Copy
@Component({ //... providers: [HINT_CONTROLLER_PROVIDER,], }) export class TuiPrimitiveTextfieldComponent { constructor( //... @Inject(TUI_HINT_WATCHED_CONTROLLER) readonly hintController: TuiHintControllerDirective, ) {} }

Well, now we can bind the directive to Textfield or any component or element that has Textfield inside. Change detection of Textfield will be called after every change of @Input in directive due to safe subscription in the factory.

It is very convenient to work with a Controller in Textfield: we just get a ready entity from DI tree and use it in template or getter not worrying about change detection, subscriptions or its existence.

I see one potential improvement here: hintWatchedControllerFactory can be designed as a common factory that can work with all controllers. We did this after adding the second type of Controller in the library but the current solution is okay for now.

What’s next?Link to this section

We considered just one simple case of making a controller. But Textfield also has a bunch of settings that we separated into a sophisticated controller that works with any level of nesting: we can set one @Input on Textfield, another one on its parent component and the third on the whole form for all Textfields inside. Moreover, every @Input can be reassigned on any level of nesting. And all this is made with default pure Angular DI, albeit using it to the maximum.

I am ready to write one more article about it but first I want to understand if there are people who want to read it. If you are interested in it, let me know!

FinallyLink to this section

Due to a few dozens of code lines and several tricks with Angular DI we reduced repeating of our code, prettified components and decreased the weight of the library itself and all applications that use it.

This solution is not easy to understand and can be excessive in some cases. But in our situation it helped us simplify a big part of our package with a small set of clever DI usage and neat API.

Discuss with community

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

NxAngularCli
NxAngularCli
NxAngularCli

Featured articles