Creating a Redux Like Store
Published on
In this post we will take the state store created in the previous post make more redux like. The first thing we need to answer is what is redux?.
According the to offical redux website:
Redux is a predictable state container for JavaScript apps. It helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test.&
In simple terms, redux is a pattern to follow to manage global application data which can be shared across multiple components.
The Pieces That Make the Redux Pattern
There are four parts to the Redux pattern each of which serve a specific function….
-
Actions: You can think of Actions as requests/events to perform operations against the state data. Actions generally have
type
property describe the action to be performed and an optionalpayload
of data that is needed to perform that Action. -
Reducers: Reducers are the Action handlers (or event handlers), that actually perform the operations based on the Action type. Generally this a a function that takes in the current state and the action and returns the new state.
-
Selectors: Selectors are functions that map a specific piece of state from the overall state. We will be using Rxjs with this example so our selectors will return Observables for that piece of state allowing us to receive updates to that state data when they occur.
-
Store: The Store is the a global object container that holds all the state data and provides an API to dispatch actions and select state.
Below is a diagram displaying the redux flow
Redux Implementation
We need to create types for an Action, Reducer, and Selector. Our Action type will have two proeprties, a type
which describes the action to be preformed, and payload
which is any additional data that is needed to perform that action/operation.
Our Reducer
type is a function that takes in the current state value T
and an Action
type and returns a new state value T
.
And our Selector
type is another function type that takes in the current state value and returns some value based on that state.
// store.ts
export interface Action {
type: string;
payload?: any;
}
export interface Reducer<T> {
(state: T, action?: Action): T;
}
export interface Selector<T, R> {
(state: T): R;
}
Now that we have our basic types defined we can create our AbstractStore
. We’re going to use the AbstractStore
from the previous post with some slight modifications.
We will add a dispatch
method that takes in an Action
and runs our current state and that action through a reducer to get our new state.
We also modified our select
method to use our Selector
type instead of using a mapping function.
Our new AbstractStore
implementation will something like…
// store.ts
export abstract class AbstractStore<T> {
protected state: T;
protected stateSource: BehaviorSubject<T>;
protected reducer: Reducer<T>;
constructor() {
this.state = this._deepFreeze(this.state);
this.stateSource = new BehaviorSubject<T>(this.state);
}
public setState(state: T): void {
this.state = this._deepFreeze(state);
this.stateSource.next(this.state);
}
public dispatch(action: Action): void {
const state: T = this.reducer(this.state, action);
this.setState(this.reducer(this.state, action));
}
public select<R>(selector: Selector<T, R>): Observable<R> {
return this.stateSource
.asObservable()
.pipe(map(selector), distinctUntilChanged());
}
private _deepFreeze<T>(obj: T): T {
const propNames = Object.getOwnPropertyNames(obj);
for (let name of propNames) {
let value = (obj as any)[name];
if (value && typeof value === 'object') {
this._deepFreeze(value);
}
}
return Object.freeze(obj);
}
}
Now we could accept the inital state and a reducer as constructor arguments (inversion of control); this would allow our store to be more testable. We could also create a decorator which will accept these values as well. Either approach will work.
Below is code for the decorator approach…
// store.ts
export interface StoreConfig {
initialState: any;
reducer: Reducer<any>;
}
export function Store(config: StoreConfig) {
return function (constructor: Function) {
constructor.prototype.state = config.initialState;
constructor.prototype.reducer = config.reducer;
};
}
Creating a Todos Store
Now we can create our Todos store. We’ll create actions to handle our basic Todo CRUD operations; creating, update, and removing todos, and selectors for selecting Todos.
// todos.actions.ts
import { Action } from './store';
import { Todo } from './todos.models';
export enum TodosActionTypes {
ADD_TODO = '[TODOS] Add Todo',
REMOVE_TODO = '[TODOS] Remove Todo',
UPDATE_TODO = '[TODOS] Update Todo',
}
export class AddTodo implements Action {
public type: TodosActionTypes = TodosActionTypes.ADD_TODO;
constructor(public payload: Todo) {}
}
export class RemoveTodo implements Action {
public type: TodosActionTypes = TodosActionTypes.REMOVE_TODO;
constructor(public payload: Todo) {}
}
export class UpdateTodo implements Action {
public type: TodosActionTypes = TodosActionTypes.UPDATE_TODO;
constructor(public payload: Todo) {}
}
And our Todo reducer to handle the above actions…
// todos.reducer.ts
import { Reducer, Action } from './store';
import { TodosActionTypes } from './todos.actions';
import { Todo } from './todos.models';
export interface TodosState {
todos: Todo[];
}
export const initialTodosState: TodosState = {
todos: [],
};
export const todosReducer: Reducer<TodosState> = (
state: TodosState = initialTodosState,
action?: Action
) => {
switch (action?.type) {
case TodosActionTypes.ADD_TODO:
return {
...state,
todos: [...state.todos, action.payload],
};
case TodosActionTypes.REMOVE_TODO:
return {
...state,
todos: state?.todos?.filter((todo) => todo?.id !== action?.payload?.id),
};
case TodosActionTypes.UPDATE_TODO:
return {
...state,
todos: state?.todos?.map((todo) =>
todo.id === action?.payload?.id ? action.payload : todo
),
};
default:
return state;
}
};
Now that we have actions and a reducer, we can create our TodosStore
which will extend our AbstractStore
and will be decorated with our @Store
decorator.
// todos.store.ts
import { initialTodosState, todosReducer, TodosState } from './todos.reducer';
import { AbstractStore, Store } from './store';
import { Injectable } from '@angular/core';
@Store({
initialState: initialTodosState,
reducer: todosReducer,
})
@Injectable({ providedIn: 'root' })
export class TodosStore extends AbstractStore<TodosState> {}
Our TodosStore
will maintain our Todo’s state, and allows us to dispatch actions to manipulate that state, and select values from that state. Now all we need are some selectors to access and receive update to state changes.
We’ll create three selectors
- A selector to select all todos
- A selector to select completed todos
- A selector to select incomplete todos.
import { Selector } from './store';
import { Todo } from './todos.models';
import { TodosState } from './todos.reducer';
export const selectTodos: Selector<TodosState, Todo[]> = (
state: TodosState
): Todo[] => state.todos;
export const selectIncompleteTodos: Selector<TodosState, Todo[]> = (
state: TodosState
): Todo[] => state.todos.filter((todo) => !todo.isComplete);
export const selectCompleteTodos: Selector<TodosState, Todo[]> = (
state: TodosState
): Todo[] => state.todos.filter((todo) => todo.isComplete);
Now we can use our TodosStore
in our Angular app…
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import {
TodosStore,
AddTodo,
UpdateTodo,
RemoveTodo,
Todo,
selectCompleteTodos,
selectIncompleteTodos,
} from '../../../store';
import { buildTodoForm } from '../../components/todo-form/todo-form.builder';
@Component({
selector: 'app-todos',
templateUrl: './todos.component.html',
styleUrls: ['./todos.component.css'],
})
export class TodosComponent implements OnInit {
public todos$: Observable<Todo[]>;
public completed$: Observable<Todo[]>;
public todosForm: FormGroup;
constructor(
private _todosStore: TodosStore,
private _formBuilder: FormBuilder
) {}
ngOnInit(): void {
this.todos$ = this._todosStore.select(selectIncompleteTodos);
this.completed$ = this._todosStore.select(selectCompleteTodos);
this.todosForm = buildTodoForm(this._formBuilder);
}
public createTodo(todo: Todo): void {
todo.id = this._generateTodoId(10000);
todo.isComplete = false;
this._todosStore.dispatch(new AddTodo(todo));
this.todosForm.reset();
}
public updateTodo(todo: Todo): void {
this._todosStore.dispatch(new UpdateTodo(todo));
}
public deleteTodo(todo: Todo): void {
this._todosStore.dispatch(new RemoveTodo(todo));
}
private _generateTodoId(max) {
return Math.floor(Math.random() * max);
}
}
Below is an embedded Stackblitz with a working example of out TodosStore
being used…
Our todo example in a working Stackblitz