Your state tree is a JSON document
Whether you reach for Redux, Zustand, or React's own useReducer, the state you're managing is — by convention and often by requirement — a plain, JSON-serializable object: strings, numbers, booleans, null, plain objects and arrays. No class instances, no functions, no Map/Set (without extra handling), no circular references. Understanding state as "just JSON" explains why time-travel debugging, persistence, and server hydration all work the way they do.
Why JSON-serializability is a hard rule, not a suggestion
Redux DevTools' time-travel debugging works by serializing every state snapshot so it can replay them. Redux Toolkit even ships a middleware that throws a warning the moment you put a non-serializable value (a class instance, a Date, a Promise) into the store:
// Redux Toolkit warns loudly about this:
dispatch(setUser({ createdAt: new Date() })); // Date isn't JSON-safe
// Store the ISO string instead — exactly like an API response would
dispatch(setUser({ createdAt: new Date().toISOString() }));This isn't pedantry: a Date object silently breaks JSON.stringify-based persistence, snapshot testing, and server-side rendering hydration, all of which round-trip your state through actual JSON at some point.
Normalizing nested JSON into a flat shape
API responses arrive deeply nested — an order with embedded customer and line-item objects. Storing that shape directly in global state causes a specific, well-known pain: updating one nested field requires spreading every parent level, and the same customer object might be duplicated across ten different orders in memory.
The fix, borrowed directly from relational database design, is normalization — flatten nested JSON into ID-keyed lookup tables:
// Before: nested, denormalized (fine for an API response, bad for a store)
{
orders: [
{ id: "o1", customer: { id: "c1", name: "Ada" }, total: 42 },
{ id: "o2", customer: { id: "c1", name: "Ada" }, total: 9 }, // c1 duplicated
]
}
// After: normalized — one copy of each entity, referenced by id
{
orders: { byId: { o1: { id: "o1", customerId: "c1", total: 42 },
o2: { id: "o2", customerId: "c1", total: 9 } }, allIds: ["o1","o2"] },
customers: { byId: { c1: { id: "c1", name: "Ada" } }, allIds: ["c1"] },
}Now updating Ada's name touches exactly one object, and every order referencing c1 sees the update without duplicating data. Libraries like Redux Toolkit's createEntityAdapter generate this byId/allIds shape for you automatically from an API array.
Immutability: new JSON, not mutated JSON
Redux (and Zustand, when you opt into its immutable patterns) requires you to return a new object on every state change rather than mutating the existing one — because change detection compares object references, not deep JSON equality:
// Wrong — mutates in place, subscribers never re-render
state.user.name = "New Name";
// Right — new object, only the changed branch is a new reference
const newState = {
...state,
user: { ...state.user, name: "New Name" },
};Redux Toolkit's createSlice uses Immer under the hood so you can *write* mutation-looking code while it produces the correct new-object structure — worth knowing so the "immutability" rule doesn't feel like it's fighting the code you actually type.
Zustand: the same rules, less ceremony
Zustand stores state the same JSON-shaped way but skips the action/reducer boilerplate — you get and set a plain object directly:
import { create } from "zustand";
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
clear: () => set({ items: [] }),
}));The persistence and serialization concerns are identical to Redux — Zustand's own persist middleware, shown next, expects the same JSON-safe values.
Persisting state to localStorage
localStorage only stores strings, so persisting a store is always a JSON.stringify/JSON.parse round trip. Both ecosystems ship middleware that does this for you:
// Zustand — built-in persist middleware
import { persist } from "zustand/middleware";
const useSettings = create(
persist(
(set) => ({ theme: "dark", setTheme: (theme) => set({ theme }) }),
{ name: "app-settings" } // localStorage key; value is JSON.stringify'd automatically
)
);// Redux — redux-persist does the same for a whole store
import { persistStore, persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage"; // wraps localStorage
const persistedReducer = persistReducer({ key: "root", storage }, rootReducer);Both throw the same warning as the DevTools case if the state contains something JSON.stringify can't faithfully round-trip.
Hydration: server JSON meeting client state
In server-rendered apps (Next.js, Remix), initial state often arrives as JSON embedded in the HTML and is used to initialize the client store on first render — the same "just JSON" property that makes time-travel debugging work is what makes hydration possible: the server can serialize state, and the client can deserialize the *exact same shape* to pick up where the server left off.