Vanilla JS State Management for Advanced Apps
Build robust state management solutions in vanilla JavaScript. Covers immutable state containers, selectors with memoization, middleware pipelines, action dispatching, computed properties, and time-travel debugging for complex applications.
Complex applications need predictable state management. This guide builds a complete state management system from scratch in vanilla JavaScript, covering immutable updates, selectors, middleware, and time-travel debugging without any framework dependency.
For the MVC pattern that state management extends, see JavaScript MVC Architecture: Complete Guide.
Immutable State Container
class Store {
#state;
#reducers = {};
#listeners = new Set();
#middleware = [];
constructor(initialState = {}) {
this.#state = Object.freeze(this.#deepClone(initialState));
}
#deepClone(obj) {
if (obj === null || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map((item) => this.#deepClone(item));
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, this.#deepClone(v)])
);
}
#deepFreeze(obj) {
if (obj === null || typeof obj !== "object") return obj;
Object.freeze(obj);
Object.values(obj).forEach((v) => this.#deepFreeze(v));
return obj;
}
getState() {
return this.#state;
}
addReducer(name, reducer) {
this.#reducers[name] = reducer;
return this;
}
use(middleware) {
this.#middleware.push(middleware);
return this;
}
dispatch(action) {
// Build middleware chain
const chain = this.#middleware.map((mw) => mw(this));
const dispatchAction = chain.reduceRight(
(next, middleware) => (action) => middleware(next)(action),
(action) => this.#processAction(action)
);
return dispatchAction(action);
}
#processAction(action) {
const prevState = this.#state;
let nextState = { ...prevState };
let changed = false;
for (const [name, reducer] of Object.entries(this.#reducers)) {
const slice = prevState[name];
const nextSlice = reducer(slice, action);
if (nextSlice !== slice) {
nextState[name] = nextSlice;
changed = true;
}
}
if (changed) {
this.#state = this.#deepFreeze(nextState);
this.#notify(action);
}
return action;
}
subscribe(listener) {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
#notify(action) {
for (const listener of this.#listeners) {
listener(this.#state, action);
}
}
}
// Reducers
function todosReducer(state = [], action) {
switch (action.type) {
case "ADD_TODO":
return [...state, { id: Date.now(), text: action.payload, completed: false }];
case "TOGGLE_TODO":
return state.map((t) =>
t.id === action.payload ? { ...t, completed: !t.completed } : t
);
case "REMOVE_TODO":
return state.filter((t) => t.id !== action.payload);
default:
return state;
}
}
function filtersReducer(state = { status: "all", search: "" }, action) {
switch (action.type) {
case "SET_FILTER":
return { ...state, status: action.payload };
case "SET_SEARCH":
return { ...state, search: action.payload };
default:
return state;
}
}
const store = new Store({ todos: [], filters: { status: "all", search: "" } });
store.addReducer("todos", todosReducer);
store.addReducer("filters", filtersReducer);Memoized Selectors
function createSelector(...args) {
const resultFn = args.pop();
const inputSelectors = args;
let lastInputs = null;
let lastResult = null;
return function selector(state) {
const inputs = inputSelectors.map((sel) => sel(state));
const inputsChanged =
lastInputs === null ||
inputs.some((input, i) => input !== lastInputs[i]);
if (inputsChanged) {
lastResult = resultFn(...inputs);
lastInputs = inputs;
}
return lastResult;
};
}
// Base selectors
const getTodos = (state) => state.todos;
const getFilters = (state) => state.filters;
// Derived selectors
const getFilteredTodos = createSelector(
getTodos,
getFilters,
(todos, filters) => {
let result = todos;
if (filters.status === "active") {
result = result.filter((t) => !t.completed);
} else if (filters.status === "completed") {
result = result.filter((t) => t.completed);
}
if (filters.search) {
const lower = filters.search.toLowerCase();
result = result.filter((t) => t.text.toLowerCase().includes(lower));
}
return result;
}
);
const getTodoStats = createSelector(getTodos, (todos) => ({
total: todos.length,
active: todos.filter((t) => !t.completed).length,
completed: todos.filter((t) => t.completed).length,
completionRate:
todos.length > 0
? Math.round(
(todos.filter((t) => t.completed).length / todos.length) * 100
)
: 0,
}));
// Selectors recompute only when inputs change
console.log(getFilteredTodos(store.getState()));
console.log(getTodoStats(store.getState()));Middleware Pipeline
// Logger middleware
const loggerMiddleware = (store) => (next) => (action) => {
console.group(`Action: ${action.type}`);
console.log("Prev state:", store.getState());
const result = next(action);
console.log("Next state:", store.getState());
console.groupEnd();
return result;
};
// Async/thunk middleware
const thunkMiddleware = (store) => (next) => (action) => {
if (typeof action === "function") {
return action(store.dispatch.bind(store), store.getState.bind(store));
}
return next(action);
};
// Validation middleware
const validationMiddleware = (store) => (next) => (action) => {
switch (action.type) {
case "ADD_TODO":
if (!action.payload || action.payload.trim().length === 0) {
console.warn("Cannot add empty todo");
return;
}
break;
}
return next(action);
};
// Batch middleware for grouping updates
const batchMiddleware = (store) => {
let batching = false;
let pendingActions = [];
return (next) => (action) => {
if (action.type === "BATCH_START") {
batching = true;
return;
}
if (action.type === "BATCH_END") {
batching = false;
const actions = [...pendingActions];
pendingActions = [];
actions.forEach((a) => next(a));
return;
}
if (batching) {
pendingActions.push(action);
return;
}
return next(action);
};
};
// Wire everything together
store
.use(loggerMiddleware)
.use(thunkMiddleware)
.use(validationMiddleware);
// Async action creator using thunk
function fetchTodos() {
return async (dispatch) => {
dispatch({ type: "FETCH_TODOS_START" });
try {
const response = await fetch("/api/todos");
const todos = await response.json();
dispatch({ type: "FETCH_TODOS_SUCCESS", payload: todos });
} catch (error) {
dispatch({ type: "FETCH_TODOS_ERROR", payload: error.message });
}
};
}
store.dispatch(fetchTodos());Computed Properties
class ComputedStore extends Store {
#computedDefs = new Map();
#computedCache = new Map();
addComputed(name, dependencies, computeFn) {
this.#computedDefs.set(name, { dependencies, computeFn });
this.#computedCache.delete(name);
return this;
}
getComputed(name) {
const def = this.#computedDefs.get(name);
if (!def) throw new Error(`Unknown computed: ${name}`);
const state = this.getState();
const currentDeps = def.dependencies.map((dep) => {
if (typeof dep === "function") return dep(state);
return state[dep];
});
const cached = this.#computedCache.get(name);
if (cached) {
const depsUnchanged = cached.deps.every(
(d, i) => d === currentDeps[i]
);
if (depsUnchanged) return cached.value;
}
const value = def.computeFn(...currentDeps);
this.#computedCache.set(name, { deps: currentDeps, value });
return value;
}
}
// Usage
const computedStore = new ComputedStore({
cart: [],
taxRate: 0.08,
});
computedStore.addComputed(
"cartTotal",
["cart", "taxRate"],
(cart, taxRate) => {
const subtotal = cart.reduce((sum, item) => sum + item.price * item.qty, 0);
const tax = subtotal * taxRate;
return { subtotal, tax, total: subtotal + tax };
}
);
computedStore.addComputed(
"cartItemCount",
["cart"],
(cart) => cart.reduce((sum, item) => sum + item.qty, 0)
);| Feature | Description | Use Case |
|---|---|---|
| Immutable state | Deep freeze prevents mutations | Predictable state changes |
| Reducers | Pure functions handle state transitions | Testable state logic |
| Selectors | Memoized derived data computations | Expensive filtering/sorting |
| Middleware | Intercept actions before reducers | Logging, validation, async |
| Computed | Auto-cached derived properties | Aggregations, totals |
| Time-travel | Undo/redo state history | Development, debugging |
Time-Travel Debugging
class TimeTravel {
#store;
#history = [];
#position = -1;
#maxHistory;
#recording = true;
constructor(store, maxHistory = 100) {
this.#store = store;
this.#maxHistory = maxHistory;
// Capture initial state
this.#pushState(store.getState(), { type: "@@INIT" });
// Subscribe to store changes
store.subscribe((state, action) => {
if (this.#recording) {
this.#pushState(state, action);
}
});
}
#pushState(state, action) {
// Discard forward history when new action is dispatched
this.#history = this.#history.slice(0, this.#position + 1);
this.#history.push({
state: JSON.parse(JSON.stringify(state)),
action,
timestamp: Date.now(),
});
// Trim excess history
if (this.#history.length > this.#maxHistory) {
this.#history.shift();
}
this.#position = this.#history.length - 1;
}
canUndo() {
return this.#position > 0;
}
canRedo() {
return this.#position < this.#history.length - 1;
}
undo() {
if (!this.canUndo()) return null;
this.#position--;
return this.#restoreCurrent();
}
redo() {
if (!this.canRedo()) return null;
this.#position++;
return this.#restoreCurrent();
}
jumpTo(index) {
if (index < 0 || index >= this.#history.length) return null;
this.#position = index;
return this.#restoreCurrent();
}
#restoreCurrent() {
const entry = this.#history[this.#position];
this.#recording = false;
// Restore state by replaying - store needs a setState method
this.#store._restoreState(entry.state);
this.#recording = true;
return entry;
}
getHistory() {
return this.#history.map((entry, i) => ({
index: i,
action: entry.action.type,
timestamp: entry.timestamp,
isCurrent: i === this.#position,
}));
}
reset() {
const initial = this.#history[0];
this.#history = [initial];
this.#position = 0;
this.#restoreCurrent();
}
}
// Usage
const debugStore = new Store({ counter: 0 });
debugStore.addReducer("counter", (state = 0, action) => {
if (action.type === "INCREMENT") return state + 1;
if (action.type === "DECREMENT") return state - 1;
return state;
});
// Add internal restore method
debugStore._restoreState = function (state) {
// This would need to be properly encapsulated
Object.assign(this, { state });
};
const timeTravel = new TimeTravel(debugStore);
debugStore.dispatch({ type: "INCREMENT" }); // counter: 1
debugStore.dispatch({ type: "INCREMENT" }); // counter: 2
debugStore.dispatch({ type: "INCREMENT" }); // counter: 3
timeTravel.undo(); // counter: 2
timeTravel.undo(); // counter: 1
timeTravel.redo(); // counter: 2Connecting State to Views
function connect(store, selectors, view) {
let prevValues = {};
function update(state) {
const nextValues = {};
let changed = false;
for (const [key, selector] of Object.entries(selectors)) {
nextValues[key] = selector(state);
if (nextValues[key] !== prevValues[key]) {
changed = true;
}
}
if (changed) {
prevValues = nextValues;
view.update(nextValues);
}
}
// Initial render
update(store.getState());
// Subscribe to changes
const unsubscribe = store.subscribe((state) => update(state));
return {
unsubscribe,
getProps: () => ({ ...prevValues }),
};
}
// Usage
class CounterView {
#element;
constructor(container) {
this.#element = document.createElement("div");
container.appendChild(this.#element);
}
update({ count, isEven }) {
this.#element.innerHTML = `
<span>Count: ${count}</span>
<span>${isEven ? "Even" : "Odd"}</span>
<button data-action="increment">+</button>
<button data-action="decrement">-</button>
`;
}
}
const counterView = new CounterView(document.body);
const connection = connect(
store,
{
count: (state) => state.counter,
isEven: (state) => state.counter % 2 === 0,
},
counterView
);| Pattern | Complexity | Scalability | Best For |
|---|---|---|---|
| Plain object passing | Low | Small apps | Prototypes, demos |
| Event emitter model | Medium | Medium apps | MVC applications |
| Centralized store | High | Large apps | Complex state flows |
| Store with middleware | High | Enterprise | Async, logging, validation |
Rune AI
Key Insights
- Immutable state containers use deep freeze to prevent accidental mutations: Every state update creates a new frozen object, making state changes explicit and traceable
- Memoized selectors cache derived computations until their input dependencies change: Selectors avoid recalculating filtered lists or aggregations when unrelated state updates occur
- Middleware pipelines intercept dispatched actions before they reach reducers: Middleware handles logging, validation, async operations, and batching without polluting reducer logic
- Time-travel debugging records state snapshots for undo, redo, and history inspection: Developers can step backward and forward through application state to diagnose bugs
- Connect functions bridge the store to views using selective subscriptions: Views only re-render when their specific selected state values change, not on every store update
Frequently Asked Questions
When should I use a centralized store instead of component-level state?
How do I handle asynchronous operations in a state container?
How do I persist state across page reloads?
Can I use this state management approach alongside framework state?
How do middleware and reducers differ in handling validation?
Conclusion
Vanilla JavaScript state management provides full control over how application data flows. Immutable state containers prevent accidental mutations. Memoized selectors efficiently derive data from state without redundant computations. Middleware pipelines handle cross-cutting concerns like logging, validation, and async operations. For the MVC patterns that benefit from centralized state, see Building Vanilla JS Apps with MVC Architecture. For event-driven communication patterns, explore The JavaScript Pub/Sub Pattern: Complete Guide.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.