Vanilla JS State Management for Advanced Apps
Build a full state management system in vanilla JavaScript. Learn how to centralize state, derive computed values, handle async actions, and connect to the DOM without a framework.
State management is the practice of keeping your application's data in one predictable place and letting the UI react when that data changes. Instead of tracking variables across files, you centralize state in a store and let every part of your app read from and write to that single source.
Frameworks like React, Vue, and Svelte each give you a state management story. But the concepts are universal. This article builds a complete vanilla JavaScript state management system so you understand exactly what happens under the hood.
The Core Store
Start with the reactive store from the observer pattern. It holds state, notifies subscribers, and enforces that state is never mutated directly:
function createStore(initialState) {
let state = { ...initialState };
const listeners = [];
return {
getState() {
return state;
},
setState(partial) {
const prev = state;
state = { ...state, ...partial };
listeners.forEach(fn => {
try {
fn(state, prev);
} catch (err) {
console.error("Store subscriber error:", err);
}
});
},
subscribe(fn) {
listeners.push(fn);
fn(state, state); // Initial call
return () => {
const idx = listeners.indexOf(fn);
if (idx !== -1) listeners.splice(idx, 1);
};
}
};
}Every state update goes through setState. Every subscriber gets the new and previous state. The unsubscribe return value prevents memory leaks.
Actions flow into the store. The store creates a new state object. Every subscriber receives the update and decides how to react. No component directly touches another component.
Computed Properties
State should not store derived values. If todos is an array in state, the count of active todos should be computed, not stored:
function createStore(initialState) {
let state = { ...initialState };
const listeners = [];
const computed = {};
return {
getState() {
return state;
},
setState(partial) {
const prev = state;
state = { ...state, ...partial };
listeners.forEach(fn => fn(state, prev));
},
// Register a computed value that derives from state
defineComputed(name, computeFn) {
computed[name] = computeFn;
},
// Read a computed value
get(name) {
if (name in computed) {
return computed[name](state);
}
return state[name];
},
subscribe(fn) {
listeners.push(fn);
fn(state, state);
return () => {
const idx = listeners.indexOf(fn);
if (idx !== -1) listeners.splice(idx, 1);
};
}
};
}Usage:
const store = createStore({
todos: [
{ id: 1, text: "Learn state management", completed: false },
{ id: 2, text: "Build a demo app", completed: true }
]
});
store.defineComputed("activeCount", (state) =>
state.todos.filter(t => !t.completed).length
);
store.defineComputed("completedCount", (state) =>
state.todos.filter(t => t.completed).length
);
store.defineComputed("isEmpty", (state) =>
state.todos.length === 0
);
console.log(store.get("activeCount")); // 1
console.log(store.get("completedCount")); // 1
console.log(store.get("isEmpty")); // falseThe rule: if a value can be calculated from existing state, do not store it. Compute it instead. This eliminates an entire class of bugs where derived state gets out of sync with source state.
Actions as Named Operations
Raw setState calls scattered across your app make it hard to understand what the app can do. Wrap state changes in named action functions:
function createTodoStore() {
const store = createStore({
todos: [],
filter: "all"
});
// Computed values
store.defineComputed("filteredTodos", (state) => {
if (state.filter === "active") {
return state.todos.filter(t => !t.completed);
}
if (state.filter === "completed") {
return state.todos.filter(t => t.completed);
}
return state.todos;
});
store.defineComputed("stats", (state) => ({
total: state.todos.length,
active: state.todos.filter(t => !t.completed).length,
completed: state.todos.filter(t => t.completed).length
}));
// Actions -- the only way to change state
return {
...store,
addTodo(text) {
const todo = { id: Date.now(), text: text.trim(), completed: false };
if (!todo.text) return;
store.setState({
todos: [...store.getState().todos, todo]
});
},
toggleTodo(id) {
store.setState({
todos: store.getState().todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
)
});
},
removeTodo(id) {
store.setState({
todos: store.getState().todos.filter(t => t.id !== id)
});
},
setFilter(filter) {
store.setState({ filter });
},
clearCompleted() {
store.setState({
todos: store.getState().todos.filter(t => !t.completed)
});
}
};
}Now the app never calls setState directly. Every state change is a named action. This gives you a complete audit trail. Add console.log to every action, and you can trace exactly what the user did.
This is the same pattern libraries like Redux formalize. Actions describe what happened. The store determines how state changes. See the pub-sub event bus pattern for another approach to decoupled communication.
Middleware: Cross-Cutting Concerns
Middleware runs before or after every state change. Use it for logging, persistence, or async side effects:
function applyMiddleware(store, ...middlewares) {
const originalSetState = store.setState.bind(store);
store.setState = (partial) => {
const prev = store.getState();
const next = { ...prev, ...partial };
// Run before-middleware
const shouldContinue = middlewares.every(mw => {
if (mw.before) return mw.before(prev, next);
return true;
});
if (!shouldContinue) return;
originalSetState(partial);
// Run after-middleware
middlewares.forEach(mw => {
if (mw.after) mw.after(prev, store.getState());
});
};
return store;
}Here are three practical middleware:
// Log every state change
const logger = {
before(prev, next) {
console.group("State Change");
console.log("Previous:", prev);
console.log("Next:", next);
console.groupEnd();
return true;
}
};
// Persist state to localStorage
const persist = {
after(prev, next) {
localStorage.setItem("app-state", JSON.stringify(next));
}
};
// Prevent duplicate state updates
const dedupe = {
before(prev, next) {
const changed = Object.keys(next).some(
key => prev[key] !== next[key]
);
return changed; // false = skip the update
}
};
const store = applyMiddleware(
createTodoStore(),
logger,
persist,
dedupe
);Middleware is the secret to keeping your store simple. Instead of adding logging, persistence, and validation inside every action, add them as middleware. Each middleware does one thing. They compose together.
Connecting to the DOM
The final piece is rendering. Each subscriber updates a specific part of the DOM based on state:
// Store setup
const app = createTodoStore();
// Render the todo list
app.subscribe((state) => {
const listEl = document.getElementById("todo-list");
const todos = app.get("filteredTodos");
if (todos.length === 0) {
listEl.innerHTML = "<p class='empty'>No todos to show</p>";
return;
}
listEl.innerHTML = todos.map(todo => `
<li class="${todo.completed ? 'completed' : ''}">
<span>${escapeHtml(todo.text)}</span>
<button data-action="toggle" data-id="${todo.id}">
${todo.completed ? 'Undo' : 'Done'}
</button>
<button data-action="remove" data-id="${todo.id}">Delete</button>
</li>
`).join("");
});
// Render stats
app.subscribe((state) => {
const stats = app.get("stats");
document.getElementById("stats").textContent =
`${stats.total} total, ${stats.active} active, ${stats.completed} completed`;
});
// Render filter buttons
app.subscribe((state) => {
document.querySelectorAll("[data-filter]").forEach(btn => {
btn.classList.toggle("active", btn.dataset.filter === state.filter);
});
});Notice the rendering functions never call setState. They are pure: state in, DOM out. Actions are the only place that changes state. This separation is the essence of predictable UI architecture.
Undo and Redo with State History
Because every state change goes through setState, you can track history:
function createStoreWithHistory(initialState) {
const store = createStore(initialState);
const history = [{ ...initialState }];
let cursor = 0;
const originalSetState = store.setState.bind(store);
store.setState = (partial) => {
const prev = store.getState();
const next = { ...prev, ...partial };
// Discard any future states if we are in the middle of history
history.splice(cursor + 1);
history.push(next);
cursor = history.length - 1;
// Keep a reasonable history size
if (history.length > 50) {
history.shift();
cursor -= 1;
}
originalSetState(partial);
};
return {
...store,
undo() {
if (cursor <= 0) return;
cursor -= 1;
store.setState(history[cursor]);
},
redo() {
if (cursor >= history.length - 1) return;
cursor += 1;
store.setState(history[cursor]);
},
canUndo() {
return cursor > 0;
},
canRedo() {
return cursor < history.length - 1;
}
};
}Every setState call pushes a snapshot onto the history stack. Undo moves the cursor back. Redo moves it forward. This works because state is always replaced, never mutated.
Common Mistakes
Storing derived data. If activeCount can be computed from todos, do not put it in state. Duplicate state inevitably gets out of sync.
Mutating state directly. Never do state.todos.push(item). Always create a new array with [...state.todos, item]. Direct mutation bypasses subscribers.
Calling setState inside a subscriber. This creates cascading updates. If subscriber A's render triggers setState, subscriber B fires, which triggers setState again. Keep subscribers pure.
Too much in state. Only put shared application state in the store. Local UI state like "is this dropdown open" belongs in the component, not the store. See the observer pattern for component-level reactivity.
Rune AI
Key Insights
- A centralized store holds all application state in one place.
- State is never mutated directly. setState creates a new state object.
- Subscribers re-render only the parts of the UI that depend on changed state.
- Computed properties derive values from state without storing them twice.
- Middleware adds cross-cutting concerns like logging, persistence, and async handling.
Frequently Asked Questions
Why build state management from scratch instead of using Redux or Zustand?
Can this replace a framework's state management?
How is this different from the reactive store in the observer article?
Conclusion
State management is the practice of keeping your application state in one predictable place and letting the UI react to changes. Build a store, add computed values, handle side effects with middleware, and connect it to the DOM. The patterns you build here are the foundation of every state management library.State management boils down to three rules: centralize state in one place, change it only through defined actions, and let subscribers react to changes. Everything else -- computed values, middleware, history -- builds on that foundation. The patterns in this article are not theoretical. They are exactly how Redux, Zustand, Pinia, and every other state library works. The difference is that libraries add devtools, performance optimizations, and framework integrations on top. The core concepts are what you just built.
More in this topic
Using Reflect and Proxy Together in JavaScript
Proxy traps intercept operations, but Reflect is what makes them behave correctly. Learn why Reflect belongs inside every trap and what breaks when you skip it.
Top JS Array Methods Interview Questions to Know
The array method questions that come up most often in JavaScript interviews, answered directly with short examples: map vs forEach, mutating vs non-mutating methods, reduce, and more.
JavaScript Reflect API: Advanced Architecture
The Reflect object exposes JavaScript's own internal operations as plain functions. Learn every Reflect method, what it returns, and why it exists as its own API.