Skip to content

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:

Framework Dependency Symptoms
- 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 it

The 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:

Closure-based state manager
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.

Using the closure store
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-based state manager
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.

Multiple class instances
const userStore = new StateManager({ name: 'Guest' });
const cartStore = new StateManager({ items: [] });
// Independent state - no shared memory
userStore.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.

Singleton state store
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:

Singleton guarantees single instance
const store1 = AppState.getInstance();
const store2 = AppState.getInstance();
console.log(store1 === store2); // true
store1.setState({ user: 'Alice' });
console.log(store2.getState()); // { user: 'Alice' } - shared

Pattern 4: Proxy for Reactive Updates

The Proxy API intercepts property changes automatically. No manual notification calls needed.

Proxy-based reactive state
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:

Automatic change detection
const state = reactiveState(
{ count: 0 },
(key, newVal, oldVal) => {
console.log(`${key}: ${oldVal} -> ${newVal}`);
}
);
state.count = 5; // Logs: count: 0 -> 5
state.count = 5; // No log - value didn't change

This 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.

CustomEvent-based state
const stateBus = new EventTarget();
let state = { count: 0 };
function setState(newState) {
state = { ...state, ...newState };
stateBus.dispatchEvent(new CustomEvent('stateChange', {
detail: state
}));
}
// Component A listens
stateBus.addEventListener('stateChange', e => {
console.log('Received:', e.detail);
});
// Component B emits
setState({ 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

Mistake: Direct mutation
// WRONG - breaks observer notifications
state.counter = 5;
// CORRECT - triggers observers
setState({ counter: 5 });

Exposing State Reference

Mistake: Exposing state reference
// WRONG - consumers can mutate directly
getState() {
return this.state;
}
// CORRECT - return immutable copy
getState() {
return { ...this.state };
}

Missing Unsubscribe

Mistake: No cleanup function
// WRONG - memory leaks
subscribe(fn) {
listeners.push(fn);
}
// CORRECT - return cleanup
subscribe(fn) {
listeners.push(fn);
return () => listeners = listeners.filter(l => l !== fn);
}

Overengineering Simple Apps

Mistake: Redux-style for trivial needs
// WRONG - 50+ lines for a counter
const INCREMENT = 'INCREMENT';
const reducer = (state, action) => { ... };
const store = createStore(reducer, middleware, ...);
// CORRECT - closure for simple cases
function createCounter() {
let count = 0;
return {
increment: () => ++count,
get: () => count
};
}

Why This Matters

Understanding vanilla patterns helps in four ways:

Benefits of understanding fundamentals
1. Debugging: When Redux breaks, you know it's a closure issue
2. Portability: Can work in legacy systems without modern frameworks
3. Interviews: Senior developers test this knowledge
4. 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:

Vanilla state patterns
Pattern | Use Case | Key Mechanism
-------------|-------------------------------|------------------
Closure | Private state encapsulation | Lexical scoping
Class | Multiple state instances | OOP structure
Singleton | Global shared state | Single instance
Proxy | Automatic reactivity | Property interception
CustomEvent | Cross-component communication | Event bus

Now 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments