DEV Community

Alexander Goncharuk for This is Angular

Posted on • Updated on

11 friends of state management in Angular

Intro

What you find below is an opinionated list of rules based on trial and error working on Angular projects of different type and size. After making some mistakes and over-complicating things where they shouldn't be complicated, I came up with this list of rules that works well for all recent projects I have contributed to. And I hope these make enough sense to be shared with the community.

The rules provide ❌ Avoid and ✅ Prefer code snippets where possible, as well as some reasoning behind each recommendation. The first two rules are more foundational, and hence I want to provide a bit more explanation for them. The rest are mostly short practical recommendations.

💡 Note this article doesn't delve into the world of signals. Signals are great and open path for a whole new way of dealing with synchronous reactivity in Angular apps. No doubt these are going to impact the state management topic a lot. But I believe we need more time for best practices to emerge in this area. Hence for this read choose to focus on those recommendations that are well tested and battle and which I can vouch for.

Rule 1: Use dedicated tools for state management

Angular is a framework that provides a lot of tools out of the box to build complex applications. Which often makes devs question the fact we need any dedicated libraries for managing state. At first glance, this makes sense. Why introduce more dependencies and complexity when we have services, Subject and BehaviorSubject and an army of RxJs operators? I would like to start with clearing this question up by saying you should use a library. And explain how I came to this conclusion.

Indeed, there are several ways of implementing management using Angular and RxJs APIs only without any help from the dedicated libraries. But the problem is, this fact alone opens a little Pandora's box for our codebase maintainability over time. Consider we have a team of several developers, and they worked on the different features of our app:

First developer relies on simple class properties and imperative state updates.

public synchronousState: State = initialState;
Enter fullscreen mode Exit fullscreen mode

Second developer is more into reactive programming and uses Subject and BehaviorSubject to manage state in
the feature they've built:

public behaviourSubjectState$ = new BehaviorSubject<State>(initialState);

public subjectState$ = new Subject<State>();
Enter fullscreen mode Exit fullscreen mode

Third developer is even more into reactive programming, so one will multicast the state using shareReplay operator to make sure every consumer won't create a new observable:

public
subjectStateMulticasted$ = this.subjectState$.pipe(
  shareReplay({ shareReplay: 1, refCount: true })
);
Enter fullscreen mode Exit fullscreen mode

Fourth developer will do the same, but one is not that much into Angular and RxJs, so is unaware of all the tips
and tricks for shareReplay operator and will not use refCount:

public anotherSubjectStateMulticated$ = this.subjectState$.pipe(
  shareReplay(1)
);
Enter fullscreen mode Exit fullscreen mode

A quick off-topic for this one. Under certain conditions, which are not that hard to meet, such shareReplay usage will result in a memory leak and maybe even browser crashing with an "Out of memory" error caused by subscriptions to source observable create by shareReplay never being unsubscribed. RxJs is great, but with great power comes great responsibility. I will not go more into details here, but you can read about the mentioned shareReplay behaviour in RxJS: What’s Changed with shareReplay?.

Fifth developer is coming from the server side world mostly with years of object-oriented programming experience. So will prefer private fields with getters and setters:

private _getterAndSetterState: State = initialState;

public get getterAndSetterState(): State {
  return this._getterAndSetterState;
}

public set getterAndSetterState(state): void {
  return this._getterAndSetterState = state;
}
Enter fullscreen mode Exit fullscreen mode

And below are a couple of examples of the side effects management.

A simple method that calls some other methods:

public simpleMethodForSideEffect(state: State): void {
  const data = this.someService.calculateData();
  this.someOtherService.doSomethingWithData(data);
}
Enter fullscreen mode Exit fullscreen mode

A long-living subscription that listens to a trigger observable and calls some other methods:

public observableListenerForSideEffect$ = this.someSubject$.pipe(
  tap(() => {
    const data = this.someService.calculateData();
    this.someOtherService.doSomethingWithData(data);
  })
).subscribe();
Enter fullscreen mode Exit fullscreen mode

The features built by our developers will likely need to both hold some state and manage some side effects, basically any CRUD use case qualifies for this. So we can multiply these 5 × 2 since nothing forbids us to mix and match them. And then let's multiply by two once again, because some devs will put state management and side effects in component classes, but some will put them in services. Some of these services will likely have providedIn: 'root', which means created once for particular screens user navigated to they will stay living in memory regardless of if they're needed further or not. Others will be bound to the lifecycle of components they serve to.

This is not an extensive list of all the combinations you can have. The approach of every made-up developer is not necessarily bad. But the thing is, in every scenario listed below:

  • I start working on a new feature.
  • I need to dive into an existing feature built by someone else to debug it to fix an issue or to introduce a change.
  • A new developer joins the team and wants to get going fast.

All these different ways of doing the same thing we have across the app will be my enemy. The codebase becomes hard to reason about and there is no "go-to" solution for a new feature implementation because you get lost in a variety of existing approaches and are likely to pick the wrong one only because that is how it was done in a .ts file located nearby.

It sounds like a bit "captain Obvious" type of conclusion. But from my observations, the idea to just use BehaviourSubject turns out to be very appealing up till the late point of realizing that codebase has become huge and hard to refactor. It is not always a bad idea to avoid external dependency. But trust my experience, state and side effects management is no the area where you want to implement your own wheel.

It also turns out to be likely to overestimate your and your teammates' ability to keep things consistent and follow the agreed way of doing things. Don't put that extra burden on yourself and your team. Delegate it to a well-maintained library with simple APIs. Find more on this ⬇️.

Rule 2: Always prefer local state unless you have a good reason to use global state

💡To reason about global versus local state approach, this section compares concrete implementations of both: NGRX global store and NGRX component store. These are both part of @ngrx libraries family and are the two most popular libraries used for state management in framework ecosystem according to npm statistics. While the code examples provided below could also be implementation agnostic, I found this would be a somewhat artificial generalization for the sake of generalization. And I intentionally wanted this article to be a collection of practical recommendations. The main local vs global reasoning still applies to other implementations of your choice, just not all the details from the examples below will be applicable.

@ngrx/store vs @ngrx/component-store

Before I dive into the benefits of local store, I would like to say that I'm not an opponent of global state as a concept. Neither am I an opponent of NGRX global store as the most well known and widely used implementaton of it in the Angular ecosystem. I don't find the necessity to create feature.actions.ts, feature.reducer.ts, feature.effects.ts that big of a burden as some people do. Moreover, I'm grateful to the NGRX team for all the great work they've done over the years, and the effort they put into making the global store library simpler to use, like introducing createActionGroup.

From what I've read on this topic, it seems to me there is a misconception in the community that these files exist
to have a state management solution only. Which makes people who prefer fewer files think of these as an overkill.
But in fact, it is not only about state management and unidirectional data flow. The great value actions bring is indirection between the action dispatcher, and the part of your system that processes this action. Different parts of your system can react to it differently. Some environments your app runs in can choose to ignore some actions not relevant for them. This gives you a lot of control to make your codebase more flexible. I had an experience building an app for brokers and traders who used it both a browser app and a desktop app running in an Electron-based wrapper. The classical global NGRX store was a great fit for this app thanks to the indirection it offers.

If you need this indirection, the global store is the right choice for you. But in most projects, I very likely don't need it. And what I need instead is a simple but consistent solution to exposing public APIs for reading and manipulating the state, and triggering side effects. I want state selectors, updators, and effects to reside next to each other in the same place to keep related things close. And I want the state to be local, in other words, I want it to be created and destroyed along with the component tree using this state. A common pattern is to provide it in the component providers:

@Component({
  providers: [FeatureStore],
  template: `
    @for(item of store.items$ | async; track item.id) {
      {{...}}
    }
    <button (click)="store.removeItem(item.id)">Remove item</button>
    <button (click)="store.loadItems()">Refresh</button>
  `
})
export class FeatureComponent implements OnInit {
  public store = inject(FeatureStore);
}
Enter fullscreen mode Exit fullscreen mode

And from my experience, usually this is all you need for state management without introducing extra complexity and coupling component trees with root app-level providers. It is easier to ensure data freshness thanks to state being bound to component lifecycle. It is easier to refactor local state to global state when necessary than the other way around.

When necessary, this approach also makes it simple to re-use code across different components sharing the same state/side effects logic. Each component gets an instance of the store configured to its. Configuration can be done in different ways, by using DI tokens, for example. But here is an example of a simple configuration object passed to the store provider factory:

export class ListDataStore extends ComponentStore<ListDataState> {
  constructor(private config: Partial<ListDataState>) {
    super({
      ...initialState,
      ...config
    });
  }
}

@Component({
  providers: [{
    provide: ListDataStore,
    useFactory: () => new ListDataStore({ 
      pageSize: DEFAULT_PAGE_SIZE 
    })   
  }],
})

@Component({
  providers: [{
    provide: ListDataStore,
    useFactory: () => new ListDataStore({ 
      sortBy: DEFAULT_SORT_SIZE, 
      sortDirection: DEFAULT_SORT_DIRECTION 
    })   
  }],
})
Enter fullscreen mode Exit fullscreen mode

You can thus share all the special logic for list data manipulation you have in your system. But at the same time, leave space for customization for each particular feature need. This is a very powerful and very underused pattern for logic sharing in Angular. You can read more on the power of private providers in Make the most of Angular DI: private providers concept.

I will not copy-paste component-store APIs description here, but will provide a link to the [official docs (https://ngrx.io/guide/component-store/initialization) instead. Just a couple more points related to this library that I believe are worth highlighting:

  • All state selectors have distinctUntilChanged and shareReplay by default, so you don't have to remember to add them every time.
  • The library is tiny: 1.9kB minified + gzipped.

Rule 3: Limit component code by view-related logic only

For components that contain a fair amount of business logic, prefer moving state and side effects logic to component stores. Besides the importance of consistency, this makes components cleaner and easier to maintain in several ways:

  • Enables using DI instead of input drilling to pass data to child components that belong to the same feature and need to work with the top-level state.
  • Makes it easier to use the same logic somewhere else if necessary.
  • Makes unit testing easier since you're testing just TypeScript classes as opposed to testing TestBeds imitating components with all related complexities.
  • Makes accessing state reactive by convention.

Avoid

@Component({
  ...
})
export class FeatureComponent implements OnInit {
  private cd = inject(ChandeDetectorRef);
  private dataService = inject(DataService);

  public data: Data;
  public filteredData: Data;
  public filterCriteria: FilterCriteria;

  ngOnInit() {
    this.loadData();
  }

  private loadData(): void {
    this.dataService.getData().pipe(
      tap((data) => {
        this.data = newData;
        this.applyFilter();
      })
    ).subscribe();
  }

  private apllyFilter(): void {
    this.filteredData = this.data.filter(item => item.name === this.filterCriteria);
    this.cd.detectChanges();
  }
}
Enter fullscreen mode Exit fullscreen mode
<div *ngFor="let item of filteredData">
...
</div>
Enter fullscreen mode Exit fullscreen mode

Prefer

export class FeatureStore extends ComponentStore<FeatureState> {
  private dataService = inject(DataService);

  constructor() {
    super({ data: [], filterCriteria: null});
  }

  public data$ = this.select(state => state.data);
  public filterCriteria$ = this.select(state => state.filterCriteria);
  public filteredData$ = this.select(
    this.data$,
    this.filterCriteria$,
    (data, filterCriteria) => data.filter(item => item.name === filterCriteria)
  );

  public loadData = this.effect(trigger$ => trigger$.pipe(
    switchMap(() => this.dataService.getData()),
    tap(data => this.setData(data)),
  ));
}

@Component({
  ...
})
export class FeatureComponent {
  public featureStore = inject(FeatureStore);

  ngOnInit() {
    this.featureStore.loadData();
  }
}
Enter fullscreen mode Exit fullscreen mode
<div *ngFor="let item of featureStore.filteredData$ | async">
...
</div>
Enter fullscreen mode Exit fullscreen mode

While the recommended approach can sometimes require a bit more lines of code, it becomes much easier to maintain:

  • Data dependencies are expressed in a declarative and reactive manner.
  • Public methods exposed by the store are available for both the feature container component and all its children.
  • Observables are efficient and memory safe. Effects are auto subscribed when the store host component is initialized and auto unsubscribed when one is destroyed.

Cases when the state belongs to the component

While the component store is a lightweight state management solution that can be applied to both complex and simple features and even to generic UI controls that are rich enough with stateful logic, the rule to use it is not set in stone. Common sense should always prevail.

Small presentational components and simple UI controls

Consider a simple UI control like this:

@Component({
  selector: 'app-slider',
  ...
  @Input({required: true}) state: boolean;
  @Input() infoTooltipText: string;
  @Output() stateChange = new EventEmitter<boolean>();
  //... formControl implementation
})
Enter fullscreen mode Exit fullscreen mode

For small presentational components and simple controls like this, introducing a separate class for state management would be an overkill. It makes more sense to have it in the component class itself.

This particular example is a bit artificially simplified, you can have more data in the component. The key indicator to use a dedicated store is the need to manipulate this data (combine, filter, etc.) and handle side effects as part of the logic encapsulated in this component.

Data aggregation is very specific to this component use case only

When a component store is used for a tree of components, we want to keep logic that is already or will potentially be shared by different components of the tree in the store class. But at the same time, we don't want to bloat the store class with all possible use cases handling.

If we have a component like this that shows a very specific loading indicator, that should consider a couple of factors:

public secondResourceLoadingStep$ = combineLatest([
  this.store.firstResourceHasLoaded$,
  this.store.secondResourceHasLoaded$,
]).pipe(
  ([firstResourceLoaded, secondResourceLoaded]) => firstResourceLoaded && !!secondResourceLoaded
)
Enter fullscreen mode Exit fullscreen mode

We might want to have it in the class of the component displaying the indicator instead of handling it in the store
class. Unlike firstResourceHasLoaded$ and secondResourceHasLoaded$ selectors, which we might need in different places, this indicator is very specific to this component's use case and is a read-only operation, which makes it a good candidate to put closer to the view where it is used.

Other use cases

A general rule to follow when deciding whether a particular piece of business logic to the store or component class is to ask yourself the following questions:

  • Is the same logic needed in other parts of this component tree?
  • Does this logic only make sense in the context of the particular component template? For example, depends on its specific HTML structure.

When logic is coupled to the view, it makes sense to have it in the component class.

Rule 4: Always separate stateful and stateless services

Don't mix stateful and server API logic in the same class. Data fetching/manipulation logic and app state are separate concerns that should be separated on the code level as well.

Avoid

export class FeatureStore extends ComponentStore<FeatureState> {
  private httpClient = inject(HttpClient);
  //...
  public loadData = this.effect((trigger$) =>
    trigger$.pipe(
      switchMap(() => this.httpClient.get<Data>(`${this.baseUrl}/data`)),
      //...
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

Prefer

export class FeatureStore extends ComponentStore<FeatureState> {
  private dataService = inject(DataService);
  //...
  public loadData = this.effect((trigger$) =>
    trigger$.pipe(
      switchMap(() => this.dataService.getData()),
      //...
    ),
  );
}

export class DataService {
  public getData(): Observable<Data> {
    return this.httpClient.get<Data>(`${this.baseUrl}/data`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Rule 5: Access state synchronously when it makes sense

RxJs is a great tool that helps to make applications reactive and express data relations in a declarative manner, but one is not a silver bullet for every use case.

While RxJs shines when it comes to asynchronous data streams, sometimes all we need is to get a slice of our state synchronously. Utilizing observable for this does nothing else but makes the code more complex. In this case, it is perfectly fine to use synchronous getters selectors available in the component store via a private get method of the component store.

It is important to ensure reactivity serves us, not the other way around. When the code looks like you're working RxJs around, prefer synchronous logic instead:

Avoid

public doSomethingInUserContext(): void {
  this.userStore.userId$.pipe(
    take(1),
    tap(userId => {
        //... logic using userId here
    }),
    this.untilDestroyed()
  ).subscribe();
}

// or:

public async doSomethingInUserContext(): Promise<void> {
  const userId = await this.userStore.userId$.pipe(
    take(1)
  ).toPromise();
  //... logic using userId here
}
Enter fullscreen mode Exit fullscreen mode

Prefer

public doSomethingInUserContext(): void {
  const userId = this.userStore.userId; // where userId is a synchronous getter in the store class that uses component store `get` under the hood
}
Enter fullscreen mode Exit fullscreen mode

Rule 6: Do not create "proxy methods" for no good reason

When you need to expose a store method to the component template, a common approach is to create a method in the component class that just calls the store method. This is very common to meet this approach in store usage examples, both global and component store, and even in official docs. And this is a valid approach when you have some extra logic to run before control is passed to the store.

But when you don't, these are just extra lines of code that make the codebase a bit more verbose and refactoring a bit longer without any real benefits. And it is even more relevant for state selectors in scenarios when there is no need to apply any processing to the data returned by the selector.

Avoid

@Component({
  providers: [FeatureStore],
  template: `
    @for(item of items$ | async; track item.id) {
      {{...}}
    }
    <button (click)="removeItem(item.id)">Remove item</button>
    <button (click)="loadItems()">Refresh</button>
  `
})
export class FeatureComponent implements OnInit {
  private store = inject(FeatureStore);
  private items$ = this.store.items$;

  public removeItem(id: string): void {
    this.store.removeItem(id);
  }

  public loadItems(): void {
    this.store.loadItems();
  }
}
Enter fullscreen mode Exit fullscreen mode

Prefer

@Component({
  providers: [FeatureStore],
  template: `
    @for(item of store.items$ | async; track item.id) {
      {{...}}
    }
    <button (click)="store.removeItem(item.id)">Remove item</button>
    <button (click)="store.loadItems()">Refresh</button>
  `
})
export class FeatureComponent implements OnInit {
  public store = inject(FeatureStore);
}
Enter fullscreen mode Exit fullscreen mode

There is a well-known practice to keep services private and expose their public interface to the component template via public methods. But in the case of component stores, I prefer to see component store provider as an extension of the component class itself that embraces logic composition. So it is up to store itself to know which its methods to expose as public to consuming components and which to keep private.

And if there is a good reason to create this method in the component class, you can always do this. But don't create this proxy code just for the sake of conforming to "providers are always private" rule.

Rule 7: Prefer less verbose APIs whenever possible

For most state update operations when you don't need to run any logic in the updater, you can use patchState:

Avoid

public setElement = this.updater((state: BlockSettingsState, element: BlockElement) => ({
  ...state,
  element,
}));
Enter fullscreen mode Exit fullscreen mode

Prefer

private setElement(element: BlockElement): void {
  this.patchState({element});
}
Enter fullscreen mode Exit fullscreen mode

Rule 8: Remember about multicasting observables returned by selectors

While shareReplay is added to every selector of the component store by default, you lose it for successive computations when you apply pipe to your selector. Combining selectors is the best solution in this scenario:

Avoid

public combinedSelector$ = this.data$.pipe(
  withLatestFrom(otherData$),
  map(([data, otherData]) => expensiveComputations(data, otherData)) // will run for every subscriber in this case
Enter fullscreen mode Exit fullscreen mode

Prefer

public combinedSelector$ = this.select(
  this.data$,
  this.otherData$,
  (data, otherData) => expensiveComputations(data, otherData))
);
Enter fullscreen mode Exit fullscreen mode

If the combined selector solution doesn't fit for any reason, add shareReplay operator at the end of the pipe:

.pipe(
 //... other operators
 shareReplay({refCount: true, bufferSize: 1}),
);
Enter fullscreen mode Exit fullscreen mode

Rule 9: Use state management APIs, but do not limit yourself to it

While consistency is the goal, it doesn't mean in the store class we should limit ourselves to the library APIs only. Library APIs like updaters, selectors and effects cover most of our needs, but it is often convenient and absolutely fine to extract some logic into simple private methods of the same component store class.

Another common use case is when part of the state should live in a reactive FormGroup which provides great built-in APIs for manipulating and validating form controls. In this case, you create a FormGroup as a property of the store class and use its API in store selectors and effects when necessary.

import { combineLatest } from 'rxjs';
import { debounceTime, shareReplay } from 'rxjs/operators';
this.filtersForm = new FormGroup({
  name: new FormControl('', [Validators.required]),
  //...
});

this.users$ = this.select((state) => state.users);
this.filteredUsers$ = combineLatest(this.users$, this.filtersForm.name.valueChanges.pipe(debounceTime(200))).pipe(
  map(([users, filtersName]) => users.filter((user) => user.name.includes(filtersName))),
  shareReplay({ refCount: true, bufferSize: 1 }),
);
Enter fullscreen mode Exit fullscreen mode

Rule 10: Embrace pure functions and static methods

If methods don't use class instance properties at all, there is no reason to make them instance methods. Prefer pure functions or static methods instead. This approach gives you several benefits:

  • Saves memory if many instances of this component are created at the same time (e.g., in a list or table)
  • Makes code easy to cover with unit tests since there is no need to create a component instance, mock providers for its DI, etc.

Avoid

private square(value: number): number {
  return value * 2;
}
Enter fullscreen mode Exit fullscreen mode

Prefer

export const square = (value: number): number => {
  return value * 2;
};
Enter fullscreen mode Exit fullscreen mode

The choice between pure functions living in a separate file like feature.fn.ts next to the component file and static methods of the component class is up to you. As a rule of thumb, pure functions should be the preferable choice since these are easier to test and reuse. But sometimes there is not enough stateless logic to justify creating a separate file for
it. In this case, static methods of the component class can be a reasonable choice as well.

Rule 11: Make sure you handle errors in effects

Because an effect is long-living observable, you should make sure errors thrown inside the operator chain are handled correctly to keep effects alive.

In most real-world scenarios when an error is thrown inside the operator chain, you don't want to stop the entire observable, which would happen without correct error handling. Use catchError operator and return of(<DATA PLACEHOLDER>) or EMPTY to keep the observable alive.

Avoid

public listenToSomeTrigger(): void {
  this.someTrigger$.pipe(
    switchMap(() => this.someService.getData().pipe(
      map((data) => ...), // error can occur here, but no error handling is provided
    )),
    tap(() => ...),
  ).subscribe();
Enter fullscreen mode Exit fullscreen mode

Prefer

public listenToSomeTrigger(): void {
  this.someTrigger$.pipe(
    switchMap(() => this.someService.getData().pipe(
      map((data) => ...),
      catchError(error => {
        ...
        return of([]);
      }),
    )),
    tap(() => ...)
  ).subscribe();
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Error should be handled in the inner observable chain (created with switchMap) to keep the outer observable alive.

For operators outside inner observables, like the ones created by switchMap or mergeMap, make sure you wrap data manipulations done by the methods called by these operators in try/catch blocks and return meaningful data placeholders when necessary.

Conclusion

Hope these rules help you to make your codebase more consistent and easier to maintain. These work well for me, but I'm still receptive to any feedback that leads to a reasonable discussion. So if you have anything to say or ask, don't
hesitate to do so in the comments and I will be happy to get back.

Top comments (3)

Collapse
 
jangelodev profile image
João Angelo

Thanks for sharing !

Collapse
 
tmish profile image
Timur Mishagin • Edited

Great article! I am happy to know there are people who share the same skillful vision upon the state management 👍

Collapse
 
londeren profile image
Sergey Lebedev

Thank you for the useful tips regarding state management🤩