JSON State Management: Normalized State, Optimistic Updates & Server State

Last updated:

Most React state management guides focus on Redux or Zustand in isolation — they miss the harder problem: how do you integrate JSON API responses into your state layer correctly? Returning a flat array from fetch() and storing it directly leads to duplicated entities, O(n) update costs, and stale-data bugs. This guide covers normalized state shapes from JSON APIs, the byId + allIds pattern and RTK's createEntityAdapter, optimistic updates with rollback, the server state vs client state boundary (TanStack Query vs Zustand), JSON Patch for incremental updates, localStorage and IndexedDB persistence, and TypeScript types derived from OpenAPI specs and Zod schemas.

JSON API Response Normalization

A typical JSON API returns arrays of objects. Storing them directly as arrays in state is tempting but creates two problems: O(n) lookup by ID (find the user with id "42" requires iterating the entire array) and O(n) updates (updating Alice's name requires patching every post that embeds her object). Normalization solves both by transforming the array into a map keyed by ID.

// ── Raw JSON API response (flat array) ───────────────────────────────
// GET /api/users
[
  { "id": "1", "name": "Alice", "role": "admin" },
  { "id": "2", "name": "Bob",   "role": "member" },
  { "id": "3", "name": "Carol", "role": "member" }
]

// ── Problem with flat array storage ───────────────────────────────────
// Lookup by ID: O(n) — must iterate entire array
const user = users.find(u => u.id === "2")   // scans all users

// Update by ID: O(n) — must map over entire array
const updated = users.map(u =>
  u.id === "2" ? { ...u, name: "Robert" } : u
)

// ── Normalized shape: byId + allIds ──────────────────────────────────
// allIds: preserves server-provided ordering
// byId:   O(1) lookup and O(1) update by ID
const normalizedUsers = {
  byId: {
    "1": { id: "1", name: "Alice", role: "admin" },
    "2": { id: "2", name: "Bob",   role: "member" },
    "3": { id: "3", name: "Carol", role: "member" },
  },
  allIds: ["1", "2", "3"],   // order preserved from API response
}

// Lookup by ID: O(1)
const user = normalizedUsers.byId["2"]

// Update by ID: O(1) — only touch the changed entity
const updated = {
  ...state,
  byId: {
    ...state.byId,
    "2": { ...state.byId["2"], name: "Robert" },
  },
}

// Ordered iteration: O(n) using allIds
const orderedUsers = normalizedUsers.allIds.map(
  id => normalizedUsers.byId[id]
)

// ── Manual normalization from an API array ────────────────────────────
function normalize<T extends { id: string }>(
  items: T[]
): { byId: Record<string, T>; allIds: string[] } {
  const byId: Record<string, T> = {}
  const allIds: string[] = []
  for (const item of items) {
    byId[item.id] = item
    allIds.push(item.id)
  }
  return { byId, allIds }
}

const response = await fetch('/api/users').then(r => r.json())
const { byId, allIds } = normalize(response)  // O(n) single pass

// ── RTK createEntityAdapter: normalized state out of the box ──────────
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'

const usersAdapter = createEntityAdapter<User>()
// Generates: { ids: string[], entities: Record<string, User> }
// Same shape as byId+allIds, using RTK terminology

const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState(),
  reducers: {
    usersReceived(state, action) {
      usersAdapter.setAll(state, action.payload)   // replace all
    },
    userUpdated: usersAdapter.updateOne,            // O(1) update
    userRemoved: usersAdapter.removeOne,            // O(1) remove
    usersAdded:  usersAdapter.addMany,              // batch insert
  },
})

// Selectors generated by adapter (memoized)
const {
  selectAll,    // returns User[] in ids order
  selectById,   // (state, id) => User | undefined
  selectIds,    // returns string[] of ids
} = usersAdapter.getSelectors((state: RootState) => state.users)

The byId + allIds pattern (or RTK's equivalent { ids, entities }) should be the default shape for any JSON API collection stored in React state. The one case where a flat array is appropriate: a list that is never updated in place and never looked up by ID (e.g., a read-only activity feed). For all other API collections, normalize at the point of ingestion — when the fetch response arrives, before it enters state.

Normalized State Shape from JSON APIs

Real applications have multiple entity types. A blog API returns posts with embedded author and tag data. A flat store duplicates authors across every post. A normalized store has separate slices for each entity type, with cross-references by ID instead of embedded objects.

// ── JSON API response with embedded relationships ─────────────────────
// GET /api/posts
[
  {
    "id": "p1",
    "title": "Intro to Normalization",
    "authorId": "u1",
    "author": { "id": "u1", "name": "Alice" },   // embedded — DUPLICATE RISK
    "tags": [
      { "id": "t1", "name": "React" },
      { "id": "t2", "name": "State" }
    ]
  },
  {
    "id": "p2",
    "title": "JSON Optimistic Updates",
    "authorId": "u1",
    "author": { "id": "u1", "name": "Alice" },   // same author, stored AGAIN
    "tags": [{ "id": "t1", "name": "React" }]    // same tag, stored AGAIN
  }
]

// ── Target normalized state shape ─────────────────────────────────────
// Each entity type gets its own slice; relationships stored as IDs only
interface AppState {
  users: {
    byId: Record<string, User>
    allIds: string[]
  }
  posts: {
    byId: Record<string, Post>       // Post.authorId: string (not User object)
    allIds: string[]
  }
  tags: {
    byId: Record<string, Tag>
    allIds: string[]
  }
}

// After normalization, the state looks like:
const state: AppState = {
  users: {
    byId: { "u1": { id: "u1", name: "Alice" } },  // stored ONCE
    allIds: ["u1"],
  },
  posts: {
    byId: {
      "p1": { id: "p1", title: "Intro to Normalization", authorId: "u1", tagIds: ["t1","t2"] },
      "p2": { id: "p2", title: "JSON Optimistic Updates", authorId: "u1", tagIds: ["t1"] },
    },
    allIds: ["p1", "p2"],
  },
  tags: {
    byId: {
      "t1": { id: "t1", name: "React" },   // stored ONCE even though used in 2 posts
      "t2": { id: "t2", name: "State" },
    },
    allIds: ["t1", "t2"],
  },
}

// ── Normalization function for nested API response ─────────────────────
interface NormalizedData {
  users: Record<string, User>
  posts: Record<string, Post>
  tags: Record<string, Tag>
  postIds: string[]
}

function normalizePostsResponse(apiPosts: ApiPost[]): NormalizedData {
  const users: Record<string, User> = {}
  const posts: Record<string, Post> = {}
  const tags: Record<string, Tag> = {}
  const postIds: string[] = []

  for (const apiPost of apiPosts) {
    // Extract and de-duplicate users
    users[apiPost.author.id] = apiPost.author

    // Extract and de-duplicate tags
    const tagIds: string[] = []
    for (const tag of apiPost.tags) {
      tags[tag.id] = tag
      tagIds.push(tag.id)
    }

    // Store post with ID references instead of embedded objects
    posts[apiPost.id] = {
      id:       apiPost.id,
      title:    apiPost.title,
      authorId: apiPost.author.id,   // reference, not copy
      tagIds,                         // references, not copies
    }
    postIds.push(apiPost.id)
  }

  return { users, posts, tags, postIds }
}

// ── Using normalizr library for complex schemas ───────────────────────
import { schema, normalize } from 'normalizr'

const tagSchema     = new schema.Entity('tags')
const userSchema    = new schema.Entity('users')
const postSchema    = new schema.Entity('posts', {
  author: userSchema,
  tags:   [tagSchema],
})

const { entities, result } = normalize(apiPosts, [postSchema])
// entities.users, entities.posts, entities.tags — all de-duplicated
// result: ["p1","p2"] — ordered ID list

Updating Alice's name after normalization is a single write: state.users.byId["u1"].name = "Alice Smith". Every post that references authorId: "u1" automatically shows the updated name — no post-by-post patching needed. For JSON in React fundamentals before adopting a full normalization strategy, see the linked guide.

Optimistic Updates with JSON

Optimistic updates make UI interactions feel instant by applying the expected result of an API mutation to local state immediately, then confirming or reverting based on the API response. The pattern requires three things: a snapshot of state before the mutation (for rollback), a temporary ID for new entities (correlation ID), and a revert path that fires on API error.

// ── TanStack Query: optimistic update with useMutation ───────────────
import { useMutation, useQueryClient } from '@tanstack/react-query'

function useUpdateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (update: { id: string; name: string }) =>
      fetch(`/api/users/${update.id}`, {
        method:  'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ name: update.name }),
      }).then(r => r.json()),

    // Step 1: apply optimistic update before API call
    onMutate: async (update) => {
      // Cancel any in-flight refetches that would overwrite our update
      await queryClient.cancelQueries({ queryKey: ['users'] })

      // Save snapshot for rollback
      const previousUsers = queryClient.getQueryData<User[]>(['users'])

      // Apply optimistic update to cache
      queryClient.setQueryData<User[]>(['users'], (old = []) =>
        old.map(u => u.id === update.id ? { ...u, name: update.name } : u)
      )

      // Return context with snapshot — available in onError and onSettled
      return { previousUsers }
    },

    // Step 2: revert on error using saved snapshot
    onError: (_err, _update, context) => {
      if (context?.previousUsers) {
        queryClient.setQueryData(['users'], context.previousUsers)
      }
    },

    // Step 3: always refetch to confirm server state
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  })
}

// ── Optimistic INSERT with correlation ID ─────────────────────────────
// New entities need a temporary client-side ID before the server assigns one
function useCreatePost() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (draft: { title: string; body: string }) =>
      fetch('/api/posts', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify(draft),
      }).then(r => r.json()),   // returns { id: "p99", title: "...", ... }

    onMutate: async (draft) => {
      await queryClient.cancelQueries({ queryKey: ['posts'] })
      const previousPosts = queryClient.getQueryData<Post[]>(['posts'])

      // Correlation ID: temp- prefix distinguishes from real server IDs
      const tempId = `temp-${Date.now()}`
      const optimisticPost: Post = {
        id:    tempId,          // replaced by server ID in onSuccess
        title: draft.title,
        body:  draft.body,
        status: 'pending',      // UI can show a spinner for pending posts
      }

      queryClient.setQueryData<Post[]>(['posts'], old => [
        ...(old ?? []),
        optimisticPost,
      ])

      return { previousPosts, tempId }
    },

    // Replace temp entity with confirmed server entity
    onSuccess: (serverPost, _draft, context) => {
      queryClient.setQueryData<Post[]>(['posts'], old =>
        (old ?? []).map(p =>
          p.id === context?.tempId ? serverPost : p
        )
      )
    },

    onError: (_err, _draft, context) => {
      queryClient.setQueryData(['posts'], context?.previousPosts)
    },
  })
}

// ── Zustand: optimistic update with manual rollback ───────────────────
import { create } from 'zustand'

interface PostStore {
  posts: Record<string, Post>
  updatePost: (id: string, patch: Partial<Post>) => Promise<void>
}

const usePostStore = create<PostStore>((set, get) => ({
  posts: {},

  updatePost: async (id, patch) => {
    // Step 1: save previous state snapshot
    const previousPost = get().posts[id]

    // Step 2: apply optimistic update immediately
    set(state => ({
      posts: {
        ...state.posts,
        [id]: { ...state.posts[id], ...patch },
      },
    }))

    try {
      // Step 3: confirm with API
      const confirmed = await fetch(`/api/posts/${id}`, {
        method:  'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify(patch),
      }).then(r => r.json())

      // Replace optimistic data with confirmed server data
      set(state => ({
        posts: { ...state.posts, [id]: confirmed },
      }))
    } catch {
      // Step 4: revert on error
      set(state => ({
        posts: { ...state.posts, [id]: previousPost },
      }))
      throw new Error(`Failed to update post ${id}`)
    }
  },
}))

The correlation ID (temp-{Date.now()}) is essential for optimistic inserts — without it, when the API responds with the real entity, you cannot reliably match and replace the temporary entity in state. For a complete implementation of React Query JSON fetching patterns including mutation and caching, see the linked guide.

Server State vs Client State from JSON APIs

The most important architectural decision in React state management is drawing the boundary between server state and client state. Server state is data fetched from a JSON API — it is owned by the server, potentially stale, and shared across users. Client state is data that exists only in the browser — it is owned by the client, always fresh, and never sent to a server. Mixing these concerns in the same store is the root cause of most React state management complexity.

// ── Server state: use TanStack Query ─────────────────────────────────
// Data from a JSON API is server state. TanStack Query handles:
// - caching, staleTime, gcTime
// - background refetch on window focus
// - request deduplication (one fetch shared by all components)
// - loading/error/success states
// - automatic retry on network failure
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// Fetching a JSON API resource — server state
function useUsers() {
  return useQuery({
    queryKey:  ['users'],
    queryFn:   () => fetch('/api/users').then(r => r.json()),
    staleTime: 5 * 60 * 1000,   // 5 min: don't refetch if data is fresh
    gcTime:    10 * 60 * 1000,  // 10 min: keep in cache after component unmounts
    // refetchOnWindowFocus: true (default) — refetch when user returns to tab
  })
}

// Components just call the hook — no prop drilling, no global loading flags
function UserList() {
  const { data: users, isLoading, isError } = useUsers()
  if (isLoading) return <p>Loading...</p>
  if (isError)   return <p>Error loading users</p>
  return <ul>{'{'}users?.map(u => <li key={'{u.id}'}>{'{u.name}'}</li>){'}'}</ul>
}

// ── Client state: use Zustand ─────────────────────────────────────────
// UI state that never lives on a server
import { create } from 'zustand'

interface UIState {
  sidebarOpen:     boolean
  activeTab:       string
  selectedUserId:  string | null
  modalVariant:    'confirm' | 'edit' | null
  filterText:      string
  toggleSidebar:   () => void
  setActiveTab:    (tab: string) => void
  selectUser:      (id: string | null) => void
  openModal:       (variant: 'confirm' | 'edit') => void
  closeModal:      () => void
  setFilterText:   (text: string) => void
}

const useUIStore = create<UIState>((set) => ({
  sidebarOpen:    false,
  activeTab:      'overview',
  selectedUserId: null,
  modalVariant:   null,
  filterText:     '',
  toggleSidebar:  () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
  setActiveTab:   (tab) => set({ activeTab: tab }),
  selectUser:     (id) => set({ selectedUserId: id }),
  openModal:      (variant) => set({ modalVariant: variant }),
  closeModal:     () => set({ modalVariant: null }),
  setFilterText:  (text) => set({ filterText: text }),
}))

// ── Combining server state and client state ────────────────────────────
// A filtered user list: filter is client state, users are server state
function FilteredUserList() {
  // Server state from TanStack Query
  const { data: users = [] } = useUsers()

  // Client state from Zustand
  const filterText = useUIStore(s => s.filterText)
  const selectUser = useUIStore(s => s.selectUser)

  // Derived value — computed from server + client state
  const filtered = users.filter(u =>
    u.name.toLowerCase().includes(filterText.toLowerCase())
  )

  return (
    <div>
      <input
        value={filterText}
        onChange={e => useUIStore.getState().setFilterText(e.target.value)}
        placeholder="Filter users..."
      />
      <ul>
        {filtered.map(u => (
          <li key={u.id} onClick={() => selectUser(u.id)}>
            {u.name}
          </li>
        ))}
      </ul>
    </div>
  )
}

// ── Rule of thumb: where does the source of truth live? ───────────────
// In a database, fetched via HTTP JSON API → server state → TanStack Query
// Only in the browser, never sent to a server → client state → Zustand

TanStack Query eliminates approximately 80% of the manual state management code required for JSON API data: the loading/error/data triple, the useEffect for fetching, the deduplication of concurrent requests, and the cache invalidation after mutations. The remaining 20% — UI state — is where Zustand or React context provides a clean, lightweight solution without the overhead of a full Redux store.

JSON Patch for Incremental State Updates

JSON Patch (RFC 6902) defines a standard format for describing changes to a JSON document as an ordered array of operations. Instead of sending a full updated document, the server or client sends only the diff. This is particularly valuable for large JSON state objects, collaborative editing, and undo/redo systems.

// ── RFC 6902 patch format ─────────────────────────────────────────────
// Six operations: add, remove, replace, move, copy, test
const patches: Operation[] = [
  { op: 'replace', path: '/users/u1/name',    value: 'Alice Smith' },
  { op: 'add',     path: '/users/u3',          value: { id:'u3', name:'Carol' } },
  { op: 'remove',  path: '/posts/p5' },
  { op: 'move',    from: '/draft',             path: '/published' },
  { op: 'copy',    from: '/templates/default', path: '/posts/p6' },
  // test: verify value before applying — rejects the whole patch if false
  { op: 'test',    path: '/version',           value: 7 },
]

// ── Applying JSON Patch to React state with fast-json-patch ───────────
import { applyPatch, createPatch } from 'fast-json-patch'
import { produce } from 'immer'

// Apply server-sent patches to Zustand store
import { create } from 'zustand'
import type { Operation } from 'fast-json-patch'

interface DocumentStore {
  doc:        AppState
  applyPatch: (patches: Operation[]) => void
  history:    Operation[][]    // patch history for undo
  undo:       () => void
}

const useDocumentStore = create<DocumentStore>((set, get) => ({
  doc:     initialState,
  history: [],

  applyPatch: (patches) => {
    set(store => {
      const nextDoc = produce(store.doc, draft => {
        // validate=true throws if patch is invalid
        // mutateDocument=false means we work on the immer draft
        applyPatch(draft, patches, /*validate*/ true, /*mutateDocument*/ false)
      })
      return {
        doc:     nextDoc,
        history: [...store.history, patches],   // save for undo
      }
    })
  },

  undo: () => {
    const { history, doc } = get()
    if (history.length === 0) return

    // Generate inverse patch for the last operation batch
    import('fast-json-patch').then(({ createPatch }) => {
      const lastPatches = history[history.length - 1]
      // Inverse: replace -> replace with old value, add -> remove, remove -> add
      const previousDoc = applyPatch(
        structuredClone(doc),
        lastPatches.map(invertPatch(doc)).reverse(),
        true, true
      ).newDocument

      set({ doc: previousDoc, history: history.slice(0, -1) })
    })
  },
}))

// Helper: compute inverse of a patch operation for undo
function invertPatch(currentDoc: AppState) {
  return (op: Operation): Operation => {
    if (op.op === 'replace') {
      // Inverse: replace back with the current value
      const ptr = op.path.replace(/^//, '').split('/')
      const oldValue = ptr.reduce((obj: unknown, key) =>
        (obj as Record<string, unknown>)[key], currentDoc)
      return { op: 'replace', path: op.path, value: oldValue }
    }
    if (op.op === 'add')    return { op: 'remove', path: op.path }
    if (op.op === 'remove') return { op: 'add', path: op.path, value: op.value }
    return op
  }
}

// ── Computing a patch from two JSON states ────────────────────────────
import { createPatch } from 'fast-json-patch'

const before = { name: 'Alice', score: 10 }
const after  = { name: 'Alice Smith', score: 10, badge: 'gold' }

const patch = createPatch(before, after)
// [
//   { op: 'replace', path: '/name',  value: 'Alice Smith' },
//   { op: 'add',     path: '/badge', value: 'gold' },
// ]

// ── Server-sent JSON Patch via SSE or WebSocket ───────────────────────
// Server sends incremental patches instead of full document re-sends
const eventSource = new EventSource('/api/doc/42/stream')

eventSource.onmessage = (event) => {
  const patches: Operation[] = JSON.parse(event.data)
  useDocumentStore.getState().applyPatch(patches)
}

// ── test operation for optimistic concurrency ─────────────────────────
// Server rejects the patch if version !== expected, preventing lost updates
const concurrentPatch: Operation[] = [
  { op: 'test',    path: '/version', value: 7 },      // guard
  { op: 'replace', path: '/status',  value: 'closed' },
  { op: 'replace', path: '/version', value: 8 },
]

The test operation is JSON Patch's built-in optimistic concurrency control — by asserting the current value of a version field before applying other operations, you guarantee the patch is applied to the correct document state and not silently applied on top of concurrent changes. For the broader context of JSON caching strategies including cache invalidation patterns, see the linked guide.

Serializing and Persisting JSON State

Browser storage options for JSON state differ significantly in API, capacity, and performance characteristics. localStorage is simplest but synchronous and size-limited. IndexedDB handles large structured data asynchronously. Zustand's persist middleware automates the serialize/deserialize cycle for either storage backend.

// ── localStorage: simple JSON persistence ─────────────────────────────
// Synchronous — blocks main thread during read/write
// Limit: 5–10 MB per origin (browser-dependent)
// Stores only strings — must JSON.stringify on write, JSON.parse on read

// Write
localStorage.setItem('appState', JSON.stringify(state))

// Read (with fallback)
const stored = localStorage.getItem('appState')
const initialState = stored ? JSON.parse(stored) : defaultState

// Caveat: JSON.parse(null) throws — always guard with ?? check
const raw = localStorage.getItem('appState')
const data = raw != null ? JSON.parse(raw) : null

// ── Zustand persist middleware: automatic localStorage sync ───────────
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

interface AppState {
  theme:      'light' | 'dark'
  favorites:  string[]
  lastViewed: string | null
  setTheme:   (t: 'light' | 'dark') => void
  addFavorite:(id: string) => void
  setLastViewed: (id: string) => void
}

const useAppStore = create<AppState>()(
  persist(
    (set) => ({
      theme:       'light',
      favorites:   [],
      lastViewed:  null,
      setTheme:    (theme) => set({ theme }),
      addFavorite: (id) => set(s => ({ favorites: [...s.favorites, id] })),
      setLastViewed: (id) => set({ lastViewed: id }),
    }),
    {
      name:    'app-store',                              // localStorage key
      storage: createJSONStorage(() => localStorage),   // default; explicit here
      // Persist only a subset of state (omit action functions — they are not serializable)
      partialize: (state) => ({
        theme:       state.theme,
        favorites:   state.favorites,
        lastViewed:  state.lastViewed,
      }),
    }
  )
)

// ── Schema migration between versions ─────────────────────────────────
// When state shape changes, migrate stored data to the new shape
const useAppStoreV2 = create<AppStateV2>()(
  persist(
    storeCreator,
    {
      name:    'app-store',
      version: 2,   // increment when shape changes
      migrate: (persistedState: unknown, fromVersion: number) => {
        if (fromVersion === 0) {
          // v0 had theme as boolean (true=dark), v1 uses 'light'|'dark'
          const s = persistedState as { darkMode: boolean }
          return { ...s, theme: s.darkMode ? 'dark' : 'light', version: 1 }
        }
        if (fromVersion === 1) {
          // v1 had favorites as Record<string,true>, v2 uses string[]
          const s = persistedState as { favorites: Record<string, boolean> }
          return { ...s, favorites: Object.keys(s.favorites), version: 2 }
        }
        return persistedState as AppStateV2
      },
    }
  )
)

// ── IndexedDB: async, GB-scale structured JSON storage ────────────────
// Use idb-keyval for simple key-value access to IndexedDB
import { get, set, del, createStore } from 'idb-keyval'

const docStore = createStore('jsonic-docs', 'docs')

// Write — async, non-blocking, no JSON.stringify needed (structured clone)
await set('largeDocument', { users: byId, posts: postsByid }, docStore)

// Read
const doc = await get('largeDocument', docStore)

// Custom IndexedDB storage adapter for Zustand persist
import { StateStorage } from 'zustand/middleware'

const indexedDBStorage: StateStorage = {
  getItem: async (name) => {
    const value = await get(name)
    return value ?? null
  },
  setItem: async (name, value) => {
    await set(name, value)
  },
  removeItem: async (name) => {
    await del(name)
  },
}

const useLargeStore = create<LargeState>()(
  persist(
    storeCreator,
    {
      name:    'large-store',
      storage: createJSONStorage(() => indexedDBStorage),
    }
  )
)

// ── Storage comparison ────────────────────────────────────────────────
// Feature          localStorage          IndexedDB
// API              Synchronous           Asynchronous (Promise/IDB API)
// Capacity         5–10 MB               Up to GB (disk-limited)
// Data types       Strings only          Structured clone (objects, blobs)
// Transactions     No                    Yes (atomic multi-key operations)
// Indexes          No                    Yes (B-tree indexes on properties)
// Use case         Small JSON state      Large datasets, offline-first apps

The Zustand persist middleware's partialize option is important: it selects which parts of state to persist. Always exclude action functions (they are not serializable) and transient state like loading flags. The version + migrate options handle the inevitable case where the state shape changes between app deployments — without migration, users with old localStorage data will either see errors or silently lose state.

TypeScript Types for JSON API State

Type-safe state management requires TypeScript types that accurately reflect the JSON API contract. Three strategies cover the spectrum from generated types (maximally accurate, requires tooling) to runtime-validated types (catches schema drift at runtime) to hand-written discriminated unions (models loading states correctly).

// ── Strategy 1: generated types from OpenAPI spec ─────────────────────
// openapi-typescript generates a Types namespace from openapi.yaml
// npx openapi-typescript openapi.yaml -o src/types/api.ts

import type { components, paths } from './types/api'

// Use generated types directly in state interfaces
type User    = components['schemas']['User']
type Post    = components['schemas']['Post']
type ApiError = components['schemas']['Error']

// Normalized state typed with generated entities
interface NormalizedState {
  users: {
    byId:   Record<string, User>
    allIds: string[]
  }
  posts: {
    byId:   Record<string, Post>
    allIds: string[]
  }
}

// Response type from a specific endpoint
type GetUsersResponse = paths['/api/users']['get']['responses']['200']['content']['application/json']
// = User[]  (inferred from the OpenAPI spec)

// ── Strategy 2: Zod for runtime validation + type inference ───────────
import { z } from 'zod'

// Define schema once — get runtime validation AND TypeScript types
const userSchema = z.object({
  id:        z.string(),
  name:      z.string().min(1).max(100),
  email:     z.string().email(),
  role:      z.enum(['admin', 'member', 'guest']),
  createdAt: z.string().datetime(),
  metadata:  z.record(z.unknown()).optional(),
})

// Infer TypeScript type from schema
type User = z.infer<typeof userSchema>

// Validate at the API boundary — throws ZodError if response doesn't match
async function fetchUser(id: string): Promise<User> {
  const raw = await fetch(`/api/users/${id}`).then(r => r.json())
  return userSchema.parse(raw)   // validates AND types the response
}

// Safe parse (does not throw) — useful for error UI
const result = userSchema.safeParse(rawData)
if (result.success) {
  const user: User = result.data     // fully typed
} else {
  console.error(result.error.issues) // structured error details
}

// Nested schemas for JSON API relationships
const postSchema = z.object({
  id:       z.string(),
  title:    z.string(),
  authorId: z.string(),
  tagIds:   z.array(z.string()),
  status:   z.enum(['draft', 'published', 'archived']),
})
type Post = z.infer<typeof postSchema>

// ── Strategy 3: discriminated union for loading states ────────────────
// Model all four states of a JSON API fetch as a union type
// TypeScript enforces exhaustive handling of all cases

type QueryState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T; fetchedAt: number }
  | { status: 'error';   error: string; code?: number }

// Usage in Zustand store
interface UsersStore {
  query: QueryState<User[]>
  fetchUsers: () => Promise<void>
}

const useUsersStore = create<UsersStore>((set) => ({
  query: { status: 'idle' },

  fetchUsers: async () => {
    set({ query: { status: 'loading' } })
    try {
      const data = await fetch('/api/users').then(r => r.json())
      const users = z.array(userSchema).parse(data)   // runtime validate
      set({ query: { status: 'success', data: users, fetchedAt: Date.now() } })
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Unknown error'
      set({ query: { status: 'error', error: message } })
    }
  },
}))

// In a component — TypeScript enforces handling all states
function UserList() {
  const query = useUsersStore(s => s.query)

  switch (query.status) {
    case 'idle':    return null
    case 'loading': return <Spinner />
    case 'error':   return <ErrorMessage message={query.error} />
    case 'success': return <List items={query.data} />  // data is User[] here
    // TypeScript errors if any case is missing (exhaustive check)
  }
}

// ── RTK createEntityAdapter with TypeScript ───────────────────────────
import { createEntityAdapter } from '@reduxjs/toolkit'

const usersAdapter = createEntityAdapter<User>({
  // Optional: custom ID selector (default: entity.id)
  selectId: (user) => user.id,
  // Optional: sort comparator
  sortComparer: (a, b) => a.name.localeCompare(b.name),
})

// Adapter state type: EntityState<User, string>
// = { ids: string[], entities: Record<string, User> }
type UsersState = ReturnType<typeof usersAdapter.getInitialState>

The discriminated union for loading states is the most important TypeScript pattern for JSON API state — it makes impossible states unrepresentable. Without it, a common bug is accessing data.map() when data might be undefined because loading isn't complete. With the discriminated union, TypeScript prevents accessing data unless the status is 'success'. For the full pattern of safe JSON parsing in TypeScript with Zod and error handling, see the linked guide.

Key Terms

Normalized State
A state shape where each entity from a JSON API is stored exactly once in a lookup map keyed by its ID, rather than duplicated across multiple arrays or nested objects. Normalized state eliminates update inconsistencies (the same entity showing different data in different parts of the UI) and reduces update complexity from O(n) to O(1). The canonical normalized shape for a collection is { byId: Record<string, T>, allIds: string[] } — byId for O(1) lookup and update, allIds for ordered iteration. Normalization is most valuable when entities are referenced in multiple places (e.g., a User referenced in Posts, Comments, and Notifications).
Entity
A discrete, identifiable data object returned by a JSON API — typically corresponding to a row in a database table. Entities have a stable unique identifier (usually an id field) that allows them to be stored, referenced, and updated by ID. In normalized state, each entity type (User, Post, Comment) gets its own slice of state. Cross-references between entities are stored as IDs rather than nested copies of the referenced entity. RTK's createEntityAdapter provides a standard interface for managing entity collections in Redux state.
byId / allIds Pattern
A normalized state structure consisting of two fields: byId, a plain object (or Map) where keys are entity IDs and values are entity objects — enabling O(1) lookup and O(1) update; and allIds, an array of IDs in the server-provided order — enabling O(n) ordered iteration. The equivalent in Redux Toolkit's createEntityAdapter is { ids: string[], entities: Record<string, T> }. The pattern separates the concern of "find entity by ID" (byId) from "render entities in order" (allIds), making both operations efficient without compromise.
Optimistic Update
A UI pattern that applies the expected result of an API mutation to local state immediately — before the network request completes — so the interface responds instantly without waiting for a server round-trip. Three components are required: (1) a snapshot of state before the mutation for rollback, (2) the optimistic state change applied immediately, and (3) a revert to the snapshot if the API call fails. For optimistic inserts (creating new entities), a temporary correlation ID (e.g., temp-1716212345678) identifies the temporary entity in local state so it can be replaced by the server-assigned ID in the success handler.
Server State
Data that lives on a server and is fetched by the client via HTTP JSON API calls. Server state is asynchronous (requires a fetch), potentially stale (the server may have newer data), shared across users (another user's action can change it), and not owned by the client. Examples: user profiles, order lists, product inventory. Server state requires infrastructure for loading states, error states, caching, background refetching, and deduplication — which is why dedicated tools like TanStack Query exist. Managing server state in Redux or Zustand requires manually reimplementing this infrastructure.
Client State
Data that exists only in the browser and is never persisted to or fetched from a server. Client state is synchronous (always available immediately), always fresh (no network round-trip needed to check for updates), and owned entirely by the client. Examples: whether a sidebar is open, which tab is active, a multi-step form's draft values, the text in a search box. Client state is appropriately managed with Zustand, React context, or useState — there is no need for caching, stale time, or background refetching because the client is the source of truth.
JSON Patch (RFC 6902)
An IETF standard (RFC 6902) that defines a format for describing changes to a JSON document as an array of operation objects. The six operations are: add, remove, replace, move, copy, and test. Each operation specifies a path (JSON Pointer, RFC 6901) identifying the location in the document to modify. Patches are applied atomically — if any operation fails (including a test assertion), the entire patch is rejected. JSON Patch enables incremental updates (sending only the diff rather than the full document), undo/redo systems (each operation has an inverse), and optimistic concurrency control (the test operation guards against concurrent modification).
Stale Time
A TanStack Query configuration option (staleTime) that specifies how long a cached JSON API response is considered fresh. During the stale time window, TanStack Query serves the cached response without triggering a background refetch. After the stale time expires, the data is marked stale — the next component mount or window focus event triggers a background refetch to update the cache. Default stale time is 0 (immediately stale). Set staleTime: 5 * 60 * 1000 (5 minutes) for data that changes infrequently (user profiles, configuration). Set lower stale times for data that changes frequently (notifications, live inventory).
RTK (Redux Toolkit)
The official, opinionated Redux package that simplifies common Redux patterns. Relevant to JSON state management: createEntityAdapter generates normalized state management utilities (CRUD operations, selectors) for any entity type in O(1)/O(n) time; createSlice combines action creators and reducers; RTK Query (included in RTK) is a full data-fetching and caching solution similar to TanStack Query, integrated with the Redux DevTools. createEntityAdapter is the RTK-specific equivalent of the byId+allIds pattern — it uses ids (array) and entities (record) rather than allIds and byId, but solves the same problem.

FAQ

What is normalized state and why is it better for JSON API data in React?

Normalized state stores each JSON API entity exactly once in a lookup map keyed by ID, then references that ID everywhere else rather than duplicating the full object. If a JSON API returns posts with embedded author objects, a flat array stores the author inside every post — updating the author name requires iterating the entire posts array to patch each copy (O(n)). Normalized state stores the author once in a users.byId map, and each post stores only the authorId. Updating Alice's name is a single O(1) write, and every post automatically reflects the change. The byId + allIds pattern provides both O(1) lookup by ID and O(n) ordered iteration: allIds preserves server-provided ordering, byId enables instant access. Redux Toolkit's createEntityAdapter implements this pattern via { ids: string[], entities: Record<string, T> }, generated from any JSON array in O(n) time. Beyond efficiency, normalization prevents stale data bugs where one component shows an updated name while another shows the old cached copy — there is a single source of truth for each entity.

How do I implement optimistic updates for a JSON API mutation in React?

The three-step pattern: (1) save a snapshot of the previous state, (2) apply the expected new state immediately, (3) revert to the snapshot if the API call fails. With TanStack Query's useMutation, implement onMutate to cancel in-flight queries and apply the optimistic update to the query cache, onError to restore the saved snapshot via queryClient.setQueryData, and onSettled to always refetch and confirm server state. For optimistic inserts, assign a temporary correlation ID (e.g., {"`temp-${Date.now()}`"}) to the new entity in local state, then replace it with the server-assigned ID in the onSuccess handler by matching on the correlation ID. Without a correlation ID, you cannot reliably match the temporary entity to the confirmed server response when multiple concurrent mutations are in flight. For Zustand, save const previousPost = get().posts[id] before mutating, then call set(state => ... ) in a catch block to revert. Never implement an optimistic update without a revert path — an unreverted failed mutation leaves users in a permanently inconsistent state.

What is the difference between server state and client state when consuming JSON APIs?

Server state is data fetched from a JSON API — it is asynchronous, potentially stale, shared across users, and not owned by the client. Examples: a user profile from /api/users/42, order list from /api/orders, product inventory. Client state exists only in the browser and is never sent to a server — it is synchronous, always fresh, and owned entirely by the client. Examples: whether a modal is open, which accordion panel is expanded, multi-step form draft values. The critical mistake is managing server state with client state tools (Zustand/Redux) — you then have to manually implement caching, loading flags, error states, background refetching, stale invalidation, and deduplication of concurrent requests. TanStack Query automates all of this: it caches JSON API responses, refetches when data is stale, shares a single in-flight request across all components, and retries on network failure. Use TanStack Query for server state, Zustand or React context for client state. The rule: if the data lives in a database and is fetched via HTTP, it is server state.

How do I apply JSON Patch (RFC 6902) updates to local React state?

Use the fast-json-patch library: import { applyPatch } from 'fast-json-patch'. Combine it with immer for immutable React state updates: produce(state, draft => { applyPatch(draft, patches, true, false) })true validates the patch (throws on invalid operations), false tells fast-json-patch to work on the immer draft rather than cloning again. For undo/redo, store patch history as an array of patch arrays. Each patch operation has an inverse: a replace generates an inverse replace with the old value (read the old value before applying), an add generates an inverse remove, a remove generates an inverse add. Replay the inverse of the last patch array in reverse order to undo. The test operation enables optimistic concurrency: if the current value at a path does not match the expected value, the entire patch is rejected — use this to assert a version number before applying server-sent patches.

How do I persist JSON state to localStorage between page refreshes?

Use Zustand's persist middleware: persist(storeCreator, { name: "my-store", storage: createJSONStorage(() => localStorage) }) — this automatically serializes state to localStorage on change and restores it on mount. Always use partialize to select only serializable state: exclude action functions and transient loading flags. For schema migration when the state shape changes, increment the version option and provide a migrate function that transforms old state shapes to the new shape. For manual persistence without the middleware: localStorage.setItem('state', JSON.stringify(state)) in a useEffect, and JSON.parse(localStorage.getItem('state') ?? 'null') for the initial value — guard against null because JSON.parse(null) returns null rather than throwing. For large JSON state (documents, offline data, cached API responses), use IndexedDB via idb-keyval: await set('state', stateObject) — async, no size limit, no JSON.stringify needed (structured clone handles objects natively).

How do I generate TypeScript types from a JSON API for type-safe state management?

Three approaches, in order of type accuracy. First, generate types from an OpenAPI spec using openapi-typescript: npx openapi-typescript openapi.yaml -o types/api.ts — produces a components['schemas']['User'] namespace that maps directly to your state interfaces. This is the most accurate approach but requires a maintained OpenAPI spec. Second, use Zod for runtime validation with type inference: const userSchema = z.object({ id: z.string(), name: z.string() }) then type User = z.infer<typeof userSchema>. Call userSchema.parse(response) at the API boundary — this validates the JSON response matches the expected shape at runtime, catching schema drift immediately. Third, write discriminated union types for loading states: type QueryState<T> = { status: "idle" } | { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: string }. TypeScript then enforces that you only access data when status === 'success', eliminating undefined-access bugs.

When should I use TanStack Query vs Redux/Zustand for JSON API state?

Use TanStack Query for all JSON API data (server state) and Zustand for UI state (client state) — they solve different problems and are best used together. TanStack Query handles the complexity of async server state automatically: caching with configurable staleTime, background refetching when data is stale, request deduplication (one fetch shared by all components that need the same query key), automatic retries, and garbage collection of unused cache entries. Building this infrastructure in Zustand or Redux requires significant boilerplate and is error-prone. Use Redux (with RTK) when you need strict predictability — large applications where state transitions must be auditable via DevTools, or where multiple state mutations must be coordinated atomically via reducers. RTK Query (built into Redux Toolkit) is a full alternative to TanStack Query for Redux users. Use Zustand for lightweight client state: sidebar open/closed, active tab, filter text, modal state. The decision rule: if the data lives on a server and is fetched over HTTP as JSON, use TanStack Query. If it lives entirely in the browser and is never sent to a server, use Zustand.

Further reading and primary sources