Centralized State Management with the Observer Pattern in Vanilla JavaScript
Build a lightweight, single‑source‑of‑truth store using the Observer pattern. Learn the minimal design, data boundaries, operational checks, and how to detect failures before they affect UI components.
19 Aug 2025, 16:46 UTC

Problem Statement
When multiple UI components need to share and react to the same data, keeping them in sync becomes error‑prone. Directly passing data through callbacks or global variables can lead to duplicated state, race conditions, and hard‑to‑debug bugs. The goal is to create a single source of truth that all components can read from and write to, without tightly coupling them.
Requirements
- Single source of truth for application state.
- Decoupled UI components that can subscribe to state changes.
- Immutable state updates to guarantee predictability.
- Runtime validation of incoming data against an expected schema.
- Clean removal of subscribers to avoid memory leaks.
Smallest Suitable Design
The core of the architecture is a Store class that owns a private state object, exposes setState and subscribe methods, and manages a list of subscriber callbacks.
class Store {
constructor(initialState = {}) {
this._state = this._freezeState(initialState);
this._subscribers = new Set();
}
_freezeState(state) {
return Object.freeze(JSON.parse(JSON.stringify(state)));
}
getState() {
return this._state;
}
setState(partial) {
if (!this._validate(partial)) {
throw new Error('State update failed validation');
}
const newState = { ...this._state, ...partial };
this._state = this._freezeState(newState);
this._notify();
}
subscribe(callback) {
if (typeof callback !== 'function') throw new TypeError('Callback must be a function');
this._subscribers.add(callback);
// Return an unsubscribe function
return () => this._subscribers.delete(callback);
}
_notify() {
for (const cb of this._subscribers) {
try { cb(this._state); } catch (e) { console.error(e); }
}
}
_validate(partial) {
// Placeholder for schema validation logic
return true;
}
}
Key points:
Setensures each subscriber is unique and allows O(1) removal.- State is deep‑cloned and frozen so external code cannot mutate it.
- Subscribers receive the updated state snapshot, not a reference to the internal object.
- The
subscribemethod returns anunsubscribefunction for clean removal.
Data Boundaries & Immutability
Immutability is enforced by:
- Deep cloning the incoming state with
JSON.parse(JSON.stringify(...))(sufficient for plain data). - Freezing the clone with
Object.freezeto prevent accidental property changes. - Providing only a read‑only snapshot via
getStateand callback parameters.
If the state contains functions or complex objects, a custom deep‑clone routine or a library such as lodash.cloneDeep should be used instead.
Operational Checks
- Schema Validation: Before merging the partial update,
_validateruns a schema check. In production, replace the placeholder with a library likeajvor a simple type guard. - Callback Safety: Each subscriber is wrapped in a
try/catchto prevent one failing component from halting notifications. - Subscriber Count Limit: Optionally enforce a maximum number of subscribers to avoid runaway memory usage.
Failure Modes
- Memory Leaks: If a component is destroyed without calling the
unsubscribefunction, the callback remains in theSetand will be invoked on every state change, holding references to DOM nodes and causing leaks. - State Mutation from Subscribers: Even though the state is frozen, a subscriber might attempt to mutate a nested object that was not frozen (e.g., arrays of objects). Ensure deep immutability or provide read‑only proxies.
- Performance Bottlenecks: In high‑frequency scenarios (e.g., a game loop), broadcasting to many subscribers can block the main thread. Consider debouncing
setStateor moving heavy logic off‑main‑thread.
Design Pivot Conditions
When the application grows:
- Complex State Transitions: If updates require combining multiple fields or performing side‑effects, move to a reducer‑based approach (Redux style) where
dispatch(action)handles state changes. - Deeply Nested Dependencies: For deeply nested objects, consider a signal‑based system (e.g.,
mobx) that tracks fine‑grained changes. - High‑Frequency Updates: If state changes exceed ~60 Hz, implement a batching or throttling mechanism to avoid UI jank.
Concrete Example
Suppose we have a simple counter UI and a logger component that listens to state changes.
// Instantiate the store
const store = new Store({ count: 0 });
// Counter component
function Counter() {
const button = document.createElement('button');
button.textContent = 'Increment';
button.onclick = () => store.setState({ count: store.getState().count + 1 });
document.body.appendChild(button);
}
// Logger component
function Logger() {
const log = (state) => console.log('State changed:', state);
const unsubscribe = store.subscribe(log);
// Store unsubscribe for later cleanup
return unsubscribe;
}
// Setup
Counter();
const unsubscribeLogger = Logger();
// Later, if Logger is no longer needed
// unsubscribeLogger();
Verification Checklist:
- Click the button once; the console should display
State changed: { count: 1 }. - Call
store.setState({ count: 2 })directly; the logger should fire again. - Attempt to mutate
state.count = 999inside the logger callback; the nextstore.getState()should still return the original count value. - Call the returned
unsubscribeLogger()and then click the button; the logger should no longer log.
Limitations & Best Practices
- The deep clone via
JSONdoes not preserve functions, dates, or circular references. Use a robust cloning library if needed. - Freezing objects is shallow; nested objects remain mutable unless also frozen. Consider a recursive freeze helper.
- Always pair
subscribewithunsubscribein component lifecycle hooks (e.g.,componentWillUnmountin React,onDestroyin Svelte). - For very large state trees, consider normalizing data to avoid unnecessary re‑renders.
- Document the expected state shape so that developers can write correct
setStatecalls and validation logic.
Summary
Implementing a lightweight Store with the Observer pattern gives you a single source of truth without the overhead of a full Redux setup. By enforcing immutability, validating updates, and providing clean subscription management, you keep UI components decoupled and maintainable. When state complexity or update frequency grows, be ready to pivot to a reducer or signal‑based system to preserve performance and scalability.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.