A practical guide to managing application state in modern frontend applications — comparing local state, context, Redux, Zustand, signals, and server-state solutions.
When an frontend application grows beyond a handful of components, the way state is created, stored, and moved through the UI becomes the primary determinant of developer velocity and runtime stability. This page catalogs the patterns you’ll reach for again and again, the concrete tools that make them possible, and the failure modes that catch teams off guard. It matters whenever more than one component needs the same data, whenever server data must stay in sync with the UI, or whenever local form state must be persisted or serialized. The patterns here are framework-agnostic in their mechanics; you can map them to React, Vue, Svelte, or vanilla JS with minor syntactic adjustments.
Every frontend project needs a place to put state that lives for the duration of the user’s session. The most common starting point is the component-local useState (or equivalent), but when multiple components need the same slice of state, a shared store is required. Real tooling gets you there.
npm init -y
npm install --save-dev typescript @types/node
npx tsc --init
A minimal hand-rolled store that works in any environment looks like this:
// store.js
class Store {
constructor(initial) {
this._state = initial;
this._subscribers = new Set();
}
getState() {
return this._state;
}
setState(partial) {
this._state = { ...this._state, ...partial };
this._subscribers.forEach(cb => cb(this._state));
}
subscribe(listener) {
this._subscribers.add(listener);
return () => this._subscribers.delete(listener);
}
}
export const createStore = (initial) => new Store(initial);
Attach it to your app once, at the root:
// index.js
import { createStore } from './store.js';
import { App } from './App.js';
const store = createStore({ count: 0, user: null });
const root = document.getElementById('root');
root._store = store; // imperative root-store attachment
function App() {
// components read store.getState() and call store.setState(...)
// rendering logic subscribes to store changes
return /* JSX */;
}
App();
Sharp edge: If you forget to subscribe before the first render, the UI never updates when state changes. Subscribe inside useEffect or your framework’s equivalent initialization hook, not inline during render.
The most common failure in frontend state management is treating server data as “just another piece of state.” Server data has a lifecycle: fetch, cache, refetch on staleness, optimistic update, rollback on error. Patterns that treat it like local state silently misbehave when networks lag or fail.
The pattern that survives real-world networks is: fetch into a normalized cache, then read from the cache for rendering. Never fetch directly into component state.
Real configuration that supports this pattern appears in build-tool or runtime configs. For Vite-based projects:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080'
}
}
});
And in the application code, the pattern that actually works:
// useServerData.js – framework-agnostic pattern
function useServerData(url) {
const [status, setStatus] = /* framework state hook */('idle');
const [cache, setCache] = /* framework state hook */(null);
async function refresh() {
setStatus('loading');
try {
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) throw new Error(response.statusText);
const data = await response.json();
setCache(data);
setStatus('success');
} catch (err) {
setStatus('error');
// exact error text you’ll see in devtools:
// FetchError: request failed with status code 500
console.error(err);
}
}
// kick off once on mount, optionally on dependency change
/* useEffect(() => { refresh(); }, []); */
return { status, cache, refresh };
}
Sharp edge: Rapid user navigation between pages that share the same URL can trigger multiple concurrent fetch calls. Without idempotency keys or request coalescing, you’ll see duplicate network traffic and the last response wins in the cache, potentially overwriting fresher data. A real production fix is a lightweight request queue that deduplicates in-flight calls by URL.
Not all state needs a backend. Form inputs, toggle switches, modal-open flags, and validation errors live and die within the component tree. The pattern that scales is controlled components when the number of inputs is small, and uncontrolled components with refs when the form is large or complex.
Real code for a controlled text input:
// In a React-like framework
function NameInput({ onSave }) {
const [value, setValue] = useState('');
const handleChange = (e) => {
setValue(e.target.value);
};
const handleSubmit = (e) => {
e.preventDefault();
onSave(value);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input value={value} onChange={handleChange} />
</label>
<button type="submit">Save</button>
</form>
);
}
Sharp edge: If you detach the input’s value prop after the component renders, the browser reverts to the DOM’s current value, which may differ from the React state. This causes “input flicker” where typing appears to have no effect. Always keep the value prop and onChange handler in sync; never conditionally omit value.
For uncontrolled patterns with a ref:
function FileSizeInput() {
const inputRef = useRef(null);
const getSize = () => {
// exact error text if the ref is null:
// Cannot read properties of null (reading 'current')
if (!inputRef.current) return 0;
return inputRef.current.files[0]?.size || 0;
};
return (
<>
<input ref={inputRef} type="file" />
<p>Selected file size: {getSize()} bytes</p>
</>
);
}
Sharp edge: Ref values are not reactive. If you read inputRef.current.files outside an event handler—say, on a timer or in an effect missing the right dependencies—you’ll get stale or empty results. Always pair ref reads with user-initiated events.
State that is computed from other state should never be stored redundantly. Derived state that lives alongside its sources invites divergence: one gets updated, the other doesn’t, and the UI shows inconsistent data. The pattern is compute-when-read, not store-and-sync.
Real pattern using a getter:
// store.js (revised)
class Store {
constructor(initial) {
this._state = initial;
this._subscribers = new Set();
}
getState() {
return this._state;
}
setState(partial) {
this._state = { ...this._state, ...partial };
this._subscribers.forEach(cb => cb(this._state));
}
subscribe(listener) {
this._subscribers.add(listener);
return () => this._subscribers.delete(listener);
}
// derived accessor – computed every time getState() is called
get doubleCount() {
return this._state.count * 2;
}
}
Usage:
const store = createStore({ count: 5 });
// In a component render:
const display = `${store.doubleCount} is double the count`;
Sharp edge: Subscribers receive the raw state object, not the derived value. If a component subscribes and only reads store.doubleCount, it will re-render on *every* state change, even ones that don’t affect the derived value. Wrap the derived accessor in a selector function that compares old and new values, or have the subscriber manually check whether the derived value actually changed.
When a user refreshes the page or closes and reopens the app, any in-memory state is lost. Persisting state to localStorage or sessionStorage is the standard solution, but the hydration step—rehydrating persisted state on app start—is where bugs hide.
Real pattern for loading persisted state on mount:
// utils.js
export const loadState = (key) => {
try {
const serialized = localStorage.getItem(key);
if (serialized === null) return undefined;
return JSON.parse(serialized);
} catch (err) {
// exact error text you’ll see in production:
// SyntaxError: Unexpected token o in JSON at position 1
// (usually means localStorage was cleared mid-read or corrupted)
console.warn('Could not load state', err);
return undefined;
}
};
export const saveState = (key, state) => {
try {
localStorage.setItem(key, JSON.stringify(state));
} catch (err) {
// exact error text:
// QuotaExceededError: The quota for session storage was exceeded
console.warn('Could not save state', err);
}
};
Application start:
// index.js
const persisted = loadState('appState');
const store = createState(persisted ?? initialState);
// later, before unload or on explicit save
window.addEventListener('beforeunload', () => {
saveState('appState', store.getState());
});
Sharp edge: Hydration mismatch between server-rendered HTML and client-rehydrated state is a real production failure. If the server renders HTML based on initial state, and the client immediately overwrites that state with localStorage data that differs, React (or your framework) will throw: "Hydration failed because the server rendered HTML doesn't match the client." The fix is to ensure the server and client start from the same initial state—either by serializing the same persisted state on the server, or by discarding persisted state on the client during the first render and letting it bootstrap from the server response.
When state is spread across a store, local components, and localStorage, finding where a value changed is nontrivial. Real developer tools help, but the underlying pattern is what matters.
Real pattern for time-travel debugging without a vendor extension:
// debug-wrapper.js
const originalSetState = Store.prototype.setState;
Store.prototype.setState = function(partial) {
const previous = this.getState();
originalSetState.call(this, partial);
const next = this.getState();
// exact console output you can grep:
// [STORE] setState: { count: 3 } -> { count: 4 }
console.log(`[STORE] setState: ${JSON.stringify(previous)} -> ${JSON.stringify(next)}`);
};
Drop this file into your build and it logs every state mutation with before/after snapshots. Pair with a browser search for [STORE] to trace the flow.
Sharp edge: If your store’s getState() returns a new object reference on every call (common with return { ...this._state } patterns), every render subscribes as a “change,” creating a performance nightmare. Normalize the shape: store state as a single plain object, and only create new references when setState is explicitly called.
Moving state from one store to another, or extracting a global store from a component tree, is a routine maintenance task that frequently introduces subtle bugs.
Real migration pattern: extract-and-adapt, don’t replace. Write the new store alongside the old one, pipe reads through both until the team is confident, then sunset the old one.
-// old-store.js
-class OldStore {
- constructor() {
- this._state = { user: loadState('user') };
- }
- setState(partial) {
- this._state = { ...this._state, ...partial };
- // no subscribers notified – this is the bug
- }
-}
+// new-store.js
+class NewStore {
+ constructor(initial) {
+ this._state = initial;
+ this._subscribers = new Set();
+ }
+ // ... full implementation with subscribers
+}
+
+// migration.js – pipe reads through both
+export const combinedStore = {
+ getState() {
+ return { ...oldStore.getState(), ...newStore.getState() };
+ },
+ setState(partial) {
+ oldStore.setState(partial);
+ newStore.setState(partial);
+ },
+ subscribe(cb) {
+ return newStore.subscribe(cb);
+ }
+};
Sharp edge: If the old store notifies subscribers and the new one doesn’t (as in the diff above), any component that switched to the new store will stop updating. The exact failure mode is: the UI shows stale data after the refactor, and no console error points to the store. The fix is ensuring every store in the migration path implements the same subscription contract, or using a combined proxy like the one shown above during the transition window.
Frontend state management patterns resolve three recurring tensions: local vs. shared, server-synced vs. standalone, and derived vs. stored. The concrete tools—npm, localStorage, plain ES modules, and hand-written store classes—get you functional fast. The sharp edges—hydration mismatches, subscriber contract drift, race conditions on refetch, and value-reference surprises—are what separate a working demo from production resilience. Keep the patterns here in mind the next time you catch yourself writing the same useState boilerplate across three components, or persisting JSON to localStorage without a try/catch guard. The patterns are simple; the failures are where the real cost lives.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy