Project 5: Implement Redux from Scratch

Project 5: Implement Redux from Scratch

Project Overview

Attribute Value
Difficulty Intermediate
Time Estimate 1 week
Main Language TypeScript
Alternatives JavaScript, Rust, Python
Prerequisites Basic callbacks, closures understanding
Key FP Concepts Immutability, pure functions, state machines, unidirectional data flow

Learning Objectives

By completing this project, you will:

  1. Internalize immutability - Experience why never mutating state eliminates bugs
  2. Master pure reducers - Write functions that transform state predictably
  3. Understand unidirectional data flow - See how one-way data makes apps predictable
  4. Implement time-travel debugging - Experience why immutability enables the impossible
  5. Isolate side effects - Learn to push impurity to the edges with middleware
  6. Build the observer pattern functionally - Subscriptions without mutation

The Core Question

โ€œWhy did Redux conquer frontend state management? What problem does โ€˜pure functions + immutable stateโ€™ actually solve?โ€

Before Redux, frontend state was chaos:

  • State scattered across components
  • Mutations happening anywhere
  • No way to know what changed or why
  • Debugging required stepping through code

Reduxโ€™s insight: treat state changes as a stream of events (actions), process them with pure reducer functions, and never mutate state. The result:

  • Every state change is logged
  • You can replay any sequence of actions
  • Time-travel debugging becomes trivial
  • Testing requires no mocks or setup

Deep Theoretical Foundation

1. The Problem Redux Solves

Consider this typical UI code:

// Scattered, mutable state
let todos = [];
let filter = 'all';
let user = null;

function addTodo(text) {
  todos.push({ id: Date.now(), text, done: false });  // Mutation!
  renderTodos();
}

function toggleTodo(id) {
  const todo = todos.find(t => t.id === id);
  todo.done = !todo.done;  // Mutation!
  renderTodos();
}

function setFilter(f) {
  filter = f;  // Mutation!
  renderTodos();
}

Problems:

  • Where did state change? Any function can mutate anything
  • What was the previous state? Lost forever
  • Why did state change? No record of intent
  • Can I reproduce a bug? Only if I remember the exact sequence of actions

2. The Redux Architecture

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                         STORE                                โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚                      STATE                           โ”‚    โ”‚
โ”‚  โ”‚  { todos: [...], filter: 'all', user: {...} }       โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚                           โ”‚                                  โ”‚
โ”‚                    getState()                                โ”‚
โ”‚                           โ–ผ                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚                       UI                             โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚                           โ”‚                                  โ”‚
โ”‚                    dispatch(action)                          โ”‚
โ”‚                           โ–ผ                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚                     ACTION                           โ”‚    โ”‚
โ”‚  โ”‚  { type: 'ADD_TODO', payload: { text: 'Learn FP' } }โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚                           โ”‚                                  โ”‚
โ”‚                    reducer(state, action)                    โ”‚
โ”‚                           โ–ผ                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    โ”‚
โ”‚  โ”‚                     REDUCER                          โ”‚    โ”‚
โ”‚  โ”‚  (state, action) => newState  (PURE FUNCTION)       โ”‚    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    โ”‚
โ”‚                           โ”‚                                  โ”‚
โ”‚                     (creates new state)                      โ”‚
โ”‚                           โ–ผ                                  โ”‚
โ”‚                      [back to STATE]                         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Redux Architecture and Data Flow

Key principles:

  1. Single source of truth: One state object in one store
  2. State is read-only: Never mutate; create new state objects
  3. Changes via pure functions: Reducers take (state, action) โ†’ newState

3. Pure Reducers: The Heart of Redux

A reducer is a pure function: (state, action) => newState

// PURE: No mutations, no side effects
function todosReducer(state: Todo[] = [], action: Action): Todo[] {
  switch (action.type) {
    case 'ADD_TODO':
      // Return NEW array with new todo
      return [...state, { id: Date.now(), text: action.payload.text, done: false }];

    case 'TOGGLE_TODO':
      // Return NEW array with modified todo
      return state.map(todo =>
        todo.id === action.payload.id
          ? { ...todo, done: !todo.done }  // New object!
          : todo
      );

    case 'REMOVE_TODO':
      return state.filter(todo => todo.id !== action.payload.id);

    default:
      return state;  // Unknown action: return unchanged
  }
}

Why pure?

  • Same inputs โ†’ same output (testable)
  • No side effects (predictable)
  • Doesnโ€™t mutate input (enables history)

4. Actions: Describing What Happened

Actions are plain objects describing events:

// Actions are data, not function calls
const addTodoAction = {
  type: 'ADD_TODO',
  payload: { text: 'Learn Redux' }
};

const toggleTodoAction = {
  type: 'TOGGLE_TODO',
  payload: { id: 123 }
};

// Action creators: functions that return actions
function addTodo(text: string): Action {
  return { type: 'ADD_TODO', payload: { text } };
}

function toggleTodo(id: number): Action {
  return { type: 'TOGGLE_TODO', payload: { id } };
}

Why objects?

  • Serializable (can be logged, saved, replayed)
  • Inspectable (debugger tools can show them)
  • Declarative (describe intent, not implementation)

5. Immutable Updates: Creating New State

The hardest part of Redux is updating nested state immutably:

// State shape
interface State {
  user: {
    profile: {
      name: string;
      settings: {
        theme: string;
      };
    };
  };
}

// WRONG: Mutating nested state
function updateThemeWrong(state: State, theme: string): State {
  state.user.profile.settings.theme = theme;  // Mutation!
  return state;
}

// RIGHT: Immutable update at each level
function updateThemeRight(state: State, theme: string): State {
  return {
    ...state,
    user: {
      ...state.user,
      profile: {
        ...state.user.profile,
        settings: {
          ...state.user.profile.settings,
          theme  // Only this changes
        }
      }
    }
  };
}

This is verbose! Thatโ€™s why libraries like Immer exist. But understanding the manual approach teaches you why immutability works.

6. The Store: Holding It All Together

The store is a closure that holds state and provides methods:

function createStore(reducer, initialState) {
  let state = initialState;
  let listeners = [];

  return {
    getState() {
      return state;
    },

    dispatch(action) {
      state = reducer(state, action);  // Compute new state
      listeners.forEach(fn => fn());    // Notify subscribers
      return action;
    },

    subscribe(listener) {
      listeners.push(listener);
      return () => {  // Return unsubscribe function
        listeners = listeners.filter(l => l !== listener);
      };
    }
  };
}

Why a closure?

  • state is private (canโ€™t be mutated from outside)
  • Only dispatch can change state (enforces the pattern)
  • subscribe enables reactive UI updates

7. Time-Travel Debugging: The Payoff

Because state is never mutated and actions are logged:

interface StoreWithHistory {
  past: State[];
  present: State;
  future: State[];
}

function enhanceWithUndo(reducer) {
  return function(state, action) {
    switch (action.type) {
      case 'UNDO':
        if (state.past.length === 0) return state;
        return {
          past: state.past.slice(0, -1),
          present: state.past[state.past.length - 1],
          future: [state.present, ...state.future]
        };

      case 'REDO':
        if (state.future.length === 0) return state;
        return {
          past: [...state.past, state.present],
          present: state.future[0],
          future: state.future.slice(1)
        };

      default:
        const newPresent = reducer(state.present, action);
        if (newPresent === state.present) return state;
        return {
          past: [...state.past, state.present],
          present: newPresent,
          future: []  // Clear redo stack on new action
        };
    }
  };
}

This is impossible with mutations. You canโ€™t go back if you overwrote the previous value!

8. Middleware: Handling Side Effects

Pure reducers canโ€™t do async operations. Middleware handles effects:

// Middleware signature
type Middleware = (store) => (next) => (action) => any;

// Logger middleware
const logger: Middleware = store => next => action => {
  console.log('Dispatching:', action);
  const result = next(action);
  console.log('New state:', store.getState());
  return result;
};

// Thunk middleware: actions can be functions
const thunk: Middleware = store => next => action => {
  if (typeof action === 'function') {
    return action(store.dispatch, store.getState);
  }
  return next(action);
};

The pattern: Keep the core pure, push effects to the edges.


Project Specification

What Youโ€™re Building

A minimal but complete Redux implementation:

// Create a store
const store = createStore(rootReducer, initialState);

// Subscribe to changes
const unsubscribe = store.subscribe(() => {
  console.log('State changed:', store.getState());
});

// Dispatch actions
store.dispatch({ type: 'ADD_TODO', payload: { text: 'Learn Redux' } });
store.dispatch({ type: 'TOGGLE_TODO', payload: { id: 1 } });

// Undo/redo
store.dispatch({ type: 'UNDO' });
store.dispatch({ type: 'REDO' });

// Combine reducers
const rootReducer = combineReducers({
  todos: todosReducer,
  filter: filterReducer,
  user: userReducer
});

// Apply middleware
const store = createStore(
  rootReducer,
  applyMiddleware(logger, thunk)
);

Core API

Function Description
createStore(reducer, initialState?, enhancer?) Create a Redux store
store.getState() Get current state
store.dispatch(action) Send action to reducer
store.subscribe(listener) Register change listener
combineReducers(reducers) Combine multiple reducers
applyMiddleware(...middlewares) Add middleware to store

Features to Implement

  1. Basic store: getState, dispatch, subscribe
  2. Reducer composition: combineReducers
  3. Middleware: applyMiddleware, compose
  4. Time-travel: Undo/redo enhancer
  5. DevTools: Action logging, state inspection

Solution Architecture

Type Definitions

// Actions
interface Action {
  type: string;
  payload?: any;
}

type ActionCreator = (...args: any[]) => Action;

// Reducers
type Reducer<S> = (state: S | undefined, action: Action) => S;

// Store
interface Store<S> {
  getState(): S;
  dispatch(action: Action): Action;
  subscribe(listener: () => void): () => void;
  replaceReducer(reducer: Reducer<S>): void;
}

// Middleware
type Middleware<S> = (
  store: { getState: () => S; dispatch: Dispatch }
) => (next: Dispatch) => (action: Action) => any;

type Dispatch = (action: Action) => Action;

// Enhancer
type StoreEnhancer = (createStore: StoreCreator) => StoreCreator;
type StoreCreator = <S>(reducer: Reducer<S>, initialState?: S) => Store<S>;

Module Structure

src/
โ”œโ”€โ”€ index.ts            # Public API exports
โ”œโ”€โ”€ createStore.ts      # Store creation
โ”œโ”€โ”€ combineReducers.ts  # Reducer composition
โ”œโ”€โ”€ applyMiddleware.ts  # Middleware application
โ”œโ”€โ”€ compose.ts          # Function composition utility
โ”œโ”€โ”€ enhancers/
โ”‚   โ””โ”€โ”€ undoable.ts     # Undo/redo enhancer
โ”œโ”€โ”€ middleware/
โ”‚   โ”œโ”€โ”€ logger.ts       # Logging middleware
โ”‚   โ””โ”€โ”€ thunk.ts        # Async action support
โ””โ”€โ”€ examples/
    โ””โ”€โ”€ todos.ts        # Example todo app

Implementation Guide

Phase 1: Basic Store (Day 1)

Goal: Implement createStore with getState, dispatch, subscribe.

// createStore.ts
export function createStore<S>(
  reducer: Reducer<S>,
  initialState?: S
): Store<S> {
  let state: S = initialState ?? reducer(undefined, { type: '@@INIT' });
  let listeners: Array<() => void> = [];

  function getState(): S {
    return state;
  }

  function dispatch(action: Action): Action {
    if (typeof action.type !== 'string') {
      throw new Error('Actions must have a type property');
    }

    state = reducer(state, action);

    listeners.forEach(listener => listener());

    return action;
  }

  function subscribe(listener: () => void): () => void {
    if (typeof listener !== 'function') {
      throw new Error('Listener must be a function');
    }

    listeners.push(listener);

    // Return unsubscribe function
    return () => {
      listeners = listeners.filter(l => l !== listener);
    };
  }

  return { getState, dispatch, subscribe };
}

Phase 2: Combine Reducers (Day 2)

Goal: Compose multiple reducers into one.

// combineReducers.ts
type ReducerMap<S> = {
  [K in keyof S]: Reducer<S[K]>;
};

export function combineReducers<S>(reducers: ReducerMap<S>): Reducer<S> {
  const reducerKeys = Object.keys(reducers) as Array<keyof S>;

  return function combination(state: S = {} as S, action: Action): S {
    let hasChanged = false;
    const nextState = {} as S;

    for (const key of reducerKeys) {
      const reducer = reducers[key];
      const previousStateForKey = state[key];
      const nextStateForKey = reducer(previousStateForKey, action);

      nextState[key] = nextStateForKey;

      // Track if any slice changed
      hasChanged = hasChanged || nextStateForKey !== previousStateForKey;
    }

    // Return same reference if nothing changed (optimization)
    return hasChanged ? nextState : state;
  };
}

Phase 3: Compose Utility (Day 3)

Goal: Compose functions right-to-left.

// compose.ts
export function compose<R>(...funcs: Function[]): (...args: any[]) => R {
  if (funcs.length === 0) {
    return (arg => arg) as any;
  }

  if (funcs.length === 1) {
    return funcs[0] as any;
  }

  return funcs.reduce(
    (a, b) => (...args: any[]) => a(b(...args))
  );
}

// Usage: compose(f, g, h)(x) === f(g(h(x)))

Phase 4: Apply Middleware (Day 4)

Goal: Intercept dispatch with middleware chain.

// applyMiddleware.ts
export function applyMiddleware<S>(
  ...middlewares: Middleware<S>[]
): StoreEnhancer {
  return (createStore) => (reducer, initialState) => {
    const store = createStore(reducer, initialState);

    // Placeholder dispatch that throws during setup
    let dispatch: Dispatch = () => {
      throw new Error('Dispatching during middleware setup');
    };

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (action: Action) => dispatch(action)
    };

    // Apply each middleware with store API
    const chain = middlewares.map(middleware => middleware(middlewareAPI));

    // Compose middleware chain and wrap original dispatch
    dispatch = compose<Dispatch>(...chain)(store.dispatch);

    return {
      ...store,
      dispatch
    };
  };
}

Phase 5: Logger Middleware (Day 5)

Goal: Create useful middleware examples.

// middleware/logger.ts
export const logger: Middleware<any> = store => next => action => {
  console.group(action.type);
  console.log('Previous state:', store.getState());
  console.log('Action:', action);

  const result = next(action);

  console.log('Next state:', store.getState());
  console.groupEnd();

  return result;
};

// middleware/thunk.ts
export const thunk: Middleware<any> = store => next => action => {
  // If action is a function, call it with dispatch and getState
  if (typeof action === 'function') {
    return action(store.dispatch, store.getState);
  }

  return next(action);
};

// Usage with thunk:
// dispatch((dispatch, getState) => {
//   setTimeout(() => {
//     dispatch({ type: 'DELAYED_ACTION' });
//   }, 1000);
// });

Phase 6: Undo/Redo Enhancer (Day 6)

Goal: Add time-travel capability.

// enhancers/undoable.ts
interface UndoableState<S> {
  past: S[];
  present: S;
  future: S[];
}

export function undoable<S>(reducer: Reducer<S>): Reducer<UndoableState<S>> {
  // Get initial state from reducer
  const initialState: UndoableState<S> = {
    past: [],
    present: reducer(undefined, { type: '@@INIT' }),
    future: []
  };

  return function undoableReducer(
    state: UndoableState<S> = initialState,
    action: Action
  ): UndoableState<S> {
    const { past, present, future } = state;

    switch (action.type) {
      case 'UNDO':
        if (past.length === 0) return state;
        return {
          past: past.slice(0, -1),
          present: past[past.length - 1],
          future: [present, ...future]
        };

      case 'REDO':
        if (future.length === 0) return state;
        return {
          past: [...past, present],
          present: future[0],
          future: future.slice(1)
        };

      case 'CLEAR_HISTORY':
        return {
          past: [],
          present,
          future: []
        };

      default:
        const newPresent = reducer(present, action);

        // If state didn't change, return same reference
        if (newPresent === present) return state;

        return {
          past: [...past, present],
          present: newPresent,
          future: []  // New action clears redo stack
        };
    }
  };
}

// Action creators
export const undo = () => ({ type: 'UNDO' });
export const redo = () => ({ type: 'REDO' });
export const clearHistory = () => ({ type: 'CLEAR_HISTORY' });

Phase 7: Complete Example (Day 7)

Goal: Build a working todo app with all features.

// examples/todos.ts
import { createStore, combineReducers, applyMiddleware } from '../index';
import { logger, thunk } from '../middleware';
import { undoable, undo, redo } from '../enhancers/undoable';

// Types
interface Todo {
  id: number;
  text: string;
  done: boolean;
}

type Filter = 'all' | 'active' | 'completed';

interface RootState {
  todos: Todo[];
  filter: Filter;
}

// Reducers
function todosReducer(state: Todo[] = [], action: Action): Todo[] {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, {
        id: Date.now(),
        text: action.payload.text,
        done: false
      }];

    case 'TOGGLE_TODO':
      return state.map(todo =>
        todo.id === action.payload.id
          ? { ...todo, done: !todo.done }
          : todo
      );

    case 'REMOVE_TODO':
      return state.filter(todo => todo.id !== action.payload.id);

    default:
      return state;
  }
}

function filterReducer(state: Filter = 'all', action: Action): Filter {
  switch (action.type) {
    case 'SET_FILTER':
      return action.payload.filter;
    default:
      return state;
  }
}

// Combined reducer with undo/redo
const rootReducer = undoable(
  combineReducers({
    todos: todosReducer,
    filter: filterReducer
  })
);

// Create store with middleware
const store = createStore(
  rootReducer,
  applyMiddleware(logger, thunk)
);

// Action creators
const addTodo = (text: string) => ({ type: 'ADD_TODO', payload: { text } });
const toggleTodo = (id: number) => ({ type: 'TOGGLE_TODO', payload: { id } });
const removeTodo = (id: number) => ({ type: 'REMOVE_TODO', payload: { id } });
const setFilter = (filter: Filter) => ({ type: 'SET_FILTER', payload: { filter } });

// Async action (thunk)
const addTodoAsync = (text: string) => (dispatch: Dispatch) => {
  setTimeout(() => {
    dispatch(addTodo(text));
  }, 1000);
};

// Usage
store.subscribe(() => {
  const state = store.getState();
  console.log('Todos:', state.present.todos);
  console.log('Filter:', state.present.filter);
  console.log('Can undo:', state.past.length > 0);
  console.log('Can redo:', state.future.length > 0);
});

store.dispatch(addTodo('Learn Redux'));
store.dispatch(addTodo('Build app'));
store.dispatch(toggleTodo(1));
store.dispatch(undo());  // Back to before toggle
store.dispatch(redo());  // Redo the toggle

Testing Strategy

Store Tests

describe('createStore', () => {
  const reducer = (state = 0, action: Action) => {
    switch (action.type) {
      case 'INCREMENT': return state + 1;
      case 'DECREMENT': return state - 1;
      default: return state;
    }
  };

  test('returns initial state', () => {
    const store = createStore(reducer);
    expect(store.getState()).toBe(0);
  });

  test('uses provided initial state', () => {
    const store = createStore(reducer, 10);
    expect(store.getState()).toBe(10);
  });

  test('dispatch updates state', () => {
    const store = createStore(reducer);
    store.dispatch({ type: 'INCREMENT' });
    expect(store.getState()).toBe(1);
  });

  test('subscribe is called on dispatch', () => {
    const store = createStore(reducer);
    const listener = jest.fn();
    store.subscribe(listener);
    store.dispatch({ type: 'INCREMENT' });
    expect(listener).toHaveBeenCalledTimes(1);
  });

  test('unsubscribe removes listener', () => {
    const store = createStore(reducer);
    const listener = jest.fn();
    const unsubscribe = store.subscribe(listener);
    unsubscribe();
    store.dispatch({ type: 'INCREMENT' });
    expect(listener).not.toHaveBeenCalled();
  });
});

Reducer Tests

describe('todosReducer', () => {
  test('returns initial state', () => {
    const result = todosReducer(undefined, { type: '@@INIT' });
    expect(result).toEqual([]);
  });

  test('ADD_TODO adds a todo', () => {
    const result = todosReducer([], addTodo('Test'));
    expect(result).toHaveLength(1);
    expect(result[0].text).toBe('Test');
    expect(result[0].done).toBe(false);
  });

  test('TOGGLE_TODO toggles done', () => {
    const initial = [{ id: 1, text: 'Test', done: false }];
    const result = todosReducer(initial, toggleTodo(1));
    expect(result[0].done).toBe(true);
  });

  test('does not mutate state', () => {
    const initial = [{ id: 1, text: 'Test', done: false }];
    const result = todosReducer(initial, toggleTodo(1));
    expect(result).not.toBe(initial);
    expect(result[0]).not.toBe(initial[0]);
  });
});

Middleware Tests

describe('thunk middleware', () => {
  test('passes actions through', () => {
    const store = createStore(reducer);
    const next = jest.fn(action => action);
    const dispatch = thunk({ getState: store.getState, dispatch: store.dispatch })(next);

    dispatch({ type: 'TEST' });
    expect(next).toHaveBeenCalledWith({ type: 'TEST' });
  });

  test('calls function actions', () => {
    const store = createStore(reducer);
    const dispatch = store.dispatch;
    const thunkAction = jest.fn();

    const enhanced = thunk({ getState: store.getState, dispatch })(dispatch);
    enhanced(thunkAction);

    expect(thunkAction).toHaveBeenCalledWith(dispatch, store.getState);
  });
});

Undo/Redo Tests

describe('undoable', () => {
  const reducer = (state = 0, action: Action) =>
    action.type === 'INCREMENT' ? state + 1 : state;

  const undoableReducer = undoable(reducer);

  test('tracks history', () => {
    let state = undoableReducer(undefined, { type: '@@INIT' });
    state = undoableReducer(state, { type: 'INCREMENT' });
    state = undoableReducer(state, { type: 'INCREMENT' });

    expect(state.present).toBe(2);
    expect(state.past).toEqual([0, 1]);
    expect(state.future).toEqual([]);
  });

  test('undo restores previous state', () => {
    let state = undoableReducer(undefined, { type: '@@INIT' });
    state = undoableReducer(state, { type: 'INCREMENT' });
    state = undoableReducer(state, { type: 'INCREMENT' });
    state = undoableReducer(state, undo());

    expect(state.present).toBe(1);
    expect(state.past).toEqual([0]);
    expect(state.future).toEqual([2]);
  });

  test('redo restores undone state', () => {
    let state = undoableReducer(undefined, { type: '@@INIT' });
    state = undoableReducer(state, { type: 'INCREMENT' });
    state = undoableReducer(state, undo());
    state = undoableReducer(state, redo());

    expect(state.present).toBe(1);
    expect(state.past).toEqual([0]);
    expect(state.future).toEqual([]);
  });
});

Common Pitfalls and Debugging

Pitfall 1: Mutating State in Reducer

Symptom: State changes but UI doesnโ€™t update, or weird bugs

// WRONG: Mutating state
function todosReducer(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      state.push(action.payload);  // Mutation!
      return state;  // Same reference!
  }
}

// RIGHT: Creating new state
function todosReducer(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, action.payload];  // New array
  }
}

Debug: Use Object.freeze() on state in development:

function createStore(reducer, initialState) {
  let state = Object.freeze(initialState);
  // ...
  dispatch(action) {
    state = Object.freeze(reducer(state, action));  // Throws if mutated
  }
}

Pitfall 2: Returning Wrong Reference

Symptom: Unnecessary re-renders, performance issues

// WRONG: Always creating new object
function reducer(state = initialState, action) {
  return {
    ...state,
    unchanged: state.unchanged  // Same data, new reference
  };
}

// RIGHT: Return same reference if unchanged
function reducer(state = initialState, action) {
  switch (action.type) {
    case 'SPECIFIC_ACTION':
      return { ...state, changed: action.payload };
    default:
      return state;  // Same reference
  }
}

Pitfall 3: Side Effects in Reducers

Symptom: Non-deterministic behavior, canโ€™t replay actions

// WRONG: Side effects in reducer
function reducer(state, action) {
  switch (action.type) {
    case 'FETCH_USER':
      fetch('/api/user').then(...);  // Side effect!
      return state;

    case 'ADD_TODO':
      console.log('Adding todo');  // Side effect!
      return { ...state, todos: [...state.todos, {
        id: Date.now(),  // Non-deterministic!
        ...action.payload
      }]};
  }
}

// RIGHT: Put side effects in middleware/thunks, use deterministic IDs
const addTodo = (text) => ({
  type: 'ADD_TODO',
  payload: { id: uuid(), text }  // ID generated in action creator
});

Pitfall 4: Subscribing in Dispatch

Symptom: Infinite loops, stack overflow

// WRONG: Dispatching in listener can cause loops
store.subscribe(() => {
  store.dispatch({ type: 'SYNC' });  // Triggers listener again!
});

// RIGHT: Guard against re-entry
let syncing = false;
store.subscribe(() => {
  if (syncing) return;
  syncing = true;
  store.dispatch({ type: 'SYNC' });
  syncing = false;
});

Extensions and Challenges

Challenge 1: Selector Library

Implement reselect-style memoized selectors:

function createSelector(...inputSelectors, resultFunc) {
  let lastInputs = [];
  let lastResult;

  return (state) => {
    const inputs = inputSelectors.map(sel => sel(state));
    const inputsChanged = inputs.some((input, i) => input !== lastInputs[i]);

    if (inputsChanged) {
      lastInputs = inputs;
      lastResult = resultFunc(...inputs);
    }

    return lastResult;
  };
}

const getVisibleTodos = createSelector(
  state => state.todos,
  state => state.filter,
  (todos, filter) => {
    switch (filter) {
      case 'active': return todos.filter(t => !t.done);
      case 'completed': return todos.filter(t => t.done);
      default: return todos;
    }
  }
);

Challenge 2: Redux DevTools Integration

Add support for the Redux DevTools browser extension:

function createStoreWithDevTools(reducer, initialState) {
  const store = createStore(reducer, initialState);

  if (typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__) {
    const devTools = window.__REDUX_DEVTOOLS_EXTENSION__.connect();

    devTools.init(store.getState());

    const originalDispatch = store.dispatch;
    store.dispatch = (action) => {
      const result = originalDispatch(action);
      devTools.send(action, store.getState());
      return result;
    };
  }

  return store;
}

Challenge 3: Observable Store

Make the store work with RxJS:

import { Observable } from 'rxjs';

function createObservableStore(reducer, initialState) {
  const store = createStore(reducer, initialState);

  return {
    ...store,
    state$: new Observable(subscriber => {
      subscriber.next(store.getState());
      return store.subscribe(() => subscriber.next(store.getState()));
    })
  };
}

Challenge 4: Immutable.js Integration

Use Immutable.js for efficient immutable updates:

import { Map, List } from 'immutable';

function todosReducer(state = List(), action) {
  switch (action.type) {
    case 'ADD_TODO':
      return state.push(Map({ id: action.payload.id, text: action.payload.text, done: false }));
    case 'TOGGLE_TODO':
      const index = state.findIndex(todo => todo.get('id') === action.payload.id);
      return state.updateIn([index, 'done'], done => !done);
    default:
      return state;
  }
}

Real-World Connections

Redux Ecosystem

Library Purpose
Redux Toolkit Official, batteries-included toolset
Redux-Saga Complex async flows with generators
Redux-Observable RxJS-based async middleware
Reselect Memoized selectors
React-Redux React bindings
Redux DevTools Time-travel debugging

Patterns That Use Redux Ideas

  • Event Sourcing: Store events, not state; derive state from events
  • CQRS: Command Query Responsibility Segregation
  • Flux: Facebookโ€™s original pattern (Redux simplified it)
  • Elm Architecture: Message, Update, Model (pure FP version)
  • Vuex/Pinia: Vueโ€™s state management (same concepts)
  • NgRx: Angularโ€™s Redux implementation

When to Use Redux

Good fit:

  • Complex state shared across many components
  • State changes need to be auditable
  • Team needs strict state management discipline
  • Time-travel debugging would help

Overkill:

  • Simple apps with local state
  • State doesnโ€™t need to be shared
  • Rapid prototyping

Resources

Essential Reading

Topic Resource
Immutability Fluent Python 2nd Ed. Ch. 6
Pure functions Domain Modeling Made Functional Ch. 4
The Elm Architecture Official Elm Guide
State machines Domain Modeling Made Functional Ch. 8

Official Resources

Video Resources

  • Dan Abramovโ€™s โ€œGetting Started with Reduxโ€ (free on Egghead)
  • Dan Abramovโ€™s โ€œBuilding React Applications with Idiomatic Reduxโ€

Self-Assessment Checklist

Core Store

  • createStore returns store object
  • getState returns current state
  • dispatch updates state via reducer
  • subscribe registers listeners
  • unsubscribe removes listeners
  • Initial action (@@INIT) is dispatched

Reducer Composition

  • combineReducers merges multiple reducers
  • Each reducer handles its slice of state
  • Unknown actions return current state
  • State reference unchanged if nothing changed

Middleware

  • applyMiddleware creates enhancer
  • Middleware can intercept actions
  • Middleware chain works correctly
  • Thunk middleware handles function actions
  • Logger middleware logs actions and state

Time Travel

  • Past states are tracked
  • Undo restores previous state
  • Redo restores future state
  • New actions clear future stack

Quality

  • No state mutation in reducers
  • Tests cover all functionality
  • TypeScript types are correct
  • Example app demonstrates features

Interview Questions

  1. โ€œWhat problem does Redux solve?โ€
    • Expected: Predictable state management via pure functions and immutability
  2. โ€œWhy must reducers be pure functions?โ€
    • Expected: Same input = same output, enables testing, time-travel, SSR
  3. โ€œHow does immutability enable time-travel?โ€
    • Expected: Previous states preserved, can restore any point in history
  4. โ€œWhat is the middleware pattern in Redux?โ€
    • Expected: Functions that wrap dispatch, can intercept/transform actions
  5. โ€œWhen would you use a thunk vs a saga?โ€
    • Expected: Thunk for simple async, saga for complex flows with cancellation
  6. โ€œHow do you handle side effects in Redux?โ€
    • Expected: Keep reducers pure, use middleware for effects
  7. โ€œWhat are selectors and why memoize them?โ€
    • Expected: Derive data from state, memoize to avoid unnecessary recomputation