State management in plain JavaScript – how to hold application data, notify the UI when it changes, and avoid turning every function into a spaghetti of global variables. No framework required, though the patterns here explain what frameworks are doing under the hood.
What counts as state?
State is anything that can change and affects what the user sees: cart items, form values, which panel is open, data loaded from an API. Not state: pure helpers, constants, DOM nodes you keep references to for performance.
Rule of thumb: one source of truth per concern. If the same fact lives in a variable, a data attribute, and hidden in the DOM text, you will eventually show the wrong thing.
A minimal store
A store holds data and lets subscribers react when it changes. This is the same idea as the cart store from the architecture tutorial, generalised.
export function createStore(initialState) {
let state = structuredClone(initialState);
const listeners = new Set();
return {
getState() {
return state;
},
setState(partial) {
state = { ...state, ...partial };
listeners.forEach((fn) => fn(state));
},
subscribe(fn) {
listeners.add(fn);
fn(state);
return () => listeners.delete(fn);
},
};
}
subscribe runs the listener immediately so the UI paints the current state on first connect. The unsubscribe function returned from subscribe prevents leaks when components are torn down.
Actions instead of scattered updates
Rather than calling setState from everywhere, group mutations into named actions. That documents intent and gives you one place to add validation or logging.
export function createTaskStore() {
const store = createStore({ tasks: [], filter: 'all' });
return {
...store,
addTask(text) {
const trimmed = text.trim();
if (!trimmed) return;
store.setState({
tasks: [
...store.getState().tasks,
{ id: crypto.randomUUID(), text: trimmed, done: false },
],
});
},
toggleTask(id) {
const tasks = store.getState().tasks.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
);
store.setState({ tasks });
},
setFilter(filter) {
store.setState({ filter });
},
};
}
Deriving data with selectors
Do not duplicate computed values in state unless you have a measured performance reason. Derive them when rendering.
function selectVisibleTasks(state) {
const { tasks, filter } = state;
if (filter === 'active') return tasks.filter((t) => !t.done);
if (filter === 'done') return tasks.filter((t) => t.done);
return tasks;
}
store.subscribe((state) => {
const visible = selectVisibleTasks(state);
renderTaskList(visible);
});
If derivation gets expensive (filtering thousands of rows on every keystroke), memoise the selector or move work off the main thread – covered later in these tutorials.
Local vs global state
Not everything belongs in a global store. A dropdown’s open/closed state can live in a closure or a small module if nothing else cares. Promote to global when two distant parts of the UI need the same data, or when you need to persist or sync it.
- Global store: user session, cart, theme, server-backed lists.
- Local state: hover, focus, animation phase, transient form drafts.
- URL state: filters and pagination that should survive refresh – use
history.pushStateor query params.
Immutability habits
Mutating nested objects in place breaks change detection and makes time-travel debugging impossible. Spread or copy at the level you change.
// avoid
state.tasks[0].done = true;
// prefer
state = {
...state,
tasks: state.tasks.map((t, i) =>
i === 0 ? { ...t, done: true } : t
),
};
When you might reach for a framework
Plain stores scale further than people expect. Consider a framework (or a dedicated state library) when you have deep component trees all subscribing to overlapping slices of state, or when you need devtools and strict conventions across a large team. Until then, a well-bounded store plus clear module boundaries is often enough.
The next tutorial introduces observables and reactive patterns – push-based updates when many listeners care about the same changing values.

