35

Simple state management in Angular with only Services and RxJS

 5 years ago
source link: https://www.tuicool.com/articles/hit/imAFbq6
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Currently there are several state management libraries for Angular apps: NGRX, NGXS, Akita... All of them have different styles of managing state, the most popular being NGRX , which pretty much follows the FLUX/Redux principles from React world.

But what if you don't want to learn, setup, and use an entire state management library for a simple project, what if you want to manage state by only using tools you already know well as an Angular developer, and still get the performance optimisations that state management libraries provide (On Push Change Detection, one way immutable data flow).

In this write up, I'll show you a simple way of managing state by only using RxJS and Dependency Injection , all of our component tree will use OnPush change detection strategy.

Imagine we have simple Todo app, and we want to manage its state, we already have our components setup and now we need a service to manage the state, let's create a simple Angular Service:

// todos-store.service.ts

@Injectable({provideIn: 'root'})
export class TodosStoreService {


}

So what we need is, a way to provide a list of todos, a way to add todos, remove, filter, and complete them, we'll use getters/setters and RxJS's Behaviour Subject to do so:

First we create ways to read and write in todos array:

// todos-store.service.ts

@Injectable({provideIn: 'root'})
export class TodosStoreService {

  // - We set the initial state in BehaviorSubject's constructor
  // - Nobody outside the Store should have access to the BehaviorSubject 
  //   because it has the write rights
  // - Writing to state should be handled by specialized Store methods (ex: addTodo, removeTodo, etc)
  // - Create one BehaviorSubject per store entity, for example if you have TodoGroups
  //   create a new BehaviorSubject for it, as well as the observable$, and getters/setters
  private readonly _todos = new BehaviorSubject<Todo[]>([]);

  // Expose the observable$ part of the _todos subject (read only stream)
  readonly todos$ = this._todos.asObservable().pipe(
    /** shareReplay does two things, caches the last emitted value, 
        so components that subscribe after a value been emitted can still display the value,
        and shares the same observable between all observers, 
        instead of creating new observables on each subscription
    */
    shareReplay(1) 
  )


  // the getter will return the last value emitted in _todos subject
  get todos(): Todo[] {
    return this._todos.getValue();
  }


  // assigning a value to this.todos will push it onto the observable 
  // and down to all of its subsribers (ex: this.todo = [])
  set todos(val: Todo[]) {
    this._todos.next(val);
  }

  addTodo(title: string) {
    // we assaign a new copy of todos by adding a new todo to it 
    // with automatically assigned ID ( don't do this at home, use uuid() )
    this.todos = [
      ...this.todos, 
      {id: this.todos.length + 1, title, isCompleted: false}
    ];
  }

  removeTodo(id: number) {
    this.todos = this.todos.filter(todo => todo.id !== id);
  }


}

Now let's create a method that will allow us to set todo's completion status:

// todos-store.service.ts


  setCompleted(id: number, isCompleted: boolean) {
    let todo = this.todos.find(todo => todo.id === id);

    if(todo) {
      // we need to make a new copy of todos array, and the todo as well
      // remember, our state must always remain immutable

      const index = this.todos.indexOf(todo);
      this.todos[index] = {
        ...todo,
        isCompleted
      }
      this.todos = [...this.todos];
    }
  }

And finally an observable source that will provide us with only completed todos:

// todos-store.service.ts

// we'll compose the todos$ observable with map operator to create a stream of only completed todos
  readonly completedTodos$ = this.todos$.pipe(
    shareReplay(1),
    map(todos => this.todos.filter(todo => todo.isCompleted))
  )

Now, our todos store looks something like this:

// todos-store.service.ts


@Injectable({providedIn: 'root'})
export class TodosStoreService {

  // - We set the initial state in BehaviorSubject's constructor
  // - Nobody outside the Store should have access to the BehaviorSubject 
  //   because it has the write rights
  // - Writing to state should be handled by specialized Store methods (ex: addTodo, removeTodo, etc)
  // - Create one BehaviorSubject per store entity, for example if you have TodoGroups
  //   create a new BehaviorSubject for it, as well as the observable$, and getters/setters
  private readonly _todos = new BehaviorSubject<Todo[]>([]);

  // Expose the observable$ part of the _todos subject (read only stream)
  readonly todos$ = this._todos.asObservable().pipe(
    /** shareReplay does two things, caches the last emmited value, 
        so components that subscribe after a value been emmited can still display the value,
        and shares the same observable between all observers, 
        instead of creating new observables on each subscription
    */
    shareReplay(1) 
  )


  // we'll compose the todos$ observable with map operator to create a stream of only completed todos
  readonly completedTodos$ = this.todos$.pipe(
    shareReplay(1),
    map(todos => this.todos.filter(todo => todo.isCompleted))
  )

  // the getter will return the last value emitted in _todos subject
  get todos(): Todo[] {
    return this._todos.getValue();
  }


  // assigning a value to this.todos will push it onto the observable 
  // and down to all of its subsribers (ex: this.todo = [])
  set todos(val: Todo[]) {
    this._todos.next(val);
  }

  addTodo(title: string) {
    // we assaign a new copy of todos by adding a new todo to it 
    // with automatically assigned ID ( don't do this at home, use uuid() )
    this.todos = [
      ...this.todos, 
      {id: this.todos.length + 1, title, isCompleted: false}
    ];
  }

  removeTodo(id: number) {
    this.todos = this.todos.filter(todo => todo.id !== id);
  }

  setCompleted(id: number, isCompleted: boolean) {
    let todo = this.todos.find(todo => todo.id === id);

    if(todo) {
      // we need to make a new copy of todos array, and the todo as well
      // remember, our state must always remain immutable

      const index = this.todos.indexOf(todo);
      this.todos[index] = {
        ...todo,
        isCompleted
      }
      this.todos = [...this.todos];
    }
  }

}

Now our smart components can access the store and manipulate it easily:

export class AppComponent  {
  constructor(public todosStore: TodosStoreService) {}
}
<div class="all-todos">

  <p>All todos</p>

  <app-todo 
    *ngFor="let todo of todosStore.todos$ | async"
    [todo]="todo"
    (complete)="todosStore.setCompleted(todo.id, $event)"
    (remove)="todosStore.removeTodo($event)"
  ></app-todo>
</div>

And here is the complete and final result:

Full working example on StackBlitz

This is a scalable way of managing state too, you can easily inject other store services into each other by using Angular's powerful DI system, combine their observables with pipe operator to create more complex observables, and inject services like HttpClient to pull data from your server for example. No need for all the NGRX boilerplate or installing other State Management libraries. Keep it simple and light when you can.

Follow me on Twitter for more interesting Angular related stuff: https://twitter.com/avatsaev


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK