Skip to content

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);
  });
});

Released under the MIT License.