Simple Patterns for Managing State in Vanilla JavaScript
Not every project needs Redux. Not every project needs React. Sometimes you're building something small, or you're working with vanilla JS, or you just want to understand how state management actually works before reaching for a library.
State management boils down to two questions:
- Where does the data live?
- How do I know when it changes?
Let me show you three patterns that answer these questions without any dependencies.
The Simple Store (Module Pattern)
The most basic approach: a module that holds state and exposes methods to read and update it.
// store.js
const Store = (function() {
// Private state - can't be accessed directly
let state = {
user: null,
items: [],
theme: 'light'
};
return {
getUser: () => state.user,
setUser: (user) => {
state.user = user;
console.log('User updated');
},
getItems: () => [...state.items], // Return copy to prevent mutation
addItem: (item) => {
state.items.push(item);
console.log('Item added');
},
getTheme: () => state.theme,
toggleTheme: () => {
state.theme = state.theme === 'light' ? 'dark' : 'light';
console.log('Theme:', state.theme);
}
};
})();
// Usage
Store.setUser({ name: 'John' });
Store.addItem({ id: 1, name: 'Widget' });
console.log(Store.getItems());
Pros: Dead simple. State is protected. Easy to debug.
Cons: No automatic UI updates. You have to manually re-render when state changes.
Pub/Sub (Observer Pattern)
The problem with the simple store: after calling setUser(), how does the navbar know to re-render? You could call navbar.render() inside setUser(), but then your store is coupled to your UI.
Pub/Sub decouples them. Components subscribe to changes. The store publishes when something changes.
// pubsub.js
const PubSub = {
events: {},
subscribe(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
// Return unsubscribe function
return () => {
this.events[event] = this.events[event].filter(cb => cb !== callback);
};
},
publish(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(callback => callback(data));
}
};
// Store with notifications
const Store = {
state: {
user: null,
cartCount: 0
},
setUser(user) {
this.state.user = user;
PubSub.publish('user:changed', user);
},
incrementCart() {
this.state.cartCount++;
PubSub.publish('cart:changed', this.state.cartCount);
}
};
// UI subscribes to changes
PubSub.subscribe('user:changed', (user) => {
document.getElementById('username').textContent = user?.name || 'Guest';
});
PubSub.subscribe('cart:changed', (count) => {
document.getElementById('cart-badge').textContent = count;
});
// Later...
Store.setUser({ name: 'John' }); // UI updates automatically
Store.incrementCart(); // Badge updates automatically
This is essentially what Redux does under the hood, minus the reducers and time-travel debugging.
Reactive State with Proxies
ES6 Proxies let you intercept property access and changes. This enables "reactive" state—when you change a value, something happens automatically.
function createReactiveStore(initialState, onChange) {
return new Proxy(initialState, {
set(target, property, value) {
const oldValue = target[property];
target[property] = value;
// Notify on change
if (oldValue !== value) {
onChange(property, value, oldValue);
}
return true;
}
});
}
// Usage
const state = createReactiveStore(
{ count: 0, user: null },
(prop, newVal, oldVal) => {
console.log(`${prop} changed: ${oldVal} → ${newVal}`);
render(); // Re-render UI
}
);
// Now changes are automatically detected
state.count = 1; // Logs: "count changed: 0 → 1"
state.count = 2; // Logs: "count changed: 1 → 2"
state.user = { name: 'John' }; // Logs: "user changed: null → [object Object]"
This is how Vue's reactivity system works (simplified). You change data normally, and the system detects it.
Caveat: Proxies only detect direct property changes. If you do state.user.name = 'Jane', the proxy on state won't catch it. You'd need nested proxies for deep reactivity.
Practical Example: Shopping Cart
Let's combine these patterns into something real:
// Simple reactive store with event emission
function createStore(initialState) {
const listeners = new Set();
const state = new Proxy(initialState, {
set(target, prop, value) {
target[prop] = value;
listeners.forEach(fn => fn(target));
return true;
}
});
return {
state,
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
};
}
// Cart store
const cart = createStore({
items: [],
total: 0
});
// Actions
function addToCart(product) {
cart.state.items = [...cart.state.items, product];
cart.state.total = cart.state.items.reduce((sum, p) => sum + p.price, 0);
}
function removeFromCart(productId) {
cart.state.items = cart.state.items.filter(p => p.id !== productId);
cart.state.total = cart.state.items.reduce((sum, p) => sum + p.price, 0);
}
// UI bindings
cart.subscribe((state) => {
document.getElementById('cart-count').textContent = state.items.length;
document.getElementById('cart-total').textContent = `$${state.total.toFixed(2)}`;
});
// Usage
addToCart({ id: 1, name: 'Shirt', price: 29.99 });
addToCart({ id: 2, name: 'Pants', price: 49.99 });
// UI updates automatically!
When to Use What
Simple Store: Small scripts, internal tools, prototypes. When you're okay with manual UI updates.
Pub/Sub: Medium complexity apps where multiple unrelated components need to react to changes. Good separation of concerns.
Reactive Proxy: When you want Vue-like reactivity without Vue. Nice DX, but watch out for deep object changes.
Use a library: When your state gets complex (async, middleware, devtools). Redux, Zustand, or your framework's built-in solution will save time.
The point isn't to avoid libraries forever—it's to understand what they're doing. Once you've built a mini state manager, tools like Redux make a lot more sense.