How NgRx Store & Effects Work: 20 LoC Re-Implementation
Nils Mehlhorn
Posted on September 17, 2020
Contents
Action, State & Reducer
Where Does NgRx Store Data?
How NgRx Effects Works
Learning NgRx
The concepts behind NgRx are inspired by the Flux architecture and it's most famous implementation: the Redux library. In theory, these concepts aren't too complicated, but in practice it might be hard to wrap your head around how everything fits together. So, let's demystify how NgRx works under the hood by coming up with a custom implementation of it - you'll be surprised with how few lines we can get really close to the real thing. At the same time we'll use our NgRx clone to implement a simple todo app.
๐ I'm writing a book on NgRx and you can get it for free! Learn how to structure your state, write testable reducers and work with actions and effects from one well-crafted resource.
Three short principles are the foundation for state management with NgRx:
Single Source of Truth: The application state is stored in one object
State is Read-Only: You cannot change the current state, only dispatch an action and produce a new state.
Changes are made with pure functions: The next state is produced purely based on the current state and a dispatched action - no side-effects allowed
Together these principles make sure that state transitions are explicit and deterministic, meaning you can easily tell how the application state evolves over time.
Action, State & Reducer
Our custom NgRx store implementation will be represented by a single file store.ts
that reflects the principles just mentioned. Meanwhile, any app using this store can work with the same building blocks that you know from the real library.
Action
Actions are plain JavaScript objects that reference events occurring in the application. Actions are distinguished by a type but can have arbitrary more properties to serve as a payload containing information about a corresponding event. We can leverage TypeScript's index types to define an interface representing the action data type:
// store.ts
export interface Action {
type: string
[property: string]: any
}
Now, any object that has a type
property can be used as an action in our application:
const addTodoAction: Action = {
type: 'ADD',
text: 'Demystify NgRx',
}
We can even create custom action data types and action creators to ease development. That's basically what the createAction and props functions from NgRx are doing - it doesn't give you quite the same type-safety though:
// todos.actions.ts
export interface AddAction extends Action {
type: 'ADD'
text: string
}
export function addTodo(text: string): AddAction {
return {
type: 'ADD',
text,
}
}
export interface ToggleAction extends Action {
type: 'TOGGLE'
index: number
}
export function toggleTodo(index: number): ToggleAction {
return {
type: 'TOGGLE',
index,
}
}
We could implement better type checking here, but let's not complicate things for now.
State
A plain JavaScript object holds the global application state. In an actual application it can have many shapes, therefore we'll treat it as a generic type named S
in our NgRx implementation. We'll use S
for typing reducers and eventually initializing the store. Meanwhile, the state of our todo app will look like follows. So, for the todo app State
will take the place of S
everywhere where we refer to S
in our custom NgRx implementation:
// todos.state.ts
export interface Todo {
index: number
text: string
done: boolean
}
export interface State {
todos: Todo[]
}
The initial state for the todo app will just contain an empty array:
// todos.state.ts
const initialState: State = { todos: [] }
Reducer
A reducer is a pure function that takes the current state and an action as parameters while returning the next state. We can convert these claims into a type signature for a reducer using the generic state type S
and our action interface:
// store.ts
export type Reducer<S> = (state: S, action: Action) => S
Now, we can define a reducer for our todo app by implementing a function with this type. There we use the spread syntax to produce a new state based on an incoming action. Note that we'll use the initial state as a default parameter. This way the reducer can be executed once without a state in order to supply the initial state to the store.
// todos.reducer.ts
const reducer = (state = initialState, action: Action) => {
switch (action.type) {
case 'ADD':
return {
todos: [
...state.todos,
{
index: state.todos.length,
text: action.text,
done: false,
},
],
}
case 'TOGGLE':
return {
todos: state.todos.map((todo, index) => {
if (index === action.index) {
return {
...todo,
done: !todo.done,
}
}
return todo
}),
}
default:
return state
}
}
Normally, you'd be using the createReducer and on functions to define a reducer. However, under the hood this is not really different from doing a switch-case on the action type. In fact, prior to Angular and NgRx 8 this was the normal way of writing reducers.
Where Does NgRx Store Data?
NgRx stores the application state in an RxJS observable inside an Angular service called Store. At the same time, this service implements the Observable
interface. So, when you subscribe to the store, the service actually forwards the subscription to the underlying observable.
Internally, NgRx is actually using a BehaviorSubject which is a special observable that has the following characteristics:
- new subscribers receive the current value upon subscription
- it requires an initial value
- since a BehaviorSubject is in turn a specialized Subject you can emit a new value on it with
subject.next()
- you can retrieve its current value synchronously using
subject.getValue()
These characteristics also come in real handy for our custom store implementation where we'll also use a BehaviorSubject to hold the application state. So, let's create our own injectable Angular service Store
by defining a corresponding class. It'll work with the generic state type S
while its constructor accepts an application-specific reducer. We compute an initial state by executing the passed-in reducer with undefined
and an initial action - just like NgRx's INIT action.
Additionally, we provide a dispatch
function accepting a single action. This function will retrieve the current state, execute the reducer and emit the resulting state through the BehaviorSubject.
Eventually, the BehaviorSubject is exposed in form of the more restrictive Observable
type via asObservable()
so that it's only possibly to cause a new state emission by dispatching an action.
So, here you go, NgRx Store re-implementation in less than 20 lines of code:
// store.ts
import { Injectable } from '@angular/core'
import { Observable, BehaviorSubject } from 'rxjs'
@Injectable()
export class Store<S> {
state$: Observable<S>
private state: BehaviorSubject<S>
constructor(private reducer: Reducer<S>) {
const initialAction = { type: '@ngrx/store/init' }
const initialState = reducer(undefined, initialAction)
this.state = new BehaviorSubject<S>(initialState)
this.state$ = this.state.asObservable()
}
dispatch(action: Action) {
const state = this.state.getValue()
const nextState = this.reducer(state, action)
this.state.next(nextState)
}
}
Anything unclear? Post a comment below or ping me on Twitter @n_mehlhorn
Note that the actual NgRx will allow you to register multiple reducers, however, for the sake of simplicity our implementation only accepts a single one. Either way, the approach stays the same: we're managing state through an RxJS BehaviorSubject - a pattern that has been described many times, for example here by Cory Rylan. However, we also make state transitions explicit through actions while keeping each state read-only with pure reducer functions.
In order to use our custom store now for the todo app, we have to register it as a provider while passing an application-specific reducer. This can be done with a value provider as follows. The actual NgRx is doing pretty much the same thing, it's just wrapped in another module.
// app.module.ts
...
import { Store } from './store/store'
import { State } from './store/todos.state'
import { reducer } from './store/todos.reducer'
@NgModule({
...
providers: [
{provide: Store, useValue: new Store<State>(reducer)}
],
...
})
export class AppModule { }
Then we can use our store almost like the real NgRx store in a component:
// app.component.ts
...
import { Store, Action } from "./store/store";
import { Todo, State } from "./store/todos.state";
import { addTodo, toggleTodo } from "./store/todos.actions";
@Component({...})
export class AppComponent {
state$: Observable<State>
constructor(private store: Store<State>) {
this.state$ = store.state$
}
add(text: string): void {
this.store.dispatch(addTodo(text));
}
toggle(todo: Todo): void {
this.store.dispatch(toggleTodo(todo.index));
}
}
<!-- app.component.html -->
<label for="text">Todo</label>
<input #textInput type="text" id="text" />
<button (click)="add(textInput.value)">Add</button>
<ul *ngIf="state$ | async as state">
<li *ngFor="let todo of state.todos">
<span [class.done]="todo.done">{{ todo.text }}</span>
<button (click)="toggle(todo)">
{{ todo.done ? 'X' : 'โ'}}
</button>
</li>
</ul>
Join my mailing list and follow me on Twitter @n_mehlhorn for more in-depth Angular & RxJS knowledge
How NgRx Effects Works
NgRx effects are managing asynchronous side-effects with RxJS observables resulting in actions being dispatched to the store. Since reducers are pure functions, they can't have side-effects - so things like HTTP requests aren't allowed. However, actions can be dispatched at anytime, for example as the result of an HTTP request that saves a todo to the server. Here's a corresponding action definition:
// todos.actions.ts
export interface SavedAction extends Action {
type: 'SAVED'
todo: Todo
}
export function savedTodo(todo: Todo): SavedAction {
return {
type: 'SAVED',
todo,
}
}
And this is how you could dispatch it after the HTTP request:
import { savedTodo } from './store/todos.actions'
import { Todo } from './store/todos.state'
this.http.post<Todo>('/todos', todo).subscribe((saved) => {
this.store.dispatch(savedTodo(saved))
})
Yet, with the current setup, we can't really run this call before the reducer creates the actual todo. Therefore we'd need to wait for the 'ADD'
action to be processed. For this we need a way to hook into all dispatched actions. With some adjustments to our store implementation, we can simply expose another observable of actions through a regular RxJS subject:
// store.ts
import { Injectable } from '@angular/core'
import { Observable, BehaviorSubject, Subject } from 'rxjs'
@Injectable()
export class Store<S> {
state$: Observable<S>
action$: Observable<Action> // NEW
private state: BehaviorSubject<S>
private action = new Subject<Action>() // NEW
constructor(private reducer: Reducer<S>) {
const initialAction = { type: '@ngrx/store/init' }
const initialState = reducer(undefined, initialAction)
this.state = new BehaviorSubject<S>(initialState)
this.state$ = this.state.asObservable()
this.action$ = this.action.asObservable() // NEW
this.action.next(initialAction) // NEW
}
dispatch(action: Action) {
const state = this.state.getValue()
const nextState = this.reducer(state, action)
this.state.next(nextState)
this.action.next(action) // NEW
}
}
Now, we can use the action$
observable from the store to compose a stream that maps the 'ADD'
action to an HTTP request which in turn will be mapped to the 'SAVED'
action. This stream could live inside a TodoEffects
service:
// todo.effects.ts
import { Injectable } from '@angular/core'
import { filter, mergeMap, map, withLatestFrom } from 'rxjs/operators'
import { Store } from './store'
import { State, Todo } from './todos.state'
import { savedTodo, AddAction } from './todos.actions'
@Injectable()
export class TodoEffects {
constructor(private store: Store<State>, private http: HttpClient) {
this.store.action$
.pipe(
// equivalent to NgRx ofType() operator
filter((action) => action.type === 'ADD'),
// fetch the latest state
withLatestFrom(this.store.state$),
// wait for HTTP request
mergeMap(([action, state]: [AddAction, State]) => {
// (use some kind of ID in a real app or only add todo to state after 'SAVED')
const todo = state.todos[state.todos.length - 1]
return this.http.post<Todo>('/todos', todo)
}),
// map to 'SAVED' action
map((todo) => savedTodo(todo.index))
)
.subscribe((action) => this.store.dispatch(action))
}
}
And that's almost all there is to NgRx effects. However, this implementation has two problems that are handled elegantly by the actual NgRx when using createEffect to register effects:
The
TodoEffects
class won't be initialized by Angular when it's not injected by any component or service.We're handling the subscription ourselves. This gets repetitive and we'd also have to handle errors. NgRx automatically retries failing effect streams up to 10 times.
Eventually, we can extend our reducer to handle the 'SAVED'
action. Note that I also added a new boolean property saved
to the Todo
interface. Usually this would rather be some kind of ID. You might also only want to add a todo to the state once it's saved to the server (see Optimistic and Pessimistic UI Rendering Approaches).
// todos.reducer.ts
case "SAVED":
return {
todos: state.todos.map((todo, index) => {
if (index === action.index) {
return {
...todo,
saved: true
};
}
return todo;
})
};
Learning NgRx
While it's fun and a good learning experience to implement NgRx store and effects yourself, you should definitely stick with the official library for real Angular apps. This way you'll get a tested and type-safe implementation with a lot more features.
If you want to learn solid NgRx foundations, you've come to the right place, because I'm writing a book on that and you can get it for free ๐
I'm pouring all my experience into this complete learning resource while allowing you to pay what you want - it's my main goal to help people to gain proper software development skills, so share the link to the book with anyone who might like it.
Either way, hopefully I was able to shed some light on the inner workings of NgRx and thus make the library more approachable for you. Here's a StackBlitz showing the full implementation.
Posted on September 17, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.