You know that feeling when you're using React or Vue, and state just... works? You update something, the UI updates, and it feels like magic. But here's the thing: it's not magic. It's just patterns. And you can build those patterns yourself with plain JavaScript.
Today, let's break down what state really is, how to manage it without any framework, and when you actually need those fancy tools.
What Even Is State?
State is just data that changes over time. That's it. Really.
When you're building a web app, state is everything that can change: the value in an input field, whether a modal is open, items in a shopping cart, or which tab is currently active. If it changes, it's state.
// This is state
let count = 0;
let isModalOpen = false;
let user = { name: "Alex", loggedIn: true };
The tricky part isn't storing this data (that's easy). The tricky part is making sure your UI updates when the data changes, and doing it in a way that doesn't turn into spaghetti code.
The Event-Driven Way of Thinking
Here's where things get interesting. JavaScript in the browser is built around events. Click a button? Event. Type in a field? Event. Load a page? You guessed it, event.
This event-driven approach is actually perfect for state management. Instead of manually updating the DOM everywhere, you can set up a system where state changes trigger events, and your UI listens for those events.
// Simple event-driven state
const state = {
count: 0
};
function updateState(newCount) {
state.count = newCount;
// Trigger a custom event
document.dispatchEvent(new CustomEvent('stateChange', {
detail: { count: state.count }
}));
}
// Listen for state changes
document.addEventListener('stateChange', (e) => {
document.getElementById('counter').textContent = e.detail.count;
});
The beauty here is separation of concerns. Your state logic doesn't know anything about the DOM, and your UI code just reacts to changes.
Building Your Own State Manager
Let's build something more robust. A tiny state manager that you can actually use in real projects.
function createStore(initialState) {
let state = initialState;
const listeners = [];
return {
getState() {
return state;
},
setState(updater) {
// Support both direct values and updater functions
state = typeof updater === 'function'
? updater(state)
: { ...state, ...updater };
// Notify all listeners
listeners.forEach(listener => listener(state));
},
subscribe(listener) {
listeners.push(listener);
// Return unsubscribe function
return () => {
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
};
}
};
}
// Using it
const store = createStore({ count: 0, user: null });
// Subscribe to changes
const unsubscribe = store.subscribe(state => {
console.log('State changed:', state);
document.getElementById('count').textContent = state.count;
});
// Update state
store.setState(state => ({ ...state, count: state.count + 1 }));
This pattern is basically what Redux does under the hood, just without the action types and reducers. Sometimes you need that structure, but often you don't.
Using the DOM as State
Here's a controversial take: sometimes the DOM itself is perfectly fine as state storage.
If you have a checkbox that controls something, the checked state of that checkbox IS the state. You don't always need to duplicate it in a JavaScript object.
// DOM as state
function getFormState() {
return {
email: document.getElementById('email').value,
newsletter: document.getElementById('newsletter').checked,
theme: document.querySelector('input[name="theme"]:checked').value
};
}
// When you need it, just read from the DOM
document.getElementById('submit').addEventListener('click', () => {
const formData = getFormState();
console.log(formData);
});
This is actually how the web worked for years, and it still works fine for simpler interactions. The problem starts when you need that state in multiple places, or when the same state needs to drive multiple UI elements.
Using Proxies for Reactive State
Modern JavaScript has this cool feature called Proxies that lets you intercept operations on objects. You can use this to make state automatically reactive.
function reactive(target, callback) {
return new Proxy(target, {
set(obj, prop, value) {
obj[prop] = value;
callback(obj);
return true;
}
});
}
// Create reactive state
const state = reactive({ count: 0 }, (newState) => {
document.getElementById('count').textContent = newState.count;
});
// Now this automatically updates the UI
state.count = 5; // DOM updates automatically
state.count++; // DOM updates again
This is basically what Vue does with its reactivity system. It's elegant, but it has limitations (nested objects need special handling, arrays are tricky, etc.).
When Frameworks Actually Help
Okay, so if you can build all this yourself, why do frameworks exist?
They help when:
Your UI is complex and deeply nested - Manually updating a deeply nested component tree is painful. Frameworks handle this with virtual DOM diffing.
You need time-travel debugging - Tools like Redux DevTools let you replay state changes. Super useful for debugging complex apps.
State changes are frequent and granular - If you're updating state 60 times per second (like in a game), a framework can batch updates efficiently.
Team collaboration - Frameworks enforce patterns. When you hire a React dev, they know where to put things.
You're building a long-lived application - The structure and tooling pay off over time.
When They Don't
Vanilla JS is better when:
The app is simple - A todo list doesn't need Redux. Seriously.
Performance is critical - Netflix switched parts of their UI to vanilla JS because framework overhead was slowing things down.
Bundle size matters - Shipping 50KB of framework code to show a dropdown menu is overkill.
You're learning - Understanding vanilla state management makes you a better framework developer.
The project is unusual - Sometimes your use case doesn't fit the framework's assumptions.
A Practical Middle Ground
You don't have to choose between "no framework" and "full framework". There's a middle ground:
// Lightweight state + DOM updates
class Component {
constructor(element, initialState) {
this.element = element;
this.state = initialState;
this.render();
}
setState(updates) {
this.state = { ...this.state, ...updates };
this.render();
}
render() {
// Override this in subclasses
throw new Error('render() must be implemented');
}
}
// Use it
class Counter extends Component {
render() {
this.element.innerHTML = `
<div>
<p>Count: ${this.state.count}</p>
<button onclick="counter.setState({ count: ${this.state.count + 1} })">
Increment
</button>
</div>
`;
}
}
const counter = new Counter(
document.getElementById('app'),
{ count: 0 }
);
This gives you component-like structure without a framework. For many projects, this is enough.
The Real Secret
The real secret of state management isn't React, Vue, or any framework. It's understanding these core concepts:
- State is just data that changes
- Changes should be predictable (one way to update, clear flow)
- UI should react to state, not the other way around
- Separation of concerns makes everything easier
Once you get these concepts, you can pick the right tool for the job. Sometimes that tool is React. Sometimes it's 20 lines of vanilla JavaScript. The important thing is that you're making that choice consciously, not just following the hype.
Frameworks are great. They solve real problems. But they're not magic, and you don't always need them. Understanding what's under the hood makes you better at using them when you do.
