State Management at Scale

Hard

State management at scale requires distinguishing between server state, client state, and UI state. Each category has different caching, synchronization, and persistence needs. A well-designed state architecture prevents prop drilling, avoids unnecessary re-renders, and keeps the data flow predictable across a large application.

Interactive Visualization

Key Points

  • Server state (remote data) should be managed by a dedicated library like React Query or SWR
  • Client state (local UI) belongs in component state, context, or a lightweight store like Zustand
  • URL state (route params, search params) is often overlooked but is the most shareable state
  • Normalized stores prevent data duplication and keep entity updates consistent across views
  • Derived state should be computed (useMemo, selectors) rather than stored separately
  • Atomic state (Jotai, Recoil) scales better than single-tree stores for large apps
  • State colocation: keep state as close to where it is used as possible

Code Examples

State Category Classification

// Classify state by its nature, then choose the right tool

// 1. SERVER STATE — remote, async, cached
// Tool: React Query, SWR, Apollo
// Characteristics: owned by the server, needs sync
const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 5 * 60 * 1000,  // Fresh for 5 minutes
})

// 2. CLIENT STATE — local, synchronous, ephemeral
// Tool: useState, useReducer, Zustand, Jotai
// Characteristics: owned by the client, no server sync
const [isModalOpen, setIsModalOpen] = useState(false)
const [selectedTab, setSelectedTab] = useState('overview')

// 3. URL STATE — shareable, bookmarkable, navigable
// Tool: useSearchParams, URL routing
// Characteristics: should survive page refresh
const [searchParams, setSearchParams] = useSearchParams()
const filter = searchParams.get('filter') ?? 'all'
const page = Number(searchParams.get('page') ?? '1')

// 4. FORM STATE — complex validation, dirty tracking
// Tool: React Hook Form, Formik
const { register, handleSubmit, formState } = useForm<ProfileForm>()

Choosing the wrong state management tool is a common design mistake. Server state needs caching and sync; client state needs reactivity; URL state needs persistence across navigation.

Normalized Store Pattern

// Normalized store: entities stored by ID, referenced by collections
interface NormalizedState {
  entities: {
    users: Record<string, User>
    posts: Record<string, Post>
    comments: Record<string, Comment>
  }
  collections: {
    feedPostIds: string[]
    userPostIds: Record<string, string[]>
    postCommentIds: Record<string, string[]>
  }
}

// Normalizer: flatten nested API responses
function normalizePostResponse(response: PostAPIResponse): {
  entities: Partial<NormalizedState['entities']>
  result: string
} {
  const users: Record<string, User> = {}
  const comments: Record<string, Comment> = {}

  response.comments.forEach(c => {
    comments[c.id] = c
    users[c.author.id] = c.author
  })
  users[response.author.id] = response.author

  return {
    entities: {
      users,
      posts: { [response.id]: { ...response, comments: undefined } },
      comments,
    },
    result: response.id,
  }
}

// Selector: denormalize for components
function selectPostWithAuthor(
  state: NormalizedState,
  postId: string
): PostWithAuthor | undefined {
  const post = state.entities.posts[postId]
  if (!post) return undefined
  const author = state.entities.users[post.authorId]
  return { ...post, author }
}

Normalization prevents data duplication. When a user updates their avatar, it reflects everywhere instantly because there is only one copy of the user entity in the store.

Atomic State with Derived Values

// Atomic state: independent atoms, derived selectors
import { atom, useAtom, useAtomValue } from 'jotai'

// Base atoms: minimal, independent state
const searchQueryAtom = atom('')
const sortOrderAtom = atom<'asc' | 'desc'>('asc')
const itemsAtom = atom<Item[]>([])

// Derived atom: computed from base atoms (like a selector)
const filteredItemsAtom = atom((get) => {
  const query = get(searchQueryAtom).toLowerCase()
  const items = get(itemsAtom)
  const sort = get(sortOrderAtom)

  const filtered = query
    ? items.filter(item => item.name.toLowerCase().includes(query))
    : items

  return [...filtered].sort((a, b) =>
    sort === 'asc'
      ? a.name.localeCompare(b.name)
      : b.name.localeCompare(a.name)
  )
})

// Components subscribe to specific atoms
// Only re-render when their subscribed atom changes
function SearchBar() {
  const [query, setQuery] = useAtom(searchQueryAtom)
  return <input value={query} onChange={e => setQuery(e.target.value)} />
}

function ItemCount() {
  const items = useAtomValue(filteredItemsAtom)
  return <span>{items.length} results</span>
}

Atomic state management lets each component subscribe to exactly the state it needs. Derived atoms are automatically recomputed, eliminating the risk of stale derived state.

Common Mistakes

  • Using a global store for everything instead of categorizing state by its nature
  • Storing derived data (filtered lists, computed values) instead of computing it with selectors
  • Putting server-fetched data in Redux/Zustand instead of using a server state library
  • Not using URL state for filters and pagination, making views non-shareable
  • Over-subscribing to global state, causing unnecessary re-renders across the app

Interview Tips

  • Always classify state into server/client/URL/form categories before choosing tools
  • Explain why you would choose one state library over another for specific use cases
  • Discuss how state architecture affects re-render performance
  • Mention state colocation: keep state where it is consumed, lift only when necessary
  • Show awareness of when global state becomes an anti-pattern (e.g., form state)

Related Concepts