Reactive application without Redux / NgRx

  • Tutorial


Today we will examine in detail the reactive angular application ( repository on github ), written entirely on the OnPush strategy . Another application uses reactive forms, which is quite typical for an enterprise application.

We will not use Flux, Redux, NgRx and instead take advantage of the capabilities already available in Typescript, Angular and RxJS. The fact is that these tools are not a silver bullet and can add unnecessary complexity even to simple applications. We are honestly warned about this by one of the authors Flux , the author Redux and the author NgRx .

But these tools give our applications very nice features:

  • Predictable data flow;
  • Support OnPush by design;
  • The immutability of data, the lack of accumulated side effect and other pleasant things.

We will try to get the same characteristics, but without introducing additional complexity.

As you will see by the end of the article, this is a fairly simple task - if you remove the details of the work of Angular and OnPush from the article, then only a few simple ideas remain.

The article does not offer a new universal pattern, but only shares with the reader several ideas that, for all their simplicity, for some reason did not occur at once. Also, the developed solution does not contradict and does not replace Flux / Redux / NgRx. They can be connected if this really becomes necessary .

For a comfortable reading of the article, an understanding of the terms smart, presentational and container components is needed .

Action plan


The logic of the application, as well as the sequence of presentation of the material can be described in the following steps:

  1. Separate read (GET) and write (PUT / POST) data
  2. Load state as a stream in container component
  3. Distribute State across the hierarchy to OnPush components
  4. Report Angular on component changes.
  5. Edit encapsulated data

To implement OnPush, we need to parse all the ways to launch change detection in Angular. There are only four such methods, and we will consistently consider them in the course of the article.

So let's go.

Separate read and write data


For the interaction of the frontend and backend applications, typically typed contracts are used (otherwise why are there typescript?).

The demo project we are considering does not have a real backend, but it contains the pre-prepared description file swagger.json . On its basis, typescript contracts are generated by the sw2dts utility .

The generated contracts have two important properties.

First, reading and writing are performed using different contracts. We use a small agreement and name reading contracts with the suffix “State”, and write contracts with the suffix “Model”.

By sharing contracts in this way, we share the flow of data in the application. From top to bottom, the read-only state extends across the component hierarchy. To change the data, a model is created, which is initially filled with data from the state, but exists as a separate object. After editing is completed, the model is sent to the backend as a command.

The second important point is that all State fields are marked with the readonly modifier. This is how we get support for typescript immunity. Now we can not accidentally change the state in the code or bind to it using [(ngModel)] - when we compile the application in the AOT mode, we get an error.

Load state as a stream in container component


To load and initialize the state, we will use ordinary angular services. They will be responsible for the following scenarios:

  • A classic example is downloading via HttpClient using the id parameter received by the component from the router.
  • Initializing an empty state when creating a new entity. For example, if the fields have default values ​​or for initialization, you need to request additional data from the backend.
  • Reloading an already loaded state after a user performs an operation that changes data on the backend.
  • Reset state by push notification, for example, when editing data together. In this case, the service is merging the local state and the state obtained from the backend.

In the demo application, we consider the first two scenarios as the most typical. Also, these scenarios are simple and will allow the service to be implemented as simple stateless objects and not be distracted by complexity that is not the subject of this particular article.

An example of a service can be found in the file some-entity.service.ts .

It remains to get the service via DI in the container component and load the state. This is usually done like this:

route.params
    .pipe(
        pluck('id'),
        filter((id: any) => {
            return !!id;
        }),
        switchMap((id: string) => {
            return myFormService.get(id);
        })
    )
    .subscribe(state => {
        this.state = state;
    });

But with this approach, two problems arise:

  • You must unsubscribe manually from the created subscription, otherwise a memory leak will occur.
  • If you switch a component to the OnPush strategy, it will stop responding to data loading.

Async pipe comes to the rescue . He listens to the Observable directly and will write off from him when necessary. Also, when using async, pipe Angular automatically launches a change detection each time the Observable publishes a new value.

An example of using an async pipe can be found in the template of the some entity entity of the component .

And in the component code, we carried the repeated logic into custom RxJS operators, added a script for creating an empty state, merging both sources of State into one stream with the merge operator, and creating a form for editing, which we will consider later:

this.state$ = merge(
            route.params.pipe(
                switchIfNotEmpty("id", (requestId: string) =>
                    requestService.get(requestId)
                )
            ),
            route.params.pipe(
                switchIfEmpty("id", () => requestService.getEmptyState())
            )
        ).pipe(
            tap(state => {
                this.form = new SomeEntityFormGroup(state);
            })
        );

This is all that was required to do in the container component. And we put in the piggy bank the first way to trigger change detection in the OnPush component - async pipe. It is useful to us more than once.

Distribute State across the hierarchy to OnPush components


When we need to display a complex state, we create a hierarchy of small components - this is how we struggle with complexity.

As a rule, components are divided into a hierarchy similar to a data hierarchy, and each component receives its own piece of data through Input-parameters to display them in a template.

Since we are going to implement all the components as OnPush, let's digress for a while and discuss what it is and how Angular works with OnPush components. If you already know this material - feel free to scroll to the end of the section.

During compilation of the application, Angular generates for each component a special change detector class that “remembers” all the bindings used in the component template. At runtime, the created class starts checking the saved expressions at each change detection cycle. If the check shows that the result of any expression has changed, then Angular will redraw the component.

By default, Angular knows nothing about our components and cannot determine which components will be affected, for example, by the newly triggered setTimeout or the completed AJAX request. Therefore, he is forced to check the entire application completely literally for every event inside the application - even a simple window scroll repeatedly launches the change detection for the entire hierarchy of application components.

Here lies the potential source of performance problems — the more complex the component templates, the harder the change detector checks. And if there are a lot of components and the checks are run frequently, then the change detection begins to take considerable time.

What to do?

If a component does not depend on any global effects (by the way, it is better to design components like this), then its internal state is determined by:

  • Input parameters ( @Input );
  • Events that occurred in the component itself ( @Output ).

Let us put aside the second point for now and assume that the state of our component depends only on the input parameters.

If all Input parameters of the component are immutable objects, then we can mark the component as OnPush. Then, before launching change detection, Angular will check if the references to the Input parameters of the component have changed since the last check. And, if they have not changed, Angular will skip the change detection for the component itself and all its child components.

Thus, if we build our entire application using the OnPush strategy, we will eliminate a whole class of performance problems from the very beginning.

Since the State in our application is already immutable, immutable objects are also passed to the Input parameters of the child components. That is, we are already ready to enable OnPush for child components and they will react to state changes.
For example, these are components of readonly-info.component and nested-items.component.

Now let's figure out how to implement a change in the component's own state in the OnPush paradigm.

Speak with Angular about your condition


Presentation state is the parameters that are responsible for the appearance of the component: loading indicators, flags of the visibility of elements or the availability to the user of an action, glued together from three fields into one line of the user's full name, etc.

Every time the presentation state of the component changes, we must notify Angular so that it can display the changes on the UI.

Depending on what is the source of the component state, there are several ways to notify Angular.

Presentation state, calculated on the basis of Input-parameters


This is the easiest option. We place the logic of computing the presentation state in the ngOnChanges hook. Change detection will start by changing the @ Input-parameters. In the demo application it is readonly-info.component .

export class ReadOnlyInfoComponent implements OnChanges {
    @Input()
    public state: Backend.SomeEntityState;
    public traits: ReadonlyInfoTraits;
    public ngOnChanges(changes: { state: SimpleChange }): void {
        this.traits = new ReadonlyInfoTraits(changes.state.currentValue);
    }
}

Everything is extremely simple, but there is one point that should be paid attention to.

If the presentation state of a component is complex, and especially if some of its fields are calculated on the basis of others, also calculated by Input-parameters, take the state of the component into a separate class, make it immutable and re-create each time ngOnChanges is started. In the demo project, an example is the class ReadonlyInfoComponentTraits . Using this approach, you protect yourself from having to synchronize dependent data when it changes.

At the same time, it is worth thinking: perhaps the component has a complex state due to the fact that it contains too much logic. A typical example is an attempt to accommodate views for different users in one component, which have very different ways of working with the system.

Component's own events


For communication between application components, we use Output-events. This is also the third way to launch change detection. Angular reasonably assumes that if a component generates an event, then something could change in its state. Therefore, Angular listens to all Output-events of components and launches change detection when they occur.

The demo project is completely synthetic, but the example is the component submit-button.component , which throws the formSaved event . The container component subscribes to this event and displays an alert with a notification.

Use Output-events should be as intended, that is, to create them for communication with the parent components, and not to launch change detection. Otherwise, there is a possibility, after months and years, not to remember why this event is not necessary for anyone, and to remove it, breaking everything.

Changes in smart components


Sometimes the state of a component is determined by complex logic: an asynchronous call to a service, a connection to a web socket, checks running via setInterval, and there is little else. Such components are called smart components.

In general, the smaller the application will be smart components, which in this case are not container components - the easier it will be to live. But sometimes you can not do without them.

The easiest way to associate the state of a smart component with change detection is to turn it into an Observable and use the async pipe already discussed above. For example, if the source of change is a service call or a reactive form status, then this is already a ready Observable. If the state is formed from something more complicated, you can use fromPromise ,websocket , timer , interval from RxJS. Or generate the stream yourself using Subject .

If none of the options fit


In cases where none of the three methods already studied is suitable, we are left with a bulletproof option - use ChangeDetectorRef directly. We are talking about the detectChanges and markForCheck methods of this class.

Documentation is comprehensive answers to all questions, so we will not dwell on his work. But note that the use of ChangeDetectorRef should be limited to cases where you clearly understand what you are doing, as this is still an Angular internal kitchen.

For all the time we have found only a few cases where this method may be needed:

  1. Manual work with change detection is used when implementing low-level components and is just the case “you clearly understand what you are doing”.
  2. Complicated relationships between components — for example, when you need to create a link to a component in a template and pass it as a parameter to another component that is higher in the hierarchy or even in another branch of the component hierarchy. Sounds hard? And there is. And it’s better to simply code this code, because it will deliver pain not only with change detection.
  3. Specific behavior of Angular itself - for example, when implementing a custom ControlValueAccessor, you may encounter that the change in the value of the control is performed by the Angular asynchronously and the changes are not applied to the desired change detection cycle.

As examples of use in a demo application, there is a base class OnPushControlValueAccessor , which solves the problem described in the last paragraph. Also in the project there is an inheritor of this class - a custom radio-button.component .

Now we have discussed all four ways to launch change detection and OnPush implementation options for all three component types: container, smart, presentational. We proceed to the final point - data editing with reactive forms.

Edit encapsulated data


Reactive forms have a number of limitations, but still this is one of the best things that happened in the Angular ecosystem.

First of all, they well encapsulate work with the state and provide all the necessary tools to respond to changes in a reactive manner.

In fact, the reactive form is a sort of mini-store that encapsulates work with the state: data and disabled / valid / pending statuses.

It remains for us to support this encapsulation as much as possible and to avoid mixing the presentation logic and the logic of the form operation.

In the demo application, you can see individual classes of forms that encapsulate all the specifics of their work: validation, creation of FormGroup children, working with the disabled state of input fields.

We create the root form in the container component at the time of loading the state and each time the state is restarted, the form is created anew. This is not a prerequisite, but so we can be sure that there are no accumulated effects in the logic of the form remaining from the previous loaded state.

Inside the form itself, we construct controls and “push” the received data on them, converting them from the State contract to the Model contract. The structure of the forms, as far as possible, coincides with the contracts of the models. As a result, the value property of the form gives us a ready model for sending to the backend.

If the state or model structure changes in the future, we will get a typescript compilation error in exactly the place where we need to add / remove fields, which is very convenient.

Also, if the state and model objects have an absolutely identical structure, then the structural typing used in typescript saves us from having to build meaningless mapping of one into another.

So, the logic of the form is isolated from the presentation logic in the components and lives “by itself”, without increasing the complexity of the data flow of our application as a whole.

This is almost all. Border cases remain when we cannot isolate the logic of the form from the rest of the application:

  1. Changes in shape that change the presentation state — for example, the visibility of a data block depending on the value entered. We implement in the component by subscribing to form events. You can do this through the immutable traits discussed earlier.
  2. If you need an asynchronous validator that calls a backend, in the component we construct AsyncValidatorFn and pass it to the form designer, not the service.

Thus, the entire "borderline" logic remains in the most prominent place - in the components.

findings


Let's summarize what we got and what else there are moments for studying and development.

First of all, the development of the OnPush strategy forces us to thoughtfully design the data flow of the application, since now we dictate the rules of the game to Angular, and not to us.

The consequences of this situation are two.

First, we get a pleasant feeling of control over the application. There is no more magic that “works somehow”. You clearly understand what is happening at any time in your application. Intuition is gradually developing, which allows you to understand the cause of the found bug, even before you opened the code.

Secondly, now we have to spend more time designing an application, but the result will always be the most “direct”, which means the simplest solution. This clearly brings to zero the likelihood of a situation when, as it grows, the application turned into a monster of immense complexity, developers lost control of this complexity and the development now looks more like mystical rites.

Controlled complexity and the absence of “magic” reduce the likelihood of a whole class of problems arising, for example, with cyclical data updates or accumulated side effects. Instead, we deal with noticeable problems already in development, when the application simply does not work. And perforce it is necessary to make the application work simply and clearly.

We also mentioned the good consequences for performance. Now, using very simple tools, such as profiler.timeChangeDetection , we can check at any time that our application is still in good shape.

Also now it's a sin not to try disabling NgZone . First, it will allow you not to load the whole library when the application starts. Secondly, it will remove a fair amount of magic from your application.

On this we end our narration.

We will be in touch!

Also popular now: