DEV Community

Nina Torgunakova for Evil Martians

Posted on

Nano Stores in Angular: how to make the state management simpler

TLDR: Angular now has integration for Nano Stores, an open source state manager based on the idea of atomic tree-shakable stores and direct manipulation. It is very small (from 334 bytes), has zero dependencies, and promotes moving logic from components to stores. With such stores, you don't need to call the selector function for all components on every store change, which makes them noticeably fast.

Here is a small example of how we can use Nano Stores in an Angular component:

import { Component } from '@angular/core';
import { atom } from 'nanostores';
import { NanostoresService } from '@nanostores/angular';
import { Observable, switchMap } from 'rxjs';

const user$ = atom<{ id: string; name: string; }>(
  { id: '0', name: 'John'}
);

@Component({
  selector: "app-root",
  template: '<p *ngIf="(currentUser$ | async) as user">{{ user.name }}</p>'
})
export class AppComponent {
  currentUser$: Observable<IUser> = this.nanostores.useStore(user$);

  constructor(private nanostores: NanostoresService) { }
};
Enter fullscreen mode Exit fullscreen mode

How can the appearance of Nano Stores in Angular improve our developer experience with state management? Let's start from the beginning.

Nowadays the concept of state management is crucial for client-side development: we need a reliable source of truth to manage data in our applications.

And in Angular, there exist specific approaches to manage state. In this area, there are a lot of implementation details that we’ve all become accustomed to over the years.

What do we have now?

When developing projects with Angular, we usually manage data state in the services, not in components or modules. But, using this approach, when the number of features increases, the number of services also increases and management becomes increasingly complicated. Because of that, we need a convenient approach to handle this process, which should also be predictable in order to reduce the number of possible bugs.

Angular developers have typically been used to implementing state management logic using RxJS or concepts like NgRx/NGXS inspired by Redux, because these were the most popular approaches. Let's try to write a simple application with state management using these instruments and understand if they are really convenient to use.

RxJS

Inside Angular, we already have a built-in option: RxJS (Reactive Extensions for JavaScript), a library for reactive programming, remains the most popular solution to compose callback-based or asynchronous code. We can also handle changing the state of our project by using RxJS operators, and it is quite a common solution, and isn’t too bad for many cases.

Let's build a familiar example: a TODO List application. We’ll consider three functions for the list of tasks: adding a new task, deleting an existing task, and toggling the status of the task from undone to done, or vice versa.

Here’s our HTML:

<input #newTask/>
<button (click)="addTask(newTask.value)">Add</button>
<ul class="todo-list" *ngIf="(tasks$ | async) as tasks">
  <li *ngFor="let task of tasks">
    <button (click)="toggleStatus(this.task.id)">Toggle Status</button>
    <span [ngClass]="{'done': this.task.isDone }">{{this.task.name}}</span>
    <button (click)="deleteTask(this.task.id)">Delete</button>
  </li>
</ul>
Enter fullscreen mode Exit fullscreen mode

Then, we can define a service with basic methods for state management (which we can extend in the future with a more detailed implementation):

import { BehaviorSubject, distinctUntilChanged, map, Observable } from 'rxjs';

// This type will be passed when extending the StateService
export class StateService<T> {
  // This field stores the current state's value and emits it to the new subscribers
  protected state$: BehaviorSubject<T>;

  // Initializes the state$ with some initial value
  constructor(initialState: T) {
    this.state$ = new BehaviorSubject<T>(initialState);
  }

  // Returns the current value from the state
  get state(): T {
    return this.state$.getValue();
  }

  // Emits the new state value
  setState(newState: Partial<T>) {
    this.state$.next({...this.state,...newState });
  }

  /**  Called when state$ emits a new value.
  Operator distinctUntilChanged() will skip emissions
  until the piece of state obtain a new value or object reference */
  select<K>(mapFn: (state: T) => K): Observable<K> {
    return this.state$.asObservable().pipe(
      map((state: T) => mapFn(state)),
      distinctUntilChanged()
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that there are no mentions of the actual TODO tasks in this part of the code. So, you can use this service later for new features that are not related to the TODO list.

Now we can extend StateService with a more practical service, TodosStateService:

@Injectable()
export class TodosStateService extends StateService<{ tasks: Task[] }> {
  /** In the constructor we call the constructor of StateService and pass the initial state (empty array).
   * Injected instance of service with method getTasks() expose an API to receive the tasks. */
  constructor(service: TodoListService) {
    super({ tasks: [] });
    service.getTasks().subscribe(tasks => this.state$.next({ tasks }));
  }

  getTasks(): Task[] {
    return this.state.tasks;
  }

 // Exposes the corresponding state data to subscribers
  todos$: Observable<Task[]> = this.select(state => state.tasks);
}
Enter fullscreen mode Exit fullscreen mode

Here’s the structure for a TODO list Task:

type Task = {
  id: string;
  name: string;
  isDone: boolean;
}
Enter fullscreen mode Exit fullscreen mode

In the todoList.component.ts we need to inject instances of two services: TodosStateService and TodoListService.
TodoListService is a service that calls API methods for receiving, adding, deleting, and changing tasks:

constructor(
    private todosStateService: TodosStateService,
    private service: TodoListService,
  ) { }
Enter fullscreen mode Exit fullscreen mode

And we need to subscribe to $todos in the TodosStateService. We can do this by declaring the component field like this:

tasks$: Observable<Task[]> = this.todosStateService.todos$;
Enter fullscreen mode Exit fullscreen mode

But don't forget to unsubscribe from this subscription when the component has been destroyed to prevent possible memory leaks. (You can do this any way you prefer, for example using RxJS operator takeUntil.)

Finally, let's implement actions with our tasks in the list: adding, deleting, and status toggling. I also suggest trying to implement this using the optimistic UI pattern: we’ll update the UI even before receiving a server response:

addTask(name: string) {
    const newTask = {id: '...', name, isDone: false};
    this.todosStateService.setState({tasks: [...this.todosStateService.getTasks(), newTask]});
    this.service.addTask(newTask);
}

deleteTask(id: string) {
    this.todosStateService.setState({
        tasks: this.todosStateService.getTasks().filter(task => task.id !== id)
    });
    this.service.deleteTask(id);
}

toggleStatus(id: string) {
    this.todosStateService.setState({ tasks: this.todosStateService.getTasks()
        .map(task => ({...task, isDone: task.id === id ? !task.isDone : task.isDone }))
    });
    this.service.toggleStatus(id);
}
Enter fullscreen mode Exit fullscreen mode

These functions will invoke the setState method in the service that maintains the state of tasks.

That's all for the RxJS approach; it’s scalable but the biggest drawback is the large number of routine tasks. We need to create an entirely new service for the whole app, then create a new one that extends it each time when we need a feature with new entities.

NgRx

Let's next try to develop the same state management example with NgRx, a very popular state management library for Angular. It was inspired by Redux and based on such concepts as Stores, Actions, Effects, and Reducers.

First of all, we’ll create a new file with actions. In NgRx, Actions are unique events that happen throughout the application. For simplicity in this case, we can define actions for a single task (adding, deleting, toggling status), and also the action to receive all tasks from the server (we can do it also by Effects, but let's stick with the easiest approach for this small example):

import { createAction, props } from '@ngrx/store';

export const LoadTasks = createAction('Load Tasks', props<{tasks: Task[]}>());
export const Add = createAction('Add', props<{task: Task}>());
export const Delete = createAction('Delete', props<{id: string;}>());
export const ToggleStatus = createAction(Toggle Status', props<{id: string;}>());
Enter fullscreen mode Exit fullscreen mode

And we need the Reducer for the tasks that will manage the state of our tasks:

import { createReducer, on } from '@ngrx/store';

export const taskReducer = createReducer([],
  on(LoadTasks, (_, action) => (action.tasks)),
  on(Add, (state, action) => ([...state, action.task])),
  on(Delete, (state, action) => state.filter(task => task.id !== action.id)),
  on(ToggleStatus, (state, action) => state.map(task => ({...task, isDone: task.id === action.id ? !task.isDone : task.isDone }))),
);
Enter fullscreen mode Exit fullscreen mode

(Did you notice we already have a lot of boilerplate for simple things?)

We need to add the StoreModule.forRoot function in the imports array of NgModule and pass the object that contains the taskReducer. The StoreModule.forRoot() method registers the providers needed to access the global store throughout the application.

import { StoreModule } from '@ngrx/store';
imports: [
    StoreModule.forRoot({todoState: taskReducer}),
    ...
],
Enter fullscreen mode Exit fullscreen mode

Well, now we can create the component with tasks. We have exactly the same HTML that we had for RxJS example, but the code in component.ts will be different.

After creating the new component, we need to inject the instance of TodoListService (we already used this in the previous example), and the Store service to dispatch the task actions:

constructor(private service: TodoListService, private store: Store<{ todoState: Array<Task> }>) { }
Enter fullscreen mode Exit fullscreen mode

Then, we can subscribe to task changes in the ngOnInit method to receive the tasks from the API:

ngOnInit() {
    this.service.getTasks().subscribe(tasks => todos$.set(tasks));
}
Enter fullscreen mode Exit fullscreen mode

Keep in mind that, in this case, we also probably want to unsubscribe from the subscription when the component has been destroyed.

We’re nearly finished. Let's add a field to the component with the actual state of tasks. We need to select them from the store using the select method we recently created:

tasks$: Observable<Task[]> = this.store.select(state => state.todoState);
Enter fullscreen mode Exit fullscreen mode

With regards to Optimistic UI, all we need to do is to add the following code for the actions with tasks:

addTask(name: string) {
    const newTask = {id: '...', name, isDone: false};
    todos$.set([...todos$.get(), newTask]);
    this.service.addTask(newTask);
}

deleteTask(id: string) {
    todos$.set(todos$.get().filter(task => task.id !== id));
    this.service.deleteTask(id);
}

toggleStatus(id: string) {
    todos$.set(todos$.get().map(
        task => ({...task, isDone: task.id === id ? !task.isDone : task.isDone })
    ));
    this.service.toggleStatus(id);
}
Enter fullscreen mode Exit fullscreen mode

All in all, we have changes in four different files, module.ts, component.ts, actions.ts, and reducer.ts (and we haven't even used Effects for even more simplicity.) We already have a lot of repeating actions to build just the basic features, imagine the result if we needed to add extended ones.

Other than that, NgRx is a heavy library for your bundle and we need to spend time to properly understand how all these concepts, like Actions, Reducers, Selectors, and so on, work.

But we can try another option to manage the state and make our life simpler.

Nano Stores

Let's try the same example with the TODO List application to see how to use Nanostores in this simple case — and how convenient it can be.

First of all, we need to provide injection tokens in our NgModule to make the Nano Stores work:

import { NANOSTORES, NanostoresService } from '@nanostores/angular';

@NgModule({ providers: [{ provide: NANOSTORES, useClass: NanostoresService }], ... })
Enter fullscreen mode Exit fullscreen mode

Then we need to create a new atomic store to hold the tasks. The initial state for the tasks will be the empty array:

import { atom } from 'nanostores';
const todos$ = atom<Task[]>([]);
Enter fullscreen mode Exit fullscreen mode

And here we also can define functions that we will invoke to update the value in our store:

export const addTask = (newTask: Task) => {
 todos$.set([...todos$.get(), newTask]);
}

export const deleteTask = (id: string) => {
 todos$.set(todos$.get().filter(task => task.id !== id));
}

export const toggleStatus = (id: string) => {
 todos$.set(todos$.get().map(
   task => ({...task, isDone: task.id === id ? !task.isDone : task.isDone })
 ));
}
Enter fullscreen mode Exit fullscreen mode

It's time to create the component with tasks (the HTML is all the same as in the previous section). We will inject the instance of our usual TodoListService, and also the instance of NanostoresService:

constructor(private service: TodoListService, private nanostores: NanostoresService) { }
Enter fullscreen mode Exit fullscreen mode

Now we can add a field into the component with the actual state of tasks using the useStore method in the injected instance of NanostoresService:

tasks$: Observable<Task[]> = this.nanostores.useStore(todos$);
Enter fullscreen mode Exit fullscreen mode

And then, in ngOnInit, we can subscribe to the task changes method to receive the tasks from the server side and set them to the store:

ngOnInit() {
    this.service.getTasks().subscribe(tasks => todos$.set(tasks));
}
Enter fullscreen mode Exit fullscreen mode

All that is left to do is to write the part of the component where we’ll call the functions that interact with the store (which we already wrote), and invoke the API methods:

addTask(name: string) {
    const newTask = {id: '...', name, isDone: false};
    addTask(newTask);
    this.service.addTask(newTask);
}

deleteTask(id: string) {
    deleteTask(id);
    this.service.deleteTask(id);
}

toggleStatus(id: string) {
    toggleStatus(id);
    this.service.toggleStatus(id);
}
Enter fullscreen mode Exit fullscreen mode

And that's all. No additional files, utility services, and no boilerplate. Don't forget to unsubscribe from the initial subscription, as was described in previous sections.

Why is Nanostores the handiest way to manage state?

After solving this simple task with three different approaches and seeing the brevity and beauty of Nanostores, let's sum up the results. Why is Nanostores the best option to manage the state of an application in Angular?

  • It was designed to move logic from components to stores – less logic in components, clearer and more predictable development process
  • No boilerplate, short and clear syntax
  • It’s much smaller than NgRx and its analogs. For instance, even the base NgRx package @ngrx/store, has a minified bundle size 3 times more than nanostores and its integration combined (using the bundlephobia metric) No complex concepts like Reducers, Effects, or Selectors, which must be deeply understood before using. You just need to deal with small atomic stores
  • Nanostores has a growing community and its own developing ecosystem: we already have a tiny router for stores, a persistent store, and a tiny data fetcher

I believe that with open source projects like this, Angular can soon get rid of boilerplate, complex definitions, and huge packages. Try using Nanostores in your Angular project to check out all these advantages for yourself.

Top comments (0)