State Management at Scale
HardState 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
The RADIO Framework
Structured approach to frontend system design interviews
Data Fetching Patterns
Caching, pagination, and optimistic update strategies
Real-Time Communication
WebSockets, SSE, and live update architectures
Performance Budgets & Optimization
Bundle budgets, Core Web Vitals, and optimization techniques