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:
- Internalize immutability - Experience why never mutating state eliminates bugs
- Master pure reducers - Write functions that transform state predictably
- Understand unidirectional data flow - See how one-way data makes apps predictable
- Implement time-travel debugging - Experience why immutability enables the impossible
- Isolate side effects - Learn to push impurity to the edges with middleware
- 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] โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ

Key principles:
- Single source of truth: One state object in one store
- State is read-only: Never mutate; create new state objects
- 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?
stateis private (canโt be mutated from outside)- Only
dispatchcan change state (enforces the pattern) subscribeenables 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
- Basic store: getState, dispatch, subscribe
- Reducer composition: combineReducers
- Middleware: applyMiddleware, compose
- Time-travel: Undo/redo enhancer
- 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
- โWhat problem does Redux solve?โ
- Expected: Predictable state management via pure functions and immutability
- โWhy must reducers be pure functions?โ
- Expected: Same input = same output, enables testing, time-travel, SSR
- โHow does immutability enable time-travel?โ
- Expected: Previous states preserved, can restore any point in history
- โWhat is the middleware pattern in Redux?โ
- Expected: Functions that wrap dispatch, can intercept/transform actions
- โWhen would you use a thunk vs a saga?โ
- Expected: Thunk for simple async, saga for complex flows with cancellation
- โHow do you handle side effects in Redux?โ
- Expected: Keep reducers pure, use middleware for effects
- โWhat are selectors and why memoize them?โ
- Expected: Derive data from state, memoize to avoid unnecessary recomputation