Concept
Redux's entire model rests on one idea: all of an app's state lives in a single plain JavaScript object, held by a single store. Not one store per feature, not one store per component tree, one store, one object, for the whole app.
import { createStore } from "redux";
function counterReducer(state = { value: 0 }, action) {
switch (action.type) {
case "counter/incremented":
return { value: state.value + 1 };
default:
return state;
}
}
const store = createStore(counterReducer);
store.subscribe(() => console.log("state changed:", store.getState()));
store.dispatch({ type: "counter/incremented" }); // logs: state changed: { value: 1 }Confirmed by running this exact code against the currently-installed redux package (v5.0.1): createStore, store.getState(), store.dispatch(), and store.subscribe() all work precisely as documented, none of this API has been removed, despite Redux Toolkit's configureStore (covered in configureStore) now being the officially recommended way to create a store. The underlying store object these functions produce is the same either way.
store.dispatch({ type: 'counter/incremented' });
dispatch() sends the action to the store's single root reducer, this is the ONLY way state changes in Redux; nothing else can mutate the store.
The store's four-method surface
A Redux store, no matter how it was created, exposes exactly this surface:
getState(), returns the current state object. Synchronous, always up to date.dispatch(action), the only way to trigger a state change. Takes a plain object (or, with middleware, other shapes, see Middleware).subscribe(listener), registers a callback that fires after every dispatched action that produces a state change, returning an unsubscribe function.replaceReducer(nextReducer), swaps the root reducer at runtime (used for code-splitting reducers; rarely needed directly in app code).
There is no fifth way to change state. No direct mutation, no setter methods, dispatch is the single funnel every state change passes through, which is precisely what makes Redux state changes traceable: every single one corresponds to exactly one dispatched action.
Single source of truth, in practice
const state = store.getState();
// { counter: { value: 1 }, user: { name: null }, cart: { items: [] } }In a real app the store's state object is a single tree holding every feature's data, counters, user info, cart contents, UI flags, all of it. Individual features don't get their own isolated stores; they get their own slice of the one store's state tree (mechanically enforced via combineReducers, shown in the visualizer above and covered fully in Actions & Reducers). This is a deliberate design choice: a single object means the entire app's state can be inspected, logged, serialized, or time-traveled through as one coherent snapshot, which is exactly what tools like Redux DevTools rely on.
Contrast with a library-managed external store
Zustand also keeps state outside React in a single external object, but Zustand's set() can be called directly from anywhere with a partial state update. Redux deliberately removes that direct-set path: the only entry point is dispatch(action), and the reducer is the only code allowed to decide what the new state looks like. The extra ceremony (defining action types, writing a reducer function) buys traceability, every state change is a named, inspectable event, not an arbitrary function call.
Try It
Predict the outcome before checking the solution.
import { createStore } from "redux";
function reducer(state = { count: 0 }, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
default:
return state;
}
}
const store = createStore(reducer);
let renderCount = 0;
store.subscribe(() => { renderCount++; });
store.dispatch({ type: "increment" });
store.dispatch
What do renderCount and store.getState() log?
Solution
3 { count: 2 }.
subscribe's listener fires after every dispatched action, including "unknown/action", even though the reducer's default case returns the exact same state reference unchanged. Redux doesn't skip notifying subscribers just because a particular action didn't produce a different state; it notifies on every dispatch, full stop. (Whether a connected component actually re-renders from that notification is a separate, later concern, covered in Selectors & Reselect, but the raw store-level subscribe callback fires three times here regardless.) The state itself only genuinely changes on the two "increment" dispatches, landing at { count: 2 }.
Implement It Yourself
Build a minimal version of createStore, to see the actual mechanism Redux wraps:
function createMiniStore(reducer, preloadedState) {
let state = preloadedState !== undefined
? preloadedState
: reducer(undefined, { type: "@@INIT" }); // reducer's default param supplies initial state
const listeners = new Set();
return {
getState() {
return state;
},
dispatch(action) {
state = reducer(state, action); // the ONLY place state is ever reassigned
listeners.forEach((listener) => listener());
return
Two things this mini version makes visible: first, calling the reducer once with { type: "@@INIT" } and no state is how the reducer's default parameter (state = { value: 0 }) becomes the store's actual initial state, a real reducer's default-state pattern isn't just a convenience, it's load-bearing. Second, dispatch is the only line that ever reassigns the closed-over state variable, everything else only reads it.
Under the Hood
The single-funnel dispatch pattern here is the mechanical foundation that Actions & Reducers builds on directly, that topic covers how combineReducers splits this one store's state tree into independently-testable slice reducers, each responding to the same dispatched actions. It also sets up the contrast with Zustand's direct-set() model and Selectors & Reselect's later point that a subscribe firing doesn't necessarily mean a component re-renders, those are two different layers.
Common Mistakes
1. Mutating state directly inside the reducer
function reducer(state = { items: [] }, action) {
if (action.type === "add") {
state.items.push(action.payload); // ❌ mutates the existing array in place
return state;
}
return state;
}Redux's change detection (and React-Redux's re-render decisions, and Redux DevTools' time-travel) all rely on comparing state references between dispatches. Mutating the existing object/array and returning the same reference means nothing downstream can tell a change happened at all.
2. Expecting subscribe to tell you what changed
store.subscribe(() => {
// ❌ no argument here, subscribe's listener receives NOTHING about what changed
console.log("something changed, but what?");
});The listener takes zero arguments by design, call store.getState() inside it (and typically diff it yourself against the last known value, exactly what useSelector does internally) to find out what actually changed.
3. Creating more than one store for "separate" state
const counterStore = createStore(counterReducer); // ❌
const userStore = createStore(userReducer); // ❌This defeats the single-source-of-truth model, no unified snapshot, no single DevTools timeline, no way to write a selector that reads across both. The correct pattern is one store, with combineReducers splitting the state tree, not the store itself (see Actions & Reducers).
Best Practices
- Treat
dispatchas the only legal entry point to state changes, no component or module should reach into the store's internals or attempt to bypass it. - Always return new references from reducers, never mutate
statein place, even when using plain (non-Toolkit) Redux, this is what every downstream consumer's change-detection depends on. - Keep the store itself a singleton per app, one store, with slices of its state tree per feature, not multiple independent stores.
Performance Tips
subscribelisteners fire on every dispatch, regardless of whether the specific slice a given listener cares about changed, this is why rawstore.subscribeis rarely used directly in app code;useSelector(fromreact-redux) wraps it with its own per-selector comparison so components don't re-render on unrelated slice changes (see Selectors & Reselect).- Because dispatch always calls the entire root reducer (which
combineReducersfans out to every slice reducer), keep individual reducer functions cheap, they run on every single dispatched action in the app, not just ones relevant to their own slice.
