Reducer Pattern
counterReducer.js
js
export const COUNTER_ACTION = {
INCREMENT: "counter/increment",
DECREMENT: "counter/decrement",
RESET: "counter/reset"
};
export default function counterReducer(state = 0, action) {
switch (action.type) {
case COUNTER_ACTION.INCREMENT:
return state + 1;
case COUNTER_ACTION.DECREMENT:
return state - 1;
case COUNTER_ACTION.RESET:
return 0;
default:
return state;
}
}
/* Actions */
export const incrementCounter = () => ({ type: COUNTER_ACTION.INCREMENT });
export const decrementCounter = () => ({ type: COUNTER_ACTION.DECREMENT });
/* Selectors */
export const getCounter = state => state.counter ?? 0;
export const isCounterPositiveOrZero = state => getCounter(state) >= 0;
export const isCounterNegative = state => !isCounterPositiveOrZero(state);
export const isCounterEven = state => getCounter(state) % 2 === 0;
export const isCounterOdd = state => !isCounterEven(state);counterReducer.test.js
js
import { describe, expect, it } from "vitest";
import counterReducer, {
decrementCounter,
incrementCounter,
isCounterNegative,
isCounterPositiveOrZero
} from "./counterReducer";
describe("counterReducer", () => {
it('should match "counter/increment" action', () => {
const state = {
counter: counterReducer(undefined, incrementCounter())
};
expect(isCounterPositiveOrZero(state)).toBe(true);
});
it('should match "counter/decrement" action', () => {
const state = {
counter: counterReducer(undefined, decrementCounter())
};
expect(isCounterNegative(state)).toBe(true);
});
});tasksReducer.js
js
export const TASKS_ACTION = {
CREATE: "tasks/create",
UPDATE: "tasks/update",
DELETE: "tasks/delete"
};
export default function tasksReducer(state = [], action) {
switch (action.type) {
case TASKS_ACTION.CREATE:
return [
...state,
{
id: Math.random().toString(32).substring(2, 12),
...action.payload
}
];
case TASKS_ACTION.UPDATE:
return state.map(task => {
if (task.id === action.payload.id) {
return { ...task, ...action.payload };
}
return task;
});
case TASKS_ACTION.DELETE:
return state.filter(task => task.id !== action.payload);
default:
return state;
}
}
/* Actions */
export const createTask = task => ({ type: TASKS_ACTION.CREATE, payload: task });
export const updateTask = task => ({ type: TASKS_ACTION.UPDATE, payload: task });
export const deleteTask = id => ({ type: TASKS_ACTION.DELETE, payload: id });
// Async Action
export const updateTaskByTitle = (title, newTask) => (dispatch, getState) => {
const state = getState();
const oldTask = getTaskByTitle(state, title);
if (oldTask) dispatch(updateTask({ ...oldTask, ...newTask }));
};
/* Selectors */
export const getTasks = state => state.tasks ?? [];
export const getTaskByTitle = (state, title) => {
return getTasks(state).find(task => task.title === title);
};tasksReducer.test.js
js
import { describe, expect, it } from "vitest";
import tasksReducer, { createTask, deleteTask, getTaskByTitle, getTasks, updateTask } from "./tasksReducer";
describe("tasksReducer", () => {
it('should match "tasks/create" action', () => {
const state = {
tasks: tasksReducer(
undefined,
createTask({
title: "Delectus Aut Autem",
completed: false
})
)
};
expect(getTasks(state)).toHaveLength(1);
});
it('should match "tasks/update" action', () => {
const initialState = [
{
id: "nq55h0g1nd",
title: "Fugiat Veniam Minus",
completed: false
}
];
const state = {
tasks: tasksReducer(
initialState,
updateTask({
id: "nq55h0g1nd",
title: "Et Porro Tempora",
completed: true
})
)
};
const task = getTaskByTitle(state, "Et Porro Tempora");
expect(task.id).toEqual("nq55h0g1nd");
expect(task.completed).toBe(true);
});
it('should match "tasks/delete" action', () => {
const initialState = [
{
id: "nq55h0g1nd",
title: "Et Porro Tempora",
completed: true
}
];
const state = {
tasks: tasksReducer(initialState, deleteTask("nq55h0g1nd"))
};
expect(getTasks(state)).toHaveLength(0);
});
});userReducer.js
js
export const USER_ACTION = {
SET: "user/set",
SET_EMAIL: "user/set-email",
RESET: "user/reset"
};
const initialState = { firstName: "", lastName: "", email: "" };
export default function userReducer(state = initialState, action) {
switch (action.type) {
case USER_ACTION.SET:
return { ...state, ...action.payload };
case USER_ACTION.SET_EMAIL:
return { ...state, email: action.payload };
case USER_ACTION.RESET:
return initialState;
default:
return state;
}
}
/* Actions */
export const setUser = user => ({ type: USER_ACTION.SET, payload: user });
export const resetUser = () => ({ type: USER_ACTION.RESET });
/* Selectors */
export const getUserName = state => `${state.user.firstName} ${state.user.lastName}`;
export const getUserEmail = state => state.user.email ?? "";userReducer.test.js
js
import { describe, expect, it } from "vitest";
import userReducer, { getUserEmail, getUserName, resetUser, setUser } from "./userReducer";
describe("userReducer", () => {
it('should match "user/set" action', () => {
const state = {
user: userReducer(undefined, setUser({ firstName: "John", lastName: "Doe", email: "john.doe@pm.me" }))
};
expect(getUserName(state)).toEqual("John Doe");
});
it('should match "user/reset" action', () => {
const initialState = {
firstName: "John",
lastName: "Doe",
email: "john.doe@pm.me"
};
const state = {
user: userReducer(initialState, resetUser())
};
expect(getUserEmail(state)).toBeFalsy();
});
});combineReducers.js
js
/**
* Combines multiple reducers into one single root reducer
*
* @param {Record<string, Function>} reducers Object containing all of the reducers to combine
* @returns {Function} Combined reducer that accepts (state, action), and returns the new state
* @throws {Error} If reducers isn't an object or if any value isn't a function
* @throws {Error} If a reducer returns `undefined`
*/
export function combineReducers(reducers) {
if (typeof reducers !== "object" || reducers === null) {
throw new Error("combineReducers expects an object");
}
const reducerKeys = Object.keys(reducers);
for (const key of reducerKeys) {
if (typeof reducers[key] !== "function") {
throw new Error(`Reducer for key "${key}" must be a function`);
}
}
return (prevState = {}, action) => {
const nextState = {};
for (const key of reducerKeys) {
const reducer = reducers[key];
const prevStateForKey = prevState[key];
const nextStateForKey = reducer(prevStateForKey, action);
if (nextStateForKey === undefined) {
throw new Error(`Reducer for key "${key}" returned undefined`);
}
nextState[key] = nextStateForKey;
}
return nextState;
};
}combineReducers.test.js
js
import { beforeEach, describe, expect, it } from "vitest";
import { combineReducers } from "./core";
import counterReducer, { COUNTER_ACTION, getCounter } from "./counterReducer";
import tasksReducer, { TASKS_ACTION, getTaskByTitle } from "./tasksReducer";
import userReducer, { USER_ACTION, getUserEmail } from "./userReducer";
describe("combineReducers", () => {
let rootReducer;
beforeEach(() => {
rootReducer = combineReducers({
counter: counterReducer,
tasks: tasksReducer,
user: userReducer
});
});
it('should trigger "counter" action', () => {
const state = rootReducer(undefined, { type: COUNTER_ACTION.RESET });
expect(getCounter(state)).toEqual(0);
});
it('should trigger "tasks" action', () => {
const initialState = {
counter: 0,
user: { firstName: "", lastName: "" },
tasks: [
{
id: "nq55h0g1nd",
title: "Fugiat Veniam Minus",
completed: false
}
]
};
const state = rootReducer(initialState, {
type: TASKS_ACTION.UPDATE,
payload: {
id: "nq55h0g1nd",
title: "Et Porro Tempora",
completed: true
}
});
const task = getTaskByTitle(state, "Et Porro Tempora");
expect(task.id).toEqual("nq55h0g1nd");
expect(task.completed).toBe(true);
});
it('should trigger "user" action', () => {
const state = rootReducer(undefined, {
type: USER_ACTION.SET_EMAIL,
payload: "john.doe@pm.me"
});
expect(getUserEmail(state)).toEqual("john.doe@pm.me");
});
});createStore.js
js
/**
* Creates a single source of truth to manage application global state
*
* @param {Function} reducer Root reducer function that handles state mutations
* @param {*} initialState Initial state based on root reducer
* @returns {Object} Store with `getState()`, `dispatch()` and `subscribe()` methods
*/
export function createStore(reducer, initialState) {
const listeners = [];
let currentState = initialState;
const getState = () => currentState;
const dispatch = action => {
currentState = reducer(currentState, action);
listeners.forEach(listener => listener({ action, state: currentState }));
return action;
};
const subscribe = listener => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index !== -1) listeners.splice(index, 1);
};
};
return {
getState,
dispatch,
subscribe
};
}createStore.test.js
js
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { combineReducers, createStore } from "./core";
import counterReducer, { decrementCounter, getCounter, incrementCounter } from "./counterReducer";
import tasksReducer, { createTask, getTasks } from "./tasksReducer";
import userReducer, { getUserName, setUser } from "./userReducer";
describe("createStore", () => {
let store, unsubscribe;
beforeEach(() => {
const rootReducer = combineReducers({
counter: counterReducer,
user: userReducer,
tasks: tasksReducer
});
store = createStore(rootReducer);
unsubscribe = store.subscribe(({ action, state }) => {
console.log(`Action Type: ${action.type}`);
console.log("State:", state);
});
});
afterEach(() => unsubscribe());
it('should dispatch "counter" action', () => {
store.dispatch(incrementCounter());
store.dispatch(decrementCounter());
expect(getCounter(store.getState())).toEqual(0);
});
it('should dispatch "tasks" action', () => {
store.dispatch(
createTask({
title: "Delectus Aut Autem",
completed: false
})
);
store.dispatch(
createTask({
title: "Et Porro Tempora",
completed: true
})
);
expect(getTasks(store.getState())).toHaveLength(2);
});
it('should dispatch "user" action', () => {
store.dispatch(setUser({ firstName: "John", lastName: "Doe" }));
expect(getUserName(store.getState())).toEqual("John Doe");
});
});applyMiddleware.js
js
/**
* Applies a series of middlewares to the store
*
* @param {...Function} middlewares Middleware functions to apply to the single source of truth
* @returns {Function} Composer that modifies the createStore function to include middlewares
*/
export function applyMiddleware(...middlewares) {
return createStore => (reducer, initialState) => {
const store = createStore(reducer, initialState);
let dispatch = store.dispatch;
const middlewareApi = {
dispatch: action => dispatch(action),
getState: store.getState
};
const middlewareChain = middlewares.map(middleware => {
return middleware(middlewareApi);
});
dispatch = middlewareChain.reduce((acc, middleware) => {
return middleware(acc);
}, store.dispatch);
return { ...store, dispatch };
};
}applyMiddleware.test.js
js
import { beforeEach, describe, expect, it, vi } from "vitest";
import { applyMiddleware, combineReducers, createStore } from "./core";
import counterReducer from "./counterReducer";
import tasksReducer, { createTask, getTaskByTitle, getTasks, updateTaskByTitle } from "./tasksReducer";
import userReducer from "./userReducer";
// prettier-ignore
export const loggerMiddleware = ({ getState }) => next => action => {
console.group();
console.log(`Action Type: ${action.type}`);
if (action.payload) console.log("Action Payload:", action.payload);
console.group();
console.log("Previous State:", getState());
const nextAction = next(action);
console.log("Next State:", getState());
console.groupEnd();
console.groupEnd();
return nextAction;
};
// prettier-ignore
export const thunkMiddleware = ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState);
}
return next(action);
}
describe("applyMiddleware", () => {
let rootReducer;
beforeEach(() => {
rootReducer = combineReducers({
counter: counterReducer,
user: userReducer,
tasks: tasksReducer
});
});
it("should apply mock middleware", () => {
const mockFunc = vi.fn();
// prettier-ignore
const mockMiddleware = ({ getState }) => next => action => {
const state = getState();
mockFunc(state);
const nextAction = next(action);
mockFunc(state);
return nextAction;
};
const createStoreWithMiddleware = applyMiddleware(mockMiddleware)(createStore);
const store = createStoreWithMiddleware(rootReducer, {
user: {
firstName: "John",
lastName: "Doe"
}
});
store.dispatch({ type: "@@INIT" });
expect(mockFunc).toHaveBeenCalledTimes(2);
expect(mockFunc).toHaveBeenCalledWith({
user: {
firstName: "John",
lastName: "Doe"
}
});
});
describe("loggerMiddleware", () => {
let console;
const consoleGroup = vi.fn();
const consoleGroupEnd = vi.fn();
const consoleLog = vi.fn();
beforeAll(() => {
console = { ...globalThis.console };
globalThis.console = {
group: consoleGroup,
groupEnd: consoleGroupEnd,
log: consoleLog
};
});
afterAll(() => {
globalThis.console = console;
});
it("should apply logger middleware", () => {
const createStoreWithMiddleware = applyMiddleware(loggerMiddleware)(createStore);
const store = createStoreWithMiddleware(rootReducer, { user: { firstName: "", lastName: "" } });
store.dispatch(setUser({ firstName: "John", lastName: "Doe" }));
expect(consoleGroup).toHaveBeenCalled(2);
expect(consoleLog).toHaveBeenCalled(4);
expect(consoleGroupEnd).toHaveBeenCalled(2);
});
});
it("should apply thunk middleware", () => {
const createStoreWithMiddleware = applyMiddleware(thunkMiddleware)(createStore);
const store = createStoreWithMiddleware(rootReducer);
store.dispatch(
createTask({
title: "Delectus Aut Autem",
completed: false
})
);
store.dispatch(
createTask({
title: "Fugiat Veniam Minus",
completed: false
})
);
store.dispatch(
updateTaskByTitle("Fugiat Veniam Minus", {
title: "Et Porro Tempora",
completed: true
})
);
expect(getTasks(store.getState())).toHaveLength(2);
const task = getTaskByTitle(store.getState(), "Et Porro Tempora");
expect(task.completed).toBe(true);
});
});