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.

JavaScriptadvanced
18 min read

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

javascriptjavascript
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

javascriptjavascript
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

javascriptjavascript
// 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

javascriptjavascript
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)
);
FeatureDescriptionUse Case
Immutable stateDeep freeze prevents mutationsPredictable state changes
ReducersPure functions handle state transitionsTestable state logic
SelectorsMemoized derived data computationsExpensive filtering/sorting
MiddlewareIntercept actions before reducersLogging, validation, async
ComputedAuto-cached derived propertiesAggregations, totals
Time-travelUndo/redo state historyDevelopment, debugging

Time-Travel Debugging

javascriptjavascript
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: 2

Connecting State to Views

javascriptjavascript
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
);
PatternComplexityScalabilityBest For
Plain object passingLowSmall appsPrototypes, demos
Event emitter modelMediumMedium appsMVC applications
Centralized storeHighLarge appsComplex state flows
Store with middlewareHighEnterpriseAsync, logging, validation
Rune AI

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
RunePowered by Rune AI

Frequently Asked Questions

When should I use a centralized store instead of component-level state?

Use a centralized store when multiple unrelated components depend on the same data or when actions in one part of the app affect state displayed in another. For state that only one component uses (like whether a dropdown is open), keep it local. The rule of thumb is: if you need to pass state through more than two levels or share it across sibling components, centralize it.

How do I handle asynchronous operations in a state container?

Use thunk middleware that checks if a dispatched action is a function instead of an object. If it is a function, invoke it with `dispatch` and `getState` as arguments. The function can then dispatch synchronous actions at each stage: start, success, and error. This keeps async logic out of reducers while providing state updates for loading indicators and error messages.

How do I persist state across page reloads?

Subscribe to store changes and save state to localStorage. On initialization, read saved state and merge it with defaults. Use debouncing to avoid writing on every rapid state change. For selective persistence, only save specific state slices rather than the entire store. Consider versioning saved state so you can migrate when the state shape changes.

Can I use this state management approach alongside framework state?

Yes. The centralized store works independently of any framework. You can create a thin adapter that subscribes to the store and triggers framework-specific updates. For React, create a hook. For Vue, create a reactive wrapper. The store remains framework-agnostic while the adapter bridges the gap. This is useful when migrating between frameworks gradually.

How do middleware and reducers differ in handling validation?

Middleware intercepts actions before they reach reducers, so it can reject invalid actions entirely. Reducers receive only actions that pass through middleware, so they assume valid data. Use middleware for input validation (checking if a todo text is empty) and reducers for state transition logic (how the state changes). This separation keeps reducers as pure data transformers.

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.