Every frontend application, from a simple to-do list to a real-time dashboard serving thousands of users, runs on state. State is what makes your UI reactive — the data that changes over time and causes the screen to update. But as applications grow, managing that state becomes the central challenge of frontend architecture.
This post walks through state management from the ground up. We start with the fundamental question of what state is, then examine each major approach — Redux, Zustand, signals, Context API, useState — with code examples, interactive demos, and honest trade-off analysis. By the end, you will know not just how each approach works, but when to reach for each one.
At its simplest, state is data that changes. A counter that increments. A form input that holds the user’s typed text. A list of items fetched from an API. An authentication token that expires.
State has three defining properties:
In React, this relationship is expressed as:
UI = f(state)
The component is a function of state. When state changes, the function re-executes, producing a new view. Every state management library is, at bottom, a mechanism for managing when and how that function re-executes.
State comes in two categories: local and global. The choice between them shapes the entire architecture of an application.
Local state belongs to a single component. A checkbox’s checked state. An accordion panel’s open/closed state. The current value of a text input. It lives inside the component and does not affect anything outside it.
function Toggle() {
const [on, setOn] = useState(false)
return <button onClick={() => setOn(!on)}>{on ? 'ON' : 'OFF'}</button>
}
Global state, by contrast, is shared across multiple components that may be far apart in the component tree. A user’s authentication status. The currently selected item in a sidebar. A cached list of products fetched from the server.
The natural home for local state is useState or useReducer. These hooks are built into React, require no libraries, and handle the vast majority of state needs in any application. The trouble starts when state needs to be shared.
When two components need access to the same piece of state, the naive solution is to lift the state up to their closest common ancestor and pass it down via props. This works for one or two levels. But when the state needs to reach a component five levels deep, you end up threading props through intermediate components that do not use them.
function App() {
const [user, setUser] = useState(null)
return (
<Layout user={user}>
<Sidebar user={user}>
<Nav user={user}>
<Avatar user={user} />
</Nav>
</Sidebar>
</Layout>
)
}
Every intermediate component — Layout, Sidebar, Nav — must accept and forward the user prop even though none of them use it. This is prop drilling: passing data through layers of components that exist only as intermediaries.
Prop drilling causes several problems:
The solution is to give components direct access to shared state without going through the component hierarchy. This is what state management libraries provide.
Before Redux, there was Flux. Facebook introduced Flux in 2014 to solve a specific problem: in the Facebook chat application, a user’s unread message count was being displayed inconsistently across different parts of the UI. The root cause was that the application did not have a single source of truth for that count.
Flux introduced a unidirectional data flow with four parts:
type and optional payload.The flow is always circular: Action -> Dispatcher -> Store -> View -> Action. Data flows in one direction, which makes the system predictable and debuggable.
Redux simplified Flux by collapsing the dispatcher into the store and enforcing a single state tree. But the core idea — unidirectional flow with explicit actions — remains the same.
Redux was created by Dan Abramov and Andrew Clark in 2015. It distilled the Flux pattern into three principles:
Unidirectional data flow: Component dispatches an action, the reducer produces new state, the store notifies subscribers.
Dispatch an action, watch it flow through the reducer, and see the new state appear. The demo shows the full cycle: the component creates an action, store.dispatch() sends it to the reducer, the reducer returns a new state object, and the store notifies all subscribers.
The reducer is the heart of Redux. It is a pure function — given the same input (state + action), it always returns the same output. It does not mutate the existing state; it creates a new one.
function counterReducer(state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + action.payload }
case 'DECREMENT':
return { count: state.count - action.payload }
default:
return state
}
}
Because reducers are pure, Redux provides time-travel debugging: every dispatched action is logged, and developers can step forward and backward through the application’s entire state history. The Redux DevTools extension is the gold standard for debuggability.
Redux reducers must be pure — no async operations, no API calls, no side effects. But real applications need side effects. The solution is middleware, which intercepts actions before they reach the reducer.
Redux Thunk allows action creators to return functions instead of objects. The function receives dispatch and getState as arguments, enabling async logic:
const fetchUser = (id) => async (dispatch) => {
dispatch({ type: 'FETCH_USER_START' })
try {
const user = await api.getUser(id)
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user })
} catch (err) {
dispatch({ type: 'FETCH_USER_ERROR', payload: err })
}
}
Redux Saga uses ES6 generators for more complex async flows — debouncing, throttling, parallel execution, and race conditions:
function* watchFetchUser() {
yield takeLatest('FETCH_USER_REQUEST', function* (action) {
try {
const user = yield call(api.getUser, action.payload)
yield put({ type: 'FETCH_USER_SUCCESS', payload: user })
} catch (err) {
yield put({ type: 'FETCH_USER_ERROR', payload: err })
}
})
}
Middleware makes Redux powerful but also adds complexity. A typical Redux application has action types, action creators, reducers, store configuration, middleware setup, and the Provider wrapper — often split across four or more files for a single feature.
Zustand (German for “state”) was created to provide the predictability of Redux without the ceremony. The entire API fits in a single create() call.
import { create } from 'zustand'
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))
No action types. No action creators. No reducer switch statement. No Provider wrapper. The store is a hook, and components use it directly.
function Counter() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
return <button onClick={increment}>{count}</button>
}
A hooks-based store with direct mutation via set(). No reducers, no action types, no dispatch. State and actions live in one place.
import { create } from 'zustand'
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((st) => ({ count: st.count + 1 })),
decrement: () => set((st) => ({ count: st.count - 1 })),
reset: () => set({ count: 0 }),
}))function Counter() {
const { count, increment, decrement } = useCounterStore()
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
)
}The demo shows the same counter implemented in Redux and Zustand, with a side-by-side comparison of boilerplate. The Zustand version is roughly one-third the code.
Zustand’s set() uses the same immutable update pattern as React’s useState. You can pass a partial state object or an updater function. The library also supports middleware for persistence, Immer integration, and Redux DevTools — but these are opt-in rather than required.
The selector function in useCounterStore((state) => state.count) is critical for performance. It tells Zustand which slice of state the component cares about. When state changes, Zustand only re-renders components whose selected slice actually changed.
Zustand works with server components in React (since it does not require a Provider), and it is small enough (~2KB) that the bundle impact is negligible. For most applications that do not need the full Redux middleware ecosystem, Zustand is the pragmatic sweet spot.
Redux and Zustand both operate at the component level: when state changes, React re-renders the entire component function. The virtual DOM then figures out what changed and patches the real DOM.
Signals take a fundamentally different approach. Instead of re-rendering components, signals track dependencies at the granularity of individual DOM nodes. When a signal changes, only the specific text or attribute nodes that depend on it are updated.
import { signal, computed } from '@preact/signals-react'
const firstName = signal('Alice')
const lastName = signal('Smith')
const fullName = computed(() => {
return firstName.value + ' ' + lastName.value
})
function Greeting() {
return <h1>Hello, {fullName}</h1>
}
When lastName.value = 'Jones' is called, the computed() re-evaluates to produce “Alice Jones”. But here is the key: the Greeting component does NOT re-render. Only the text node inside the <h1> is patched directly in the DOM.
Signals provide granular reactivity: when a dependency changes, only the computed value re-evaluates, not the entire component. This is fundamentally different from Redux or Zustand (which trigger component-level re-renders).
import { signal, computed } from '@preact/signals-react'
const firstName = signal('Alice')
const lastName = signal('Smith')
// computed re-evaluates only when firstName or lastName changes
const fullName = computed(() => {
console.log('recomputing fullName')
return firstName.value + ' ' + lastName.value
})
// Component only subscribes to fullName
function Greeting() {
return <h1>Hello, {fullName}</h1>
}
// Changing lastName skips Greeting's component re-render
// only the text node updates in the DOM
lastName.value = 'Jones'The demo lets you change two signals independently and watch which values re-compute. Because fullName depends on both firstName and lastName, changing either one triggers a recomputation. But nothing else in the component re-evaluates.
This is the fundamental difference between signals and traditional React state:
The signal path skips component re-rendering and virtual DOM entirely. For performance-critical UIs — real-time dashboards, data visualization, animation-heavy interfaces — this can be dramatically faster.
Signals originated in SolidJS, which built its entire reactivity system around them. Preact adopted signals with @preact/signals. Vue’s reactive system is conceptually similar. Angular recently added signals to its core framework. The pattern is becoming a cross-framework standard.
The trade-off is that signals require a different mental model. Instead of thinking in terms of renders (“when this state changes, re-render”), you think in terms of subscriptions (“this computation depends on these signals”). The DevTools ecosystem is less mature, and there is no middleware equivalent.
To understand the practical difference between signals and React’s useState, consider a component that displays a full name and has independent fields for first and last name.
With useState, every change to either field re-renders the entire component:
function NameForm() {
const [first, setFirst] = useState('Alice')
const [last, setLast] = useState('Smith')
const full = first + ' ' + last // recomputed EVERY render
return (
<form>
<input value={first} onChange={e => setFirst(e.target.value)} />
<input value={last} onChange={e => setLast(e.target.value)} />
<p>Full name: {full}</p>
</form>
)
}
Every keystroke in the first name field triggers a re-render of the entire form, including the last name input and the paragraph. With React.memo on child components and useMemo on the full name computation, you can optimize this, but it requires manual effort.
With signals, only the affected nodes are patched:
const first = signal('Alice')
const last = signal('Smith')
const full = computed(() => first.value + ' ' + last.value)
function NameForm() {
return (
<form>
<input value={first.value} onInput={e => first.value = e.target.value} />
<input value={last.value} onInput={e => last.value = e.target.value} />
<p>Full name: {full.value}</p>
</form>
)
}
The component function runs once. After that, changing first.value patches just that input and the computed full name. The form, the last name input, and everything else remain untouched.
This fine-grained reactivity is why SolidJS benchmarks consistently outperform React in rendering speed. It is also why Angular moved to signals: they provide predictable, efficient updates without a virtual DOM.
As state grows beyond a few fields, the shape of the state tree becomes a design decision. The wrong shape causes performance problems: deep nesting leads to expensive immutable updates, and duplicate data leads to synchronization bugs.
Normalization is the practice of flattening nested data into a relational structure, similar to a database schema. Instead of:
{
posts: [
{
id: 1,
title: "...",
author: { id: 1, name: "Alice" },
comments: [
{ id: 1, text: "...", author: { id: 2, name: "Bob" } }
]
}
]
}
You normalize to:
{
posts: { byId: { 1: { id: 1, title: "...", authorId: 1, commentIds: [1] } } },
users: { byId: { 1: { id: 1, name: "Alice" }, 2: { id: 2, name: "Bob" } } },
comments: { byId: { 1: { id: 1, text: "...", authorId: 2, postId: 1 } } }
}
Normalization has three benefits:
Selectors are functions that compute derived data from the normalized state. The Reselect library provides memoized selectors that only re-compute when their inputs change:
import { createSelector } from '@reduxjs/toolkit'
const selectPostById = createSelector(
[state => state.posts.byId, (_, postId) => postId],
(postsById, postId) => postsById[postId]
)
const selectPostWithAuthor = createSelector(
[selectPostById, state => state.users.byId],
(post, usersById) => ({
...post,
author: usersById[post.authorId]
})
)
The second selector only re-runs when the specific post or the users lookup changes. This is essential for Redux performance: without memoized selectors, every state change would re-derive all computed data.
Zustand achieves the same result through selector functions on the store hook:
const authorName = useStore(state => state.users.byId[post.authorId]?.name)
And signals handle derived data naturally through computed(), which is memoized by default.
Each approach has different testing ergonomics.
Redux is the most testable because reducers are pure functions. Testing a reducer requires only calling it with known inputs and asserting the output:
import counterReducer from './counterReducer'
test('increment', () => {
const next = counterReducer({ count: 0 }, { type: 'INCREMENT', payload: 1 })
expect(next).toEqual({ count: 1 })
})
Middleware requires mocking the store and dispatch, but the pattern is well established.
Zustand stores are functions, so you create a fresh store for each test:
import { create } from 'zustand'
function createTestStore() {
return create((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
}))
}
test('increment', () => {
const store = createTestStore()
store.getState().increment()
expect(store.getState().count).toBe(1)
})
Signals are plain objects. Testing a computed is straightforward:
test('full name', () => {
const first = signal('Alice')
const last = signal('Smith')
const full = computed(() => first.value + ' ' + last.value)
expect(full.value).toBe('Alice Smith')
first.value = 'Bob'
expect(full.value).toBe('Bob Smith')
})
The testing stories for all three are good. Redux benefits from the most mature patterns, but the difference is marginal for most applications.
There is no single best state management approach. Each solves a different set of problems and makes different trade-offs.
Interactive comparison across five approaches. Higher score is better in each category. Hover over cells for details.
| Approach | Boilerplate | Performance | DevTools | Middleware | Bundle | Learning |
|---|---|---|---|---|---|---|
| Redux | ||||||
| Zustand | ||||||
| Signals | ||||||
| Context API | ||||||
| useState |
The interactive comparison table ranks each approach across six dimensions. Use the scores, not as absolute judgments, but as a framework for thinking about what matters in your application.
Here is a simpler heuristic:
A pragmatic architecture often combines multiple approaches: useState for local form state, Zustand for shared data (user preferences, cached API responses), and signals selectively in performance-critical views.