How to Manage State in Vanilla JavaScript Without React or Third-Party Libraries
A senior developer with 15 years experience asked me: “How would you manage state across components without React?”
I stared at him. I’d been using React’s useState, Redux, and Context API for years. I knew the patterns inside those frameworks. But when asked to strip away the abstractions, I had nothing.
The interviewer later demonstrated his own approach using CustomEvent and Web Components. I walked away realizing I’d become a framework-dependent developer - someone who understands the API but not the fundamentals underneath.
The Problem: Framework Dependency
This question caught me off guard because:
- I learned state management through React, not through JavaScript- I knew useState syntax but didn't know closures create the same encapsulation- I understood Redux's store but didn't realize it's just a singleton pattern- I grasped React's reactivity but couldn't explain how Proxy enables itThe interviewer wasn’t being mean. He was testing whether I understood JavaScript fundamentals or just framework APIs. This question separates candidates who can debug complex issues from those who only know how to follow documentation.
A Reddit thread confirmed I wasn’t alone. Many developers face this gap. The community answers pointed to four vanilla patterns: closures, classes, singleton, and event-driven systems.
Pattern 1: Closures for Private State
Closures are JavaScript’s native privacy mechanism. A function “remembers” its outer variables even after that outer function finishes.
I tried building a simple counter:
function createStore(initialState) { let state = initialState; // Private - no direct access const listeners = [];
return { getState() { return state; }, setState(newState) { state = { ...state, ...newState }; listeners.forEach(fn => fn(state)); }, subscribe(listener) { listeners.push(listener); return () => { listeners.splice(listeners.indexOf(listener), 1); }; } };}This is essentially Redux’s store in ~15 lines. The state variable is private because nothing outside the function can access it directly. Only the returned methods can read or modify it.
const counter = createStore({ count: 0 });counter.subscribe(s => console.log('Changed:', s));counter.setState({ count: 5 }); // Logs: Changed: { count: 5 }Pattern 2: Classes as State Managers
Classes provide structure for state management. They bundle state and methods together.
class StateManager { constructor(initialState = {}) { this.state = initialState; this.observers = []; }
subscribe(observer) { this.observers.push(observer); return () => { this.observers = this.observers.filter(o => o !== observer); }; }
setState(newState) { this.state = { ...this.state, ...newState }; this.notifyObservers(); }
getState() { return { ...this.state }; // Return copy, not reference }
notifyObservers() { this.observers.forEach(o => o(this.getState())); }}The key difference from closures: classes can be instantiated multiple times. Each instance has its own state.
const userStore = new StateManager({ name: 'Guest' });const cartStore = new StateManager({ items: [] });
// Independent state - no shared memoryuserStore.setState({ name: 'John' });console.log(cartStore.getState()); // Still { items: [] }Pattern 3: Singleton for Global State
When I need one global store (Redux’s “single source of truth”), the singleton pattern works.
const AppState = (function() { let instance; let state = {}; let listeners = [];
function createInstance() { return { getState: () => ({ ...state }), setState: (newState) => { state = { ...state, ...newState }; listeners.forEach(fn => fn(state)); }, subscribe: (listener) => { listeners.push(listener); return () => listeners = listeners.filter(l => l !== listener); } }; }
return { getInstance: () => { if (!instance) instance = createInstance(); return instance; } };})();Every call to getInstance() returns the same object:
const store1 = AppState.getInstance();const store2 = AppState.getInstance();console.log(store1 === store2); // true
store1.setState({ user: 'Alice' });console.log(store2.getState()); // { user: 'Alice' } - sharedPattern 4: Proxy for Reactive Updates
The Proxy API intercepts property changes automatically. No manual notification calls needed.
function reactiveState(target, onChange) { return new Proxy(target, { set(obj, prop, value) { const old = obj[prop]; obj[prop] = value; if (old !== value) onChange(prop, value, old); return true; } });}Now mutations trigger callbacks automatically:
const state = reactiveState( { count: 0 }, (key, newVal, oldVal) => { console.log(`${key}: ${oldVal} -> ${newVal}`); });
state.count = 5; // Logs: count: 0 -> 5state.count = 5; // No log - value didn't changeThis is what Vue 3’s reactivity system uses. React’s useState is less magical - it just returns a setter function that triggers re-render.
Pattern 5: Event-Driven with CustomEvent
The interviewer demonstrated this approach. Use browser-native events for cross-component communication.
const stateBus = new EventTarget();let state = { count: 0 };
function setState(newState) { state = { ...state, ...newState }; stateBus.dispatchEvent(new CustomEvent('stateChange', { detail: state }));}
// Component A listensstateBus.addEventListener('stateChange', e => { console.log('Received:', e.detail);});
// Component B emitssetState({ count: 10 }); // Logs: Received: { count: 10 }This decouples components. A doesn’t know B exists. Both just know the event bus.
Common Mistakes I Made
Direct State Mutation
// WRONG - breaks observer notificationsstate.counter = 5;
// CORRECT - triggers observerssetState({ counter: 5 });Exposing State Reference
// WRONG - consumers can mutate directlygetState() { return this.state;}
// CORRECT - return immutable copygetState() { return { ...this.state };}Missing Unsubscribe
// WRONG - memory leakssubscribe(fn) { listeners.push(fn);}
// CORRECT - return cleanupsubscribe(fn) { listeners.push(fn); return () => listeners = listeners.filter(l => l !== fn);}Overengineering Simple Apps
// WRONG - 50+ lines for a counterconst INCREMENT = 'INCREMENT';const reducer = (state, action) => { ... };const store = createStore(reducer, middleware, ...);
// CORRECT - closure for simple casesfunction createCounter() { let count = 0; return { increment: () => ++count, get: () => count };}Why This Matters
Understanding vanilla patterns helps in four ways:
1. Debugging: When Redux breaks, you know it's a closure issue2. Portability: Can work in legacy systems without modern frameworks3. Interviews: Senior developers test this knowledge4. Teaching: Can explain "why" not just "how"A custom vanilla state manager can be 5KB. Redux is 500KB+. For performance-critical apps or simple projects, vanilla patterns win.
Summary
The interviewer’s question forced me to confront my framework dependency. React’s useState is closures. Redux’s store is singleton. Vue’s reactivity is Proxy. These aren’t framework inventions - they’re JavaScript patterns wrapped in convenient APIs.
I learned five vanilla patterns:
Pattern | Use Case | Key Mechanism-------------|-------------------------------|------------------Closure | Private state encapsulation | Lexical scopingClass | Multiple state instances | OOP structureSingleton | Global shared state | Single instanceProxy | Automatic reactivity | Property interceptionCustomEvent | Cross-component communication | Event busNow when someone asks how to manage state without React, I have an answer. The abstractions I relied on are built on these fundamentals. Understanding them makes me a developer, not just an API user.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Reddit: Bombed the final question of a React technical discussion
- 👨💻 MDN Web Docs: JavaScript Proxy API
- 👨💻 MDN Web Docs: JavaScript Closures
- 👨💻 JavaScript.info: Observable Pattern with Proxy
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments