One of the most challenging things in software development is state management. 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 (basically using one way data flow and immutable data structures but with RxJS observable streams).
But what if you don't want to learn, setup, use an entire state management library, and deal with all the boilerplate 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 and coherency that state management libraries provide (On Push Change Detection, one way immutable data flow).
DISCLAIMER: This is not a post against state management libraries. We do use NGRX at work, and it really helps us to manage very complex states in very big and complex applications, but as I always say, NGRX complicates things for simple applications, and simplifies things for complex applications, keep that in mind.
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:
// 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();
// 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.todos = [])
private 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
// otherwise, on push change detection won't work, and won't update its view
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(
map(todos => 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();
// we'll compose the todos$ observable with map operator to create a stream of only completed todos
readonly completedTodos$ = this.todos$.pipe(
map(todos => 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.todos = [])
private 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
// otherwise, on push change detection won't work, and won't update its view
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:
(PS: Instead of managing immutability by hand, I'd recommend using something ImmutableJS)
// app.component.ts
export class AppComponent {
constructor(public todosStore: TodosStoreService) {}
}
<!-- app.component.html -->
<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 example on StackBlitz with a real REST API
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
Top comments (67)
Exactly what I'm trying to do in my app. An advice: don't put HTTP, async services, etc. into the store. Keep it separate. State management has nothing to do with such services and business logic. Ngrx Effects is a terrible mix of two concepts.
You're right, side effects must always be separated from state management, this was a quick example, i'll try to clean it up when I have some free time.
I've almost made a career out of reducing the complexity and size of Angular 2+ apps by appropriately using services and RxJs instead of convoluted, home-grown solutions to state management.
My resume:
Very well put together, succinct article!
We don't use NgRX at my workplace, but we do have a couple of data source services.
They kind of just grew organically.
I see the benefits of following your rules of immutability, having a private
behaviourSubject
, and the getter and setter. Also the second readonly observable which pipes thebehaviourSubject
is very nice.Is the term
storeService
a widely used suffix?I currently use
dataSourceService
.Thanks!
nope it's up to you to name it, I usually name it something like TodosStore
I think I prefer the suffix store over dataSource.
Also the ngrx stuff talks about stores a lot. Seems to be a popular term.
This looks really nice. After 4ish projects across React and Angular, I have yet to find the Redux pattern remotely worth it... But I'll say that has a lot to do with particular executions...
Regardless, this seems like this would make life a lot easier. :)
One question, tho: is the
shareReplay
incompletedTodos$
not redundant? It would callshareReplay
twice in a row in the pipe.To be honest I'm not sure, I've put it for good measure, but theoretically yes, i didn't have to use shareReplay on filtering considering that the original source is already multicast, I'll do some tests to confirm, and remove it later.
As a few others have pointed out, this is basically Akita. Akita gives you all the basic crud methods you need to start managing either an array of entities (todos) or a single entity, but with none of the boilerplate required by ngrx or ngxs. There's almost no setup, and in addition to getting a rxjs-based approach to state management, you also get all the other bells and whistles, like action tracking with the redux dev tools, time travelling, action tracing, etc. I started out with rxjs stores, and then tried ngrx, ngxs, went back to rxjs stores, then finally found akita, and I see no reason to ever not use it-
github.com/datorama/akita
Hi, some perf prob from Akita ? medium.com/@vpranskunas/deep-compa...
I figure out if you get these probs too ?
This is exactly what I do in my project. I trialled ngrx/store but found it so overly bloated with a ridiculous amount of boilerplate for very little gain. This pattern is simple, elegant, easy to reason about.
Give ngrx/data a look for a more streamlined version of ngrx/store. It's excellent for managing collections of objects. It provides almost all of the functionality you might need out of the box and is based on configuration so you end up writing very little logic.
Honestly, unless your app is extremely complicated, anyone using a state management library for Angular has misunderstood the component life cycle and is pretty much trying to make Angular into a React application.
Hey Aslan! I wrote a similar article on DEV. It is inspired by yours! thanks!
dev.to/angular/simple-yet-powerful...
TLDR Let’s create our own state management Class with just RxJS/BehaviorSubject (inspired by some well known state management libs).
why use behavior subject with sharereplay?
get todos() is also overkill. It's not a good way to get data from observer, except in async pipe. For adding todo we should use scan operator, same as for mark as completed. Subject with shareReplay (or ReplaySubject) solves all the problems and BehaviorSubject is really redundant here.
Thanks for the feedback, updated.
Accessors for the same member name must specify the same accessibility
todos getter/setters must either both be public or both be private. My preference is they are private, so that publicly, only the observable is accessible.
Another alternative is make private set _todos and public get todos.
You probably already know better than me which options are best, but thought I throw some options out there for others that come across this.
Thanks for the excellent example. Do you by any chance have an example for todosGroup and todos, and how they would work with each other when navigating from group to single todo?
Thank you for sharing. I value your input as a developer and person. :D
I notice that the StackBlitz project refers to an undefined trackBy function:
todosTrackFn
I can't see any problem that it is causing. The project compiles ok. TSLint flags the problem in my vscode edit buffer.
yep it's a typo, fixed it on stackblitz
Thanks for the article! Some points/questions for someone who is quite new to Angular:
todosTrackFn
. Didn't know that existed!Thanks for this post, it's help us very well.
When you define the "readonly completedTodos$" in the service, you use the getter (this.todos) in the map operator to filter the stream. What doesn't use directly the "todos" var entry from the map operator like this:
readonly completedTodos$ = this.todos$.pipe(
map(todos => todos.filter(todo => todo.isCompleted))
) ?
Hey, both do the same thing, but yes you are correct, using todos from the map makes more sense, i updated the article.
Absolutely brilliant! Always know your actual toolset before buying into new ones!
Whats your opinion about using this method in a complex application that doesn't use any state management at all yet (due to lack of initial planning), but our team would like to change this in the future regarding modules one by one starting with the most complex one. Is it a good idea to go with this one, or would it be better idea to go with ngrx because of the complexity?
Depends on the scale, if you have a big app with a lot of components than need to share state, and a lot of moving parts, i strongly suggest ngrx, i used this method (the one described in the article) to build several libraries that have internal state, and it works very well, so no need to use ngrx in component libs.