Advanced State Management
Reviewed & published by Brayan K
State management is the discipline of keeping your application's data consistent and predictable — using a single source of truth, the observer pattern, immutable updates, and reducers, which are the same ideas that power libraries like Redux and Zustand, implemented here in plain JavaScript.
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Single source of truth
- Observer / pub-sub pattern
- Immutable updates
- Reducers & actions
- Subscribing views to changes
- Proxy reactivity & selectors
💡 Running Code Locally: Everything here is plain JavaScript you can run anywhere. For the best experience:
- Download Node.js to run JavaScript on your computer
- Use your browser's Developer Console (Press F12) to test code snippets
- Create a .html file with <script> tags and open it in your browser
📋 Real-World Analogy: A state store is like an office whiteboard everyone shares:
- • Single source of truth = one whiteboard, not sticky notes scattered on every desk
- • Actions = requests written in a logbook ("mark task done")
- • Reducer = the rule for how each logbook entry updates the board
- • Subscribers = teammates who glance up whenever the board changes
As apps grow, the hardest bugs come from state being copied, mutated, and drifting out of sync across the UI. The solution is a deliberate architecture: keep state in one store, change it only through described actions, compute the next state with pure functions, and let views subscribe to changes. These patterns are framework-independent — Redux and Zustand simply package them — and you can build them from scratch in vanilla JavaScript.
| Concept | What It Solves | Core Idea |
|---|---|---|
| Single store | Scattered, conflicting state | One source of truth |
| Pub/Sub | Keeping views in sync | Subscribe + notify |
| Reducer | Unpredictable mutations | (state, action) => newState |
🔥 A Single Source of Truth
The foundation is one store object that owns the state. Nothing reads or writes the data directly; everything goes through the store. This makes the current state easy to inspect, log, and reason about.
function createStore(initialState) {
let state = initialState;
return {
getState() {
return state;
},
setState(partial) {
// Immutable update: build a NEW state object
state = { ...state, ...partial };
return state;
}
};
}
const store = createStore({ count: 0, user: "Ada" });
console.log("Initial:", store.getState());
store.setState({ count: 1 });
console.log("After update:", store.getState());Centralizing state means there is exactly one place to look when something is wrong, and exactly one place that can change it.
🔥 The Observer (Pub/Sub) Pattern
A store is only useful if views know when it changes. The observer pattern lets parts of your app subscribe with a callback; the store notifies every subscriber whenever state updates. Subscribing returns an unsubscribe function to prevent leaks.
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
setState(partial) {
state = { ...state, ...partial };
// Notify every subscriber
listeners.forEach((fn) => fn(state));
},
subscribe(fn) {
listeners.add(fn);
// Return an unsubscribe function
return () => listeners.delete(fn);
}
};
}
const store = createStore({ count: 0 });
const unsubscribe = store.subscribe((s) =>
console.log("View notified, count =", s.count)
);
store.setState({ count: 1 });
store.setState({ count: 2 });
unsubscribe();
store.setState({ count: 3 }); // no log - we unsubscribed
// ✅ Expected output:
// View notified, count = 1
// View notified, count = 2
//
// Only two lines for three setState calls. The third change really did
// happen - store.getState().count is 3 - but nobody was listening any more.This decouples the store from the views: the store does not know what a subscriber does, only that it should be told about changes.
🔥 Immutable Updates
Rather than mutating state in place, create a new object for each change. Immutability makes change detection trivial (compare references), enables undo/redo and time-travel debugging, and prevents subtle bugs from shared references.
const state = {
user: { name: "Ada", role: "admin" },
todos: ["Write code", "Test code"]
};
// ❌ Mutation: changes the original, hard to detect
// state.user.role = "guest";
// ✅ Immutable: new objects all the way down
const nextState = {
...state,
user: { ...state.user, role: "guest" },
todos: [...state.todos, "Ship code"]
};
console.log("Original role:", state.user.role); // admin (unchanged)
console.log("Next role:", nextState.user.role); // guest
console.log("Are they the same object?", state === nextState); // falseBecause state !== nextState, a view can cheaply detect that something changed by comparing references instead of deep-scanning the data.
🔥 Reducers and Actions
A reducer is a pure function (state, action) => newState. An action is a plain object describing what happened (with a type). The reducer is the single rulebook for how state evolves, which makes transitions predictable and testable.
// Pure reducer: same inputs -> same output, no side effects
function reducer(state, action) {
switch (action.type) {
case "INCREMENT":
return { ...state, count: state.count + 1 };
case "ADD":
return { ...state, count: state.count + action.amount };
case "RESET":
return { ...state, count: 0 };
default:
return state;
}
}
function createStore(reducer, initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
dispatch(action) {
state = reducer(state, action);
listeners.forEach((fn) => fn(state));
},
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
};
}
const store = createStore(reducer, { count: 0 });
store.subscribe((s) => console.log("count:", s.count));
store.dispatch({ type: "INCREMENT" }); // 1
store.dispatch({ type: "ADD", amount: 5 }); // 6
store.dispatch({ type: "RESET" }); // 0
// ✅ Expected output:
// count: 1
// count: 6
// count: 0This is exactly the model Redux popularized: dispatch an action, the reducer returns new state, subscribers re-render. Because the reducer is pure, you can replay a list of actions to reproduce any bug.
Worked example: the four ideas working together
Single source of truth, immutable updates, a pure reducer and subscribers are four separate ideas that only make sense once you see them running as one machine. Below is a small shopping cart doing exactly that. Every dispatch prints whether the state is a genuinely new object, so you can watch immutability happen rather than believe it.
// ---- 1. The rulebook: a pure reducer ---------------------------------
// "Pure" means the same (state, action) always produces the same result and
// nothing outside the function is touched - no fetch, no Math.random(), and
// crucially no pushing into the old array.
function reducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.item] }; // NEW array
case 'REMOVE_ITEM':
return { ...state, items: state.items.filter(i => i.name !== action.name) };
case 'SET_DISCOUNT':
return { ...state, discount: action.percent };
default:
return state; // unknown action: hand the SAME object back
}
}
// ---- 2. The store: owns the state, runs the reducer, tells subscribers
function createStore(reducer, initialState) {
let state = initialState;
const listeners = new Set(); // a Set, so one function cannot subscribe twice
return {
getState: () => state,
dispatch(action) {
const previous = state;
state = reducer(state, action); // never mutate - always reassign
console.log('dispatch ' + action.type +
' | new object? ' + (state !== previous));
listeners.forEach(fn => fn(state)); // every subscriber hears about it
},
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn); // call this to stop listening
}
};
}
// ---- 3. Selectors: derive values, never store them -------------------
const selectCount = s => s.items.length;
const selectTotal = s => {
const gross = s.items.reduce((sum, i) => sum + i.price, 0);
return Math.round(gross * (100 - s.discount)) / 100;
};
// ---- 4. Wire a "view" to the store -----------------------------------
const store = createStore(reducer, { items: [], discount: 0 });
const stop = store.subscribe(s =>
console.log(' view: ' + selectCount(s) + ' items, total ' +
selectTotal(s).toFixed(2)));
store.dispatch({ type: 'ADD_ITEM', item: { name: 'Tea', price: 3.5 } });
store.dispatch({ type: 'ADD_ITEM', item: { name: 'Mug', price: 9 } });
store.dispatch({ type: 'SET_DISCOUNT', percent: 10 });
store.dispatch({ type: 'REMOVE_ITEM', name: 'Tea' });
store.dispatch({ type: 'MYSTERY_ACTION' }); // the reducer ignores it
stop(); // unsubscribe the view
store.dispatch({ type: 'ADD_ITEM', item: { name: 'Spoon', price: 1 } });
console.log('final count (the view never heard): ' + selectCount(store.getState()));
// ✅ Expected output:
// dispatch ADD_ITEM | new object? true
// view: 1 items, total 3.50
// dispatch ADD_ITEM | new object? true
// view: 2 items, total 12.50
// dispatch SET_DISCOUNT | new object? true
// view: 2 items, total 11.25
// dispatch REMOVE_ITEM | new object? true
// view: 1 items, total 8.10
// dispatch MYSTERY_ACTION | new object? false
// view: 1 items, total 8.10
// dispatch ADD_ITEM | new object? true
// final count (the view never heard): 2
//
// Three things worth pausing on:
// - MYSTERY_ACTION printed 'new object? false'. The reducer returned the
// same object, which is precisely how a view knows nothing changed:
// compare two references instead of deep-scanning the data.
// - The view still ran for MYSTERY_ACTION. This store notifies on every
// dispatch; real ones often skip the notify when state === previous.
// - After stop(), the state kept changing and the view went quiet. Forget
// that unsubscribe in a real app and you have a memory leak.🎯 Your turn: write the reducer cases
The store and the driving code are done. Three blanks, all in the reducer, and all the same idea: build a new object or array rather than editing the old one. The last line of output is your immutability check — it can only print true if you never touched the original.
// 🎯 YOUR TURN — fill in the blanks marked with ___
function createStore(reducer, initialState) { // already written
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
dispatch(action) { state = reducer(state, action); listeners.forEach(fn => fn(state)); },
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
};
}
function reducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
// 1) A NEW state object, with a NEW array that ends with action.text.
// state.todos.push(...) would be a mutation - do not use it.
return { ...state, todos: ___ }; // 👉 [...state.todos, action.text]
case 'SET_FILTER':
// 2) Same state, one field replaced.
return { ...state, filter: ___ }; // 👉 action.filter
case 'CLEAR':
// 3) Empty the list - again by replacing it, not by editing it.
return { ...state, todos: ___ }; // 👉 []
default:
return state;
}
}
const store = createStore(reducer, { todos: [], filter: 'all' });
store.subscribe(s =>
console.log('todos: [' + s.todos.join(', ') + '] filter=' + s.filter));
const before = store.getState(); // a reference to the FIRST state
store.dispatch({ type: 'ADD_TODO', text: 'Write tests' });
store.dispatch({ type: 'ADD_TODO', text: 'Fix bug' });
store.dispatch({ type: 'SET_FILTER', filter: 'active' });
store.dispatch({ type: 'CLEAR' });
console.log('did the original state object survive untouched? ' +
(before.todos.length === 0));
// ✅ Expected output once all three blanks are filled:
// todos: [Write tests] filter=all
// todos: [Write tests, Fix bug] filter=all
// todos: [Write tests, Fix bug] filter=active
// todos: [] filter=active
// did the original state object survive untouched? true
//
// That last line is the whole lesson. If it prints false you mutated the
// array the store started with - most likely with push() - and the "old"
// state is no longer old. Undo, time-travel debugging and cheap change
// detection all die at that moment.🔥 Subscribing Views to Changes
Views become a function of state: subscribe to the store, and re-render whenever it changes. The view never holds its own copy of the data — it always reflects the latest store state.
function createStore(reducer, initial) {
let state = initial;
const listeners = new Set();
return {
getState: () => state,
dispatch(action) { state = reducer(state, action); listeners.forEach(f => f(state)); },
subscribe(fn) { listeners.add(fn); fn(state); return () => listeners.delete(fn); }
};
}
const reducer = (s, a) =>
a.type === "TOGGLE" ? { ...s, on: !s.on } : s;
const store = createStore(reducer, { on: false });
// A "view" that just logs - imagine it updating the DOM
function render(state) {
console.log("Render -> switch is", state.on ? "ON" : "OFF");
}
store.subscribe(render); // renders immediately with initial state
store.dispatch({ type: "TOGGLE" });
store.dispatch({ type: "TOGGLE" });In a real app, render would update the DOM. The pattern is the same one frameworks use internally: state changes flow to subscribed views automatically.
🔥 Proxy-Based Reactivity
Instead of calling setState manually, a Proxy can make plain property assignment reactive. By trapping set (and get), the store notices writes and notifies subscribers automatically — the technique behind Vue's reactivity and signals.
Proxies remove boilerplate: components mutate state naturally while the system tracks reads and writes behind the scenes to know exactly what to update.
🔥 Derived State and Selectors
Do not store what you can compute. A selector derives a value from state, keeping the store minimal and avoiding duplicated data that can drift out of sync. Selectors are often memoized so they only recompute when their inputs change.
const state = {
todos: [
{ text: "Write", done: true },
{ text: "Test", done: false },
{ text: "Ship", done: false }
]
};
// Selectors: derive values, never store them
const selectRemaining = (s) => s.todos.filter((t) => !t.done).length;
const selectProgress = (s) =>
Math.round((s.todos.filter((t) => t.done).length / s.todos.length) * 100);
console.log("Remaining:", selectRemaining(state)); // 2
console.log("Progress:", selectProgress(state) + "%"); // 33%
// Simple memoization: recompute only when the input reference changes
function memoize(fn) {
let lastArg, lastResult;
return (arg) => {
if (arg !== lastArg) {
lastArg = arg;
lastResult = fn(arg);
}
return lastResult;
};
}
const memoRemaining = memoize(selectRemaining);
console.log("Memoized:", memoRemaining(state));Keeping derived data out of the store is one of the highest-leverage habits in state management: there is simply less that can go wrong.
🔥 The Ideas Behind Redux and Zustand
Everything above maps directly onto popular libraries. Redux is a store plus reducers, actions, and dispatch. Zustand is a tiny subscribable store with a getState/setState/subscribe shape. Understanding the vanilla patterns means you understand the libraries — and can pick the right tool deliberately.
🎯 Mini-Challenge: Write createStore Yourself
You have now read createStore four times. Write it from the outline without looking back. It is about ten lines, and those ten lines are the core of Redux, Zustand and every framework store you will ever use.
The reducer and the script that drives it are given, so the output tells you immediately whether your store works.
// The reducer is written for you - a game lobby
function reducer(state, action) {
switch (action.type) {
case 'JOIN': return { ...state, players: [...state.players, action.name] };
case 'SCORE': return { ...state, score: state.score + action.points };
case 'LEAVE': return { ...state, players: state.players.filter(p => p !== action.name) };
default: return state;
}
}
// 🎯 MINI-CHALLENGE: write createStore(reducer, initialState)
// It must return an object with exactly three things:
// getState() -> the current state
// dispatch(action)-> run the reducer, replace the state, then call every
// listener with the new state
// subscribe(fn) -> remember fn, and RETURN a function that removes it
// Hints:
// - hold the state in a 'let' inside createStore (closure = private state)
// - hold the listeners in a Set; Set has .add, .delete and .forEach
// - subscribe must return () => listeners.delete(fn)
function createStore(reducer, initialState) {
// your code here
}
const store = createStore(reducer, { players: [], score: 0 });
const stop = store.subscribe(s =>
console.log('players: ' + s.players.join('/') + ' | score: ' + s.score));
store.dispatch({ type: 'JOIN', name: 'Ada' });
store.dispatch({ type: 'JOIN', name: 'Linus' });
store.dispatch({ type: 'SCORE', points: 30 });
store.dispatch({ type: 'LEAVE', name: 'Ada' });
stop(); // the view stops listening
store.dispatch({ type: 'SCORE', points: 5 });
console.log('final score after unsubscribing: ' + store.getState().score);
// ✅ Expected output when your store is right:
// players: Ada | score: 0
// players: Ada/Linus | score: 0
// players: Ada/Linus | score: 30
// players: Linus | score: 30
// final score after unsubscribing: 35
//
// ❌ TypeError: Cannot read properties of undefined (reading 'subscribe')
// means createStore returned nothing - it needs a return statement.
// A fifth 'players:' line after stop() means subscribe did not return a
// working remover.
// 'final score: 30' means dispatch is not reassigning state.❌ State Management Anti-Patterns
- Mutating state in place — breaks reference-based change detection and time-travel
- Multiple sources of truth — copies drift out of sync and cause "impossible" bugs
- Side effects inside reducers — reducers must be pure; do I/O elsewhere
- Storing derived data — compute it with selectors instead of duplicating it
- Forgetting to unsubscribe — leaked listeners cause memory leaks and stale updates
🎯 Key Takeaways
- Keep application state in a single source of truth
- Use the observer/pub-sub pattern so views subscribe and the store notifies them
- Update state immutably by creating new objects
- A reducer is a pure (state, action) => newState function driven by actions
- Views become a function of state and re-render on change
- A Proxy can make plain assignment reactive automatically
- Derive values with memoized selectors instead of storing them
- These ideas power Redux and Zustand — no framework required
Practice quiz
What does 'single source of truth' mean in state management?
- Application state lives in one central store rather than scattered everywhere
- Every component keeps its own private copy of the data
- State is stored only on the server
- Only one user can edit state at a time
Answer: Application state lives in one central store rather than scattered everywhere. A single source of truth keeps app state in one central store, so all views read from and update the same place, avoiding inconsistency.
What is the core idea of the observer (pub/sub) pattern?
- It stores data in cookies
- It compiles code to wasm
- Subscribers register callbacks and are notified when state changes
- It blocks the UI until data loads
Answer: Subscribers register callbacks and are notified when state changes. In pub/sub, subscribers register listeners and the store notifies them whenever the state it owns changes.
What does an 'immutable update' do?
- Deletes the previous state permanently
- Produces a new state object instead of modifying the old one
- Locks the object so it can never change
- Mutates the existing object in place
Answer: Produces a new state object instead of modifying the old one. Immutable updates create a new object (e.g. with spread syntax) rather than mutating the existing one, which makes change detection and time-travel easier.
What is the signature of a reducer function?
- (store) => subscribers
- () => undefined
- (action) => view
- (state, action) => newState
Answer: (state, action) => newState. A reducer is a pure function of the form (state, action) => newState; it computes the next state from the current state and a described action.
What is an action in a reducer-based store?
- A plain object describing what happened, usually with a type field
- A direct DOM event
- A CSS class
- A network socket
Answer: A plain object describing what happened, usually with a type field. An action is a plain object that describes an event, typically with a type (and optional payload); the reducer decides how it changes state.
Why should a reducer be a pure function?
- So it can mutate global variables
- So the same inputs always yield the same output with no side effects, making state predictable and testable
- So it can render the DOM directly
- So it can call the network
Answer: So the same inputs always yield the same output with no side effects, making state predictable and testable. Pure reducers (no side effects, deterministic) make state transitions predictable, testable, and safe to replay.
How do views stay up to date in a store-based architecture?
- They poll the store every millisecond
- They reload the whole page on each change
- They read state only once at startup
- They subscribe to the store and re-render when notified of changes
Answer: They subscribe to the store and re-render when notified of changes. Views subscribe to the store; when state changes the store notifies subscribers, which then re-render with the new state.
How can a JavaScript Proxy enable reactivity?
- By storing the object in localStorage
- By freezing the object
- By intercepting get/set operations to track reads and trigger updates on writes
- By compiling the object to wasm
Answer: By intercepting get/set operations to track reads and trigger updates on writes. A Proxy can trap property get and set, letting a system track which state is read and automatically notify subscribers when it is written.
What is a selector (or derived state)?
- A way to delete state
- A function that computes a value from state, often memoized, instead of storing it
- A network request helper
- A CSS selector for styling
Answer: A function that computes a value from state, often memoized, instead of storing it. A selector derives a value from existing state (and is often memoized), keeping the store minimal and avoiding duplicated, drift-prone data.
What well-known libraries are built on these vanilla-JS ideas?
- Redux and Zustand
- jQuery and Lodash
- Express and Koa
- Sass and Less
Answer: Redux and Zustand. Redux (store + reducers + actions) and Zustand (a small subscribable store) are built on the same single-source-of-truth and subscription ideas you can implement in plain JS.
Continue this course
- Previous: WebAssembly & JS Interop
- Quick reference: JavaScript cheat sheet