Full-Stack Questions
State and Data Libraries in Depth
Redux Toolkit, TanStack Query v5, Zustand and Jotai, server state vs client state, optimistic updates, hydration with the App Router, forms with Zod.
On this page
In one line: the three libraries answer three different questions. TanStack Query keeps a copy of server data fresh. Redux Toolkit gives a big team a strict, traceable place for client state. Zustand gives you a small, fast store with no setup. A strong answer names the kind of state first and the library second.
Lesson 13 gave the overview: what each library is, the staleTime and gcTime clocks, and a first optimistic update. State Management Decisions sorted state into five kinds. This lesson does not repeat those. It goes inside each library: how Redux Toolkit really works, the parts of TanStack Query that interviewers use to separate seniors from juniors (infinite lists, hydration, suspense, races), why Zustand needs no provider and when that bites, and how a form library fits next to them.
All code below was run or type-checked with Redux Toolkit 2.13, TanStack Query 5.104, Zustand 5.0, Jotai 3.0, React Hook Form 7.89 and Zod 4.6 on React 19.
Words used in this lesson
- A store is one object that holds state and lets code read it, change it and subscribe to changes. A subscriber is code that is told when the state changes.
- An action is a plain object that describes what happened, such as
{ type: 'todos/toggled', payload: 3 }. To dispatch is to send an action to the store. - A reducer is a pure function
(state, action) => newState. It does not change the old state; it returns the next one. - Immutable means "never changed in place". You make a new copy instead. React and Redux use this to see quickly that something changed (the new object is a different object).
- A selector is a function that reads one piece out of the state, such as
s => s.items.length. The component re-renders only if that piece changes. - Memoization is remembering the last result of a function and returning it again if the inputs are the same.
- Middleware is code that runs between "an action is dispatched" and "the reducer runs". It can log, delay, or start extra work.
- A cache is a stored copy that you reuse to avoid asking again. Hydration is when the browser takes HTML and data made by the server and "wakes it up" so React can take over.
- An atom (Jotai) is one small piece of state, kept outside components, that other atoms can depend on.
Server state and client state: the one rule
Server state is a copy of data that lives on a server and that others can change. Client state is data that only this browser tab owns. Server state needs caching, refetching and error handling. Client state needs a place to live. Mixing them is the most common mistake in this area.
Server state: orders, a profile, a product list. Its natural home is TanStack Query, RTK Query or Server Components. Client state: an open modal, a cart before checkout, a theme. Its home is useState, Zustand, Jotai or a Redux slice. Form fields are neither; a form library owns them. The full sorting is in State Management Decisions.
Rule I repeat in interviews: copy server data into a client store only when a user is editing it (a draft). A copy "just so everyone can read it" is a second cache with no refresh logic.
Redux Toolkit in depth
Redux Toolkit (RTK) is the official way to write Redux. It is a set of helpers that remove the boilerplate of plain Redux. The pieces: configureStore, createSlice, createAsyncThunk, createSelector, createEntityAdapter, the listener middleware, and RTK Query.
Slices and Immer
A slice is the state for one feature plus the reducers and actions that change it. createSlice writes the action types and action creators for you.
Immer is a library inside RTK. It gives your reducer a draft: a special copy-on-write proxy of the state. You write code that looks like a mutation (todo.done = !todo.done). Immer records what you changed and builds a new immutable object, reusing every part you did not touch.
import { createAsyncThunk, createSelector, createSlice, configureStore, type PayloadAction } from '@reduxjs/toolkit'type Todo = { id: number; text: string; done: boolean }export const fetchTodos = createAsyncThunk<Todo[], void, { rejectValue: string }>( 'todos/fetch', async (_arg, { rejectWithValue, signal }) => { const res = await fetch('/api/todos', { signal }) if (!res.ok) return rejectWithValue(`HTTP ${res.status}`) return res.json() },)const todosSlice = createSlice({ name: 'todos', initialState: { items: [] as Todo[], status: 'idle' as 'idle' | 'loading' | 'failed', error: null as string | null }, reducers: { toggled(state, action: PayloadAction<number>) { const todo = state.items.find((t) => t.id === action.payload) if (todo) todo.done = !todo.done // looks like a mutation, but state is an Immer draft }, }, extraReducers: (builder) => builder .addCase(fetchTodos.pending, (s) => { s.status = 'loading' }) .addCase(fetchTodos.fulfilled, (s, a) => { s.status = 'idle'; s.items = a.payload }) .addCase(fetchTodos.rejected, (s, a) => { s.status = 'failed'; s.error = a.payload ?? a.error.message ?? 'Unknown error' }), selectors: { selectItems: (s) => s.items },})export const { toggled } = todosSlice.actionsexport const store = configureStore({ reducer: { todos: todosSlice.reducer } })export type RootState = ReturnType<typeof store.getState>export type AppDispatch = typeof store.dispatchWhat I measured when I ran it:
- After an action that changes something, the state is a new object, and the old one is untouched. After an action that changes nothing (toggling an id that does not exist), the state is the same object. React-Redux relies on this to skip re-renders.
- State is frozen in development.
store.getState().todos.items.push(x)throwsCannot add property 1, object is not extensible. - Inside a reducer you may either mutate the draft or return a new value. Doing both throws:
An immer producer returned a new value *and* modified its draft. - A draft is revoked when the reducer ends. If you keep it in a variable and read it later, you get
Cannot perform 'get' on a proxy that has been revoked.
Reducers must stay pure: no fetch, no Date.now(), no Math.random() inside them. Do that work in a thunk, or put the result in the action (prepare callback).
createAsyncThunk
A thunk is a function you dispatch instead of an object. Redux calls it with dispatch and getState, so it can do async work and dispatch real actions later. createAsyncThunk is the standard shape for "call an API". It dispatches three actions: pending, then fulfilled or rejected.
Facts I checked by running it:
| Situation | What happens |
|---|---|
return value | fulfilled with payload = value |
return rejectWithValue('bad id') | rejected, and action.payload is 'bad id' (your own typed error) |
throw new Error('boom') | rejected, and action.error.message is 'boom', payload is undefined |
condition option returns false | no request, and a rejected action with meta.condition === true |
await dispatch(thunk()) | never throws: it resolves with the rejected action |
await dispatch(thunk()).unwrap() | resolves with the payload, or throws the error |
Two good habits: pass signal to fetch, so you can cancel with promise.abort(); and use condition to stop duplicate calls. For simple data fetching today, RTK Query (below) replaces most hand-written thunks.
Selectors and reselect
A selector that builds a new array each time is a trap. s.todos.items.filter(...) returns a new array on every call, so React-Redux thinks the value changed and re-renders on every store update. I checked: selector(state) === selector(state) is false.
createSelector (from the reselect library, re-exported by RTK) fixes this. It remembers the last result, and runs your calculation only when an input selector returns a different reference.
export const selectOpenCount = createSelector( [(s: RootState) => s.todos.items], (items) => items.filter((t) => !t.done).length,)In my run, calling it twice with the same state ran the calculation once; after the filter changed it ran again. Notes on reselect 5: the default memoizer keeps results per argument set (it uses WeakMaps), so alternating between two arguments does not recompute; and in development it warns when an input selector returns a new reference for the same arguments (An input selector returned a different result when passed same arguments). Do not wrap a trivial selector (s => s.user.name) in createSelector: there is nothing to save.
createSlice also accepts a selectors field, and the generated selectors take the root state. combineSlices builds the root reducer and wires this up, so counter.selectors.selectValue(store.getState()) works.
Entity adapter, middleware and the listener
createEntityAdapterstores a list as{ ids: [...], entities: { id: item } }(a normalised shape) with ready-madeaddMany,updateOneand selectors. Finding one item by id is O(1).configureStoreadds three default middleware in development: thunk, an immutability check, and a serializability check. I dispatched an action holding aDate: it loggedA non-serializable value was detected in an action. State should be plain data (numbers, strings, arrays, objects) so devtools, persistence and time travel work. Store ISO strings and convert at the edge.- The listener middleware (
createListenerMiddleware) runs side effects in response to actions:startListening({ actionCreator, effect }). Insideeffectyou getapi.delay,api.cancelActiveListenersandapi.condition. It replaces most uses of Redux Saga in new code.
RTK Query
RTK Query is the data-fetching part of RTK. It stores server data inside the Redux store and works with tags to know what to refetch.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'type Post = { id: number; title: string }export const api = createApi({ reducerPath: 'api', baseQuery: fetchBaseQuery({ baseUrl: '/api' }), tagTypes: ['Post'], endpoints: (build) => ({ getPosts: build.query<Post[], void>({ query: () => '/posts', providesTags: (result) => [ ...(result ?? []).map((p) => ({ type: 'Post' as const, id: p.id })), { type: 'Post', id: 'LIST' }, ], }), addPost: build.mutation<Post, Pick<Post, 'title'>>({ query: (body) => ({ url: '/posts', method: 'POST', body }), invalidatesTags: [{ type: 'Post', id: 'LIST' }], // refetch the list, not every post }), }),})export const { useGetPostsQuery, useAddPostMutation } = apiI ran the same endpoints against a fake base query. Two subscribers to getPosts made one request. After addPost, only the LIST tag was invalidated, so getPosts refetched and a separate getPost(1) did not. Unused data stays for keepUnusedDataFor seconds, 60 by default (TanStack's gcTime is 5 minutes).
Difference in one line: RTK Query invalidates by tags you declare on each endpoint; TanStack Query invalidates by query key you pass at call time.
TanStack Query v5 in depth
Lesson 13 covers the two clocks. These are the parts it skipped.
Keys are a tree, and invalidation walks it
invalidateQueries({ queryKey }) matches by prefix. Design keys from general to specific and keep them in one factory. Add exact: true to match only one entry.
export const todoKeys = { all: ['todos'] as const, lists: () => [...todoKeys.all, 'list'] as const, list: (f: Filters) => [...todoKeys.lists(), f] as const, detail: (id: number) => [...todoKeys.all, 'detail', id] as const,}// after saving todo 1:qc.invalidateQueries({ queryKey: todoKeys.lists() }) // all listsqc.invalidateQueries({ queryKey: todoKeys.detail(1), exact: true }) // only detail 1With four observed queries (two lists, detail 1, detail 2), the two calls above refetched exactly three: both lists and detail 1. Detail 2 was left alone. Also checked: object key order does not matter ({a, b} equals {b, a}), array order does, and a property set to undefined is ignored.
invalidateQueries marks entries stale and refetches only those that are in use on screen (active). Unused ones refetch the next time they mount.
A third value: staleTime: 'static'
Lesson 13 mentioned it. I tested it: with staleTime: Infinity, invalidateQueries() still refetched (2 calls). With 'static' it did not (1 call). Use 'static' for values that cannot change while the app runs.
Mutations and optimistic updates, with the race shown
There are two ways to show a result before the server answers:
- Via the UI: do not touch the cache. While
mutation.isPending, rendermutation.variablesas a temporary row. If it fails, the row disappears (or stays with a "Retry" button, sincevariablesare kept on error). To show it in another component, useuseMutationStatewith amutationKey. I ran this: while pending it showedhello; after finishing, it was empty. - Via the cache:
onMutatewrites a guess into the cache,onErrorrestores a snapshot,onSettledrefetches.
export function useAddTodo() { const qc = useQueryClient() return useMutation({ mutationFn: async (text: string) => { /* POST, throw on !res.ok */ }, onMutate: async (text) => { await qc.cancelQueries({ queryKey: KEY }) // an older refetch must not overwrite our guess const previous = qc.getQueryData<Todo[]>(KEY) qc.setQueryData<Todo[]>(KEY, (old = []) => [...old, { id: -Date.now(), text }]) return { previous } }, onError: (_err, _text, context) => qc.setQueryData(KEY, context?.previous), onSettled: () => qc.invalidateQueries({ queryKey: KEY }), // return the promise })}I built the race on purpose. A refetch started before the mutation and read the list ['a']. Then I added 'b' optimistically. Without cancelQueries, the old refetch finished after my write, and the cache became ["a"]: the new item vanished, while the server already had ["a","b"]. With cancelQueries, the cache kept ["a","b(optimistic)"]. Rolling back after a failed mutation restored the old list.
Returning the promise from onSettled (return qc.invalidateQueries(...), or the arrow shown above) keeps the mutation in pending until the refetch is done. This avoids a flash where the temporary row disappears before the real one arrives.
Callbacks given to mutate(vars, { onSuccess }) do not run if the component unmounts before the mutation ends. Callbacks given to useMutation still run. Put cache work in useMutation's callbacks, and component-only work (close a modal, navigate) in mutate's.
Infinite queries
An infinite query is a list that grows page by page ("load more" or infinite scroll). The cache holds one entry that contains all pages.
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } = useInfiniteQuery({ queryKey: ['projects'], queryFn: async ({ pageParam, signal }): Promise<Page> => { const res = await fetch(`/api/projects?cursor=${pageParam}`, { signal }) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, initialPageParam: 0, // required in v5 getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, // undefined or null = no more maxPages: 5, // keep at most 5 pages in memory and refetch only those})// data.pages is an array of pages: data.pages.flatMap((p) => p.items)Facts I verified by running it:
hasNextPagebecomesfalsewhengetNextPageParamreturnsundefinedornull.- With
maxPages: 3, after loading four pages the cache kept page params[3, 6, 9]; the oldest page was dropped. When refetched, the three kept pages were fetched one after another (3, 6, 9), not in parallel. This stops cursors from going stale. The cost: a refetch of a long list is slow, which is whymaxPagesexists. - There is only one fetch at a time per infinite query. Do not call
fetchNextPagewhileisFetching: guard withhasNextPage && !isFetching. - Use cursor pagination (
nextCursor) for lists that change. Offset pagination skips or repeats items when rows are inserted. - For numbered pages ("page 1, 2, 3") use a normal
useQuerywith the page in the key andplaceholderData: keepPreviousData. I saw the old page shown withisPlaceholderData: trueuntil the new one arrived, so the screen does not flash.
Prefetch and hydration with the Next.js App Router
The goal: the server fetches data, the HTML already contains it, and the browser starts with a full cache and makes zero requests.
// app/get-query-client.tsimport { QueryClient, defaultShouldDehydrateQuery, environmentManager } from '@tanstack/react-query'function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 }, // above 0, or the browser refetches at once dehydrate: { // also send queries that are still loading, so the data streams in later shouldDehydrateQuery: (q) => defaultShouldDehydrateQuery(q) || q.state.status === 'pending', }, }, })}let browserClient: QueryClient | undefinedexport function getQueryClient() { if (environmentManager.isServer()) return makeQueryClient() // new one for every request return (browserClient ??= makeQueryClient()) // one for the life of the tab}// app/posts/page.tsx (a Server Component)export default async function PostsPage() { const queryClient = getQueryClient() await queryClient.query(postsQuery).catch(noop) // fetch, cache, swallow the error return ( <HydrationBoundary state={dehydrate(queryClient)}> <PostList /> {/* a Client Component that calls useQuery(postsQuery) */} </HydrationBoundary> )}app/providers.tsx is a Client Component ('use client') that renders <QueryClientProvider client={getQueryClient()}>. The Next.js docs say the same: a new client per server render, one client in the browser.
What I ran and saw:
- The server prefetched,
dehydratemade a plain object, andrenderToStringof a component usinguseQueryproduced<p>p1</p>: the data is inside the HTML. A new browser client that received the same state made 0 requests (withstaleTime: 60_000). - The state goes through JSON. A
Datebecame a string. Return plain data fromqueryFn, or convert it on the way out. - Failed queries are not dehydrated by default, and pending queries are not either. After I added
shouldDehydrateQuery, a query that was still loading was sent too (0queries before,1after). That is how you start a slow fetch without blocking the page. getQueryClient()creating a new client on the server avoids sharing one user's cache with another. A cache shared across users is a data leak.
Version note. In TanStack Query 5.104 the docs use queryClient.query(options) for prefetching, and mark prefetchQuery, fetchQuery and ensureQueryData as deprecated (they still work and are removed in the next major version). query returns the data and throws on error, so add .catch(noop) when a failure is acceptable. Older tutorials and the Next.js guide still show prefetchQuery. In an interview, say you know both.
Two more rules from the TanStack docs. First, treat the Server Component as a place to prefetch, not to render the same data. If the server renders posts.length and the client revalidates later, TanStack cannot update the server-rendered text, so the two get out of sync. Second, do not call a Server Action as your queryFn: Server Actions called from the client run one after another, which conflicts with how queries fetch in parallel. Use a Route Handler or a plain API for queryFn. And if you start from a new App Router app, ask whether Server Components alone are enough (Next.js, Lesson 5); the docs themselves suggest starting without TanStack Query until you need it.
Suspense
Suspense is a React feature where a component that is not ready "waits", and the nearest <Suspense fallback> is shown. TanStack provides useSuspenseQuery, useSuspenseInfiniteQuery and useSuspenseQueries.
Differences from useQuery, which I checked:
datais never undefined at the type level, and there is noisPending. Loading is the fallback; errors go to an error boundary (a component that catches errors thrown below it). Reset the boundary withQueryErrorResetBoundary.- You cannot use
enabledorplaceholderData. To avoid the fallback appearing again when the key changes, wrap the key change instartTransition. - Two
useSuspenseQuerycalls in one component run one after another (a waterfall: each waits for the one before). Put independent queries in sibling components or useuseSuspenseQueries. - After the first success, a later refetch error does not throw: the component keeps showing the old data. The error is thrown only if there is no data.
- Prefetch on the server and use plain
useQueryfor that data. If the prefetch is removed by mistake,useQuerysimply fetches in the browser, whileuseSuspenseQuerywould suspend.
Zustand in depth
Zustand is a small state library. create returns a hook; the store lives outside React in module memory.
Selectors and the v5 loop
A component that calls useStore(s => s.count) re-renders only when count changes. I counted: with three components, after incA the one reading b did not re-render, but a component that used useStore() with no selector re-rendered on every change.
In Zustand 5 a selector that returns a new object or array each time causes an infinite loop. I got Maximum update depth exceeded with useStore(s => ({ a: s.a })). The fix is useShallow, which compares the object one level deep. With it, an unrelated change caused 0 extra renders.
import { useShallow } from 'zustand/shallow'const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })))Better: select each field separately (const a = useStore(s => s.a)). Prefer selecting actions alone too: functions in the store never change, so they never cause renders.
Slices
When the store grows, split it into slices: functions that each return one part of the state and are merged in create. Each slice can still read and write the whole store. This is a code-organisation pattern, not Redux slices.
Persist
persist middleware saves state to localStorage (by default) and loads it back.
export const useStore = create<Store>()( persist( (...a) => ({ ...createCartSlice(...a), ...createUiSlice(...a) }), { name: 'shop', // key in localStorage version: 1, storage: createJSONStorage(() => localStorage), partialize: (s) => ({ items: s.items }), // do not save UI flags or functions skipHydration: true, // call useStore.persist.rehydrate() in useEffect }, ),)Verified by running: nothing is written at store creation in v5; partialize kept only items in storage ({"state":{"items":[...]},"version":1}); with skipHydration: true the store started with [] and became ['saved'] only after persist.rehydrate(); and a stored version: 1 with a new version: 2 and a migrate function was converted correctly.
In Next.js, if the server renders with the empty store and the browser loads localStorage at once, the HTML differs and React reports a hydration mismatch. Use skipHydration and rehydrate in an effect, or show a placeholder until the store has hydrated. Also, localStorage is readable by any script on your page: never persist tokens you cannot afford to lose.
Why no provider, and when to add one
React Context needs a provider because the value lives in React. A Zustand store lives in a module variable; the hook subscribes to it with useSyncExternalStore, so any component can import and use it. Benefits: no wrapper, no context re-render problem, and you can read it outside React (useStore.getState()).
The cost is that a module is a singleton (one instance per process). In a browser tab that is fine. On a Next.js server one process serves many users, so a module-level store is shared between their requests. I tested the idea: a "second request" saw the value 42 set by the first. The Zustand docs say: create the store per request and pass it through context, and do not read or write a global store from Server Components.
const createCounterStore = (initial = 0) => createStore<CounterState>()((set) => ({ count: initial, inc: () => set((s) => ({ count: s.count + 1 })) }))export function CounterProvider({ initial, children }: { initial?: number; children: ReactNode }) { const [store] = useState(() => createCounterStore(initial)) // one store per provider return <Ctx.Provider value={store}>{children}</Ctx.Provider>}Two providers with initial 1 and 10 showed 1 and 10. Use this pattern also when you need a fresh store per page, per test, or per widget instance.
Jotai and Context, measured
I rendered the same case in each: two components, one reads name, the other reads count, and count changes. How many times did the name component render?
| Approach | name component renders | Why |
|---|---|---|
Context with one object { count, name } | 2 (one extra) | Any change to the value re-renders every consumer |
| Zustand with a selector | 1 | The selector for name did not change |
| Jotai with separate atoms | 1 | name is its own atom |
- Jotai (
atom) stores state in small pieces. A derived atom (atom(get => get(countAtom) * 2)) updates when its inputs do; I saw2after one increment. A write-only atom (atom(null, (get, set) => ...)) holds an action. With no provider Jotai uses a default store; use<Provider>orcreateStore()for isolated stores. - Context is the right tool for values that change rarely (theme, locale, a client object) and for passing a store instance down. If you must use it for changing state, split it into several contexts, or keep state and the setter apart.
Difference in one line: Zustand is one store, many selectors; Jotai is many atoms, no selectors; Context is one value, everyone re-renders.
Forms: React Hook Form with Zod
Lesson 13 introduced the setup. Here are the parts that decide real forms.
const Schema = z.object({ email: z.email('Enter a valid email'), age: z.coerce.number<string>().min(18, 'Must be 18+'), tags: z.array(z.object({ value: z.string().min(1, 'Required') })).max(3),})type FormIn = z.input<typeof Schema> // what the inputs hold (age is a string)type FormOut = z.output<typeof Schema> // what handleSubmit gives you (age is a number)const { register, handleSubmit, control, setError, formState: { errors, isSubmitting } } = useForm<FormIn, unknown, FormOut>({ resolver: zodResolver(Schema), defaultValues: { email: '', age: '', tags: [] } })const { fields, append, remove } = useFieldArray({ control, name: 'tags' })const onValid: SubmitHandler<FormOut> = async (values) => { const result = await save(values) if (result.emailTaken) setError('email', { type: 'server', message: 'Already registered' })}I ran this with a simulated user (jsdom, user-event):
- Typing 3 characters into a field made one extra render of the form, not three (the one was the
isDirtyflag changing, because I read it). That is the uncontrolled design at work. - On submit with bad data, both messages showed. Zod issues have paths like
tags.0.value, which match nested field names, so errors land on the right input. - After a valid submit,
agearrived as the number30. setErrorshows an error that came from the server (an email already taken). Always handle this: client validation cannot know about other users.useFieldArraygives each row a stableid(f.id). Use it as the Reactkey, not the index, or rows mix up when you remove one.
Gotchas: z.coerce.number() turns '' into 0 (I ran z.coerce.number().parse('') and got 0), so an empty age passes min(0). Check empty strings first. And watch('field') re-renders the whole form on every key press; use useWatch in a small child component instead.
Interview questions
Why do people say Redux Toolkit is "Redux without the pain"? What does createSlice give you? (Must know)
Plain Redux needs a lot of hand-written parts: action type strings, action creators, switch statements, manual copies, and store set-up. Redux Toolkit is the official set of helpers on top of it. createSlice takes a name, an initial state and reducer functions, and creates the action creators, the action types (todos/toggled) and the reducer. It uses Immer, so I can write todo.done = !todo.done and still get an immutable update. configureStore adds thunk, devtools and development checks for me.
It is still Redux: one store, plain-object actions, pure reducers. The trade-off is that even simple state needs a slice, an action and a selector, so I do not use it for a modal flag.
How does Immer let you "mutate" state in a reducer? What can go wrong? (Senior signal)
Immer gives the reducer a draft, a proxy of the real state. When I set draft.done = true, Immer records it, and at the end builds a new object that shares every part I did not touch. Unchanged branches keep the same reference, so selectors and React-Redux can skip work.
Things that go wrong: (1) mutating the draft and returning a new value throws; pick one. (2) Using the draft after the reducer ends throws, because the proxy is revoked. (3) Mutating state outside a reducer fails, because state is frozen in development. (4) Non-plain values (class instances, Date) in state trigger warnings.
Walk me through createAsyncThunk. How do you handle errors? (Must know)
It creates a function that dispatches pending first, runs my async function, then dispatches fulfilled with the return value or rejected. I handle those three in extraReducers.
For errors there are two paths. If I throw, the rejected action has action.error.message. If I want a typed server error, I return rejectWithValue(data) and read action.payload. A common trap: await dispatch(thunk()) never throws; it resolves with the rejected action. If I want try/catch, I call .unwrap(). I pass signal to fetch so promise.abort() cancels, and use condition to skip a duplicate call. For plain data fetching I prefer RTK Query.
When do you need createSelector, and what is a common mistake? (Senior signal)
I need it when a selector computes something that returns a new object or array: a filter, a map, a sort. Without it the selector returns a new reference on every call, so React-Redux re-renders the component on every store update, even when nothing relevant changed. I checked that two calls of a plain filter selector are not ===.
createSelector remembers the last result and recalculates only when an input selector returns a new reference. Common mistakes: wrapping a trivial selector (s => s.user.name), which gains nothing; input selectors that themselves make new arrays (reselect 5 warns about this in development); and using it for data only one component needs, where useMemo is enough. Often the cleanest fix is to select a small primitive.
RTK Query or TanStack Query? (Senior signal)
Both cache server data by request, share one request between components, and refetch after changes. If the app already uses Redux Toolkit, RTK Query is a natural fit: one store, one devtools, and endpoints are defined once with generated hooks. Invalidation uses tags that endpoints declare (providesTags, invalidatesTags).
TanStack Query has a larger community and more features: infinite queries with maxPages, query options reuse, useSuspenseQuery, useMutationState, offline and persistence plugins, and a clear hydration story for the Next.js App Router. It does not need Redux. For a new Next.js project I pick TanStack Query (or only Server Components), and add Redux only if the client state really needs it. I would not use both for the same data.
How do you design query keys and invalidate after a mutation? (Must know)
I put every value that changes the result into the key, and order the key from general to specific: ['todos', 'list', { status, page }] and ['todos', 'detail', id]. I keep them in one factory object so nobody types them by hand.
invalidateQueries matches by prefix, so ['todos'] hits everything and ['todos', 'list'] hits only lists. exact: true hits one entry. In my test, after saving todo 1, invalidating the lists prefix and the exact detail key refetched exactly three entries and skipped detail 2. Only queries on screen refetch at once; others refetch on next mount. Object property order does not matter in a key, but array order does.
Users say the app sends too many requests. Where do you look first? (Trap)
Default staleTime is 0, so every new mount, window focus and reconnect triggers a background refetch of all queries. That is safe but chatty. First I check what freshness each kind of data needs and set staleTime per query or a default (30 to 60 seconds for most lists). Then I check: refetchOnWindowFocus for data that does not change; keys that change on every render (an object with a new Date, for example), which create a new cache entry each time; and the same data fetched under two different keys. In SSR I check that the server default staleTime is above 0, or the browser refetches the server's data at once. For values that never change in a session, staleTime: 'static' stops refetching even after invalidateQueries; Infinity does not.
Implement an optimistic update. What is the bug people forget? (Senior signal)
In onMutate: await cancelQueries, save a snapshot with getQueryData, write the guess with setQueryData, and return the snapshot. In onError: restore the snapshot. In onSettled: invalidateQueries to get the real data.
The forgotten step is cancelQueries. If a refetch started before my mutation, it can finish after my write and replace my guess with old data. I reproduced it: without cancel the new item vanished and the cache showed ["a"] while the server had ["a","b"]; with cancel it stayed. Second bug: temporary ids. Use a negative or random id and do not send it to the server. If only one component shows the data, I skip the cache and render mutation.variables while it is pending. It is simpler and has no rollback.
How does an infinite query work, and what limits memory? (Must know)
The cache has one entry with data.pages and data.pageParams. I give initialPageParam (required in v5) and getNextPageParam, which returns the cursor for the next page or undefined when there is no more. I call fetchNextPage from a button or an intersection observer, and guard with hasNextPage && !isFetching.
When the query is refetched, all loaded pages are fetched one after another from the first, to avoid stale cursors. With 50 pages that is 50 requests. maxPages limits both memory and refetch work: in my run, maxPages: 3 kept pages [3, 6, 9] and refetched only those. For lists that change I use cursor pagination, because offset pagination repeats or skips rows when items are inserted.
How do you prefetch on the server and avoid a second request in the browser? (Senior signal)
In a Server Component I create a query client for this request, run the query with a server-side fetch, and wrap the client component in <HydrationBoundary state={dehydrate(queryClient)}>. The client component calls useQuery with the same key, finds the data in its cache and does not fetch. The browser needs one client that lives as long as the tab, and a staleTime above 0, otherwise it refetches at once. I checked both: with staleTime: 60_000 the browser made 0 requests.
Details that matter: a new client per server request (a shared one leaks data between users); dates become strings after JSON; failed queries are not sent; pending queries are sent only if I set shouldDehydrateQuery, which lets a slow query stream in without blocking the page; and the queryFn must use a server-reachable URL (a relative /api/... does not work in Node). I also avoid rendering the same data in the Server Component and the client, because revalidation would not update the server part.
useQuery or useSuspenseQuery? (Senior signal)
useQuery returns states: isPending, error, data | undefined. I handle them in the component. useSuspenseQuery removes those states: data is always defined, loading is the nearest <Suspense> fallback and errors go to an error boundary. The result is shorter components and loading UI in one place.
Costs: no enabled, no placeholderData; two suspense queries in one component run as a waterfall, so I split them into siblings or use useSuspenseQueries; and when the key changes the fallback shows again unless I wrap the change in startTransition. After the first success, later refetch errors do not throw, so the component keeps old data. If I prefetched on the server, I use useQuery for that data, so a missing prefetch degrades to a normal fetch.
A teammate copies useQuery data into useState so it can be edited. What goes wrong? (Trap)
The copy is made once, on first render. When the query refetches (window focus, invalidation), the component shows the new data in the query but the old copy in state. The user's edits and the server's changes silently diverge. Also useState(data) runs when data is still undefined on the first render.
For editing, I keep form state (React Hook Form with defaultValues/values from the query) and treat the server value as the starting point. For live fields, I keep the draft separate from the server value and merge on save. I do not copy server data into a global store "so other components can read it": they can call the same query. If a form must reset when new data arrives, I key the form on data.updatedAt or use reset(data) on purpose.
Zustand: why did my selector crash with "Maximum update depth exceeded"? (Trap)
In Zustand 5, useStore(s => ({ a: s.a, b: s.b })) returns a new object on each call. React thinks the snapshot changed every time, re-renders, calls the selector again, and loops. I reproduced the error. Zustand 4 let you pass an equality function; v5 removed it from create.
Fixes: wrap the selector in useShallow, which compares the object one level deep and returns the old reference if nothing changed; or select fields separately. For a fallback like s.action ?? (() => {}), define the fallback once outside the component, so its reference is stable. Selecting without any selector (useStore()) is legal but re-renders on every change; I found it re-rendered even for unrelated fields.
Why does Zustand need no provider? When does that become a problem? (Senior signal)
The store is a module-level object outside React. Components subscribe to it with useSyncExternalStore, so there is nothing to pass down. That means no wrapper, no re-render of the whole tree, and I can call getState() from a utility or an event handler.
The problem is that a module is shared by everyone who uses it in the same process. In the browser that is one user, so fine. In Next.js, server rendering runs in one process for many users, so a global store can leak state between requests: in my test a "second request" saw the first one's value. I also need fresh stores per test and per widget instance. The fix is createStore inside a provider (one store per request or per mount), useStore(store, selector) to read it, and not touching the store from Server Components.
How do you persist Zustand state safely? (Senior signal)
I use the persist middleware with a name, and partialize to save only what must survive (items, theme) and not functions, UI flags or tokens. I set a version and a migrate function, so when the shape changes old data from users' browsers is converted instead of crashing the app; I tested a version 1 to 2 migration. In v5 nothing is written when the store is created.
With SSR the server cannot see localStorage, so the first browser render would differ from the server HTML and cause a hydration mismatch. I set skipHydration: true and call persist.rehydrate() in useEffect, or I wait for hasHydrated() before showing persisted parts. And I remember localStorage is readable by any script, so no secrets; also it is per browser, so it is not a replacement for the server.
Context, Zustand, Jotai or Redux Toolkit for shared client state? (Must know)
I decide by how often the value changes and how many readers need different parts. Rarely changing, read everywhere (theme, locale): Context. A cart or filter panel with several fields: Zustand, because selectors stop unrelated renders; I measured that the component reading name rendered once with Zustand and twice with a Context holding one object. Many small linked values or derived values (a canvas, a form-like editor): Jotai atoms. A big team, many events, need for strict rules, time-travel debugging or an audit trail: Redux Toolkit.
I add one more rule: server data is none of these. It goes in TanStack Query. If the answer is "I do not need a store, the URL and props are enough", that is often the best answer.
How do React Hook Form and Zod work together, and what do z.input and z.output change? (Must know)
RHF reads values from uncontrolled inputs through refs, so typing does not re-render the form (I counted one render for three characters). The Zod resolver runs the schema on submit (or on blur or change if I set mode) and maps issue paths such as tags.0.value to field errors.
z.input is the type of what the inputs hold (strings), and z.output is what comes out after coercion and transforms (an age number). So I use useForm<FormIn, unknown, FormOut>: handleSubmit receives FormOut. I reuse the same schema on the server, because anyone can call my API without my form. Server-only failures (email taken) go in with setError. For lists I use useFieldArray and key rows by field.id. I avoid watch for big forms and use useWatch in small children; and I watch for z.coerce.number() turning '' into 0.
Design the state for a shop page: product list with filters, a cart, and a checkout form. (Senior signal)
I sort it by kind. Products are server state: TanStack Query with a key from the filters, staleTime 30 to 60 seconds, placeholderData: keepPreviousData so paging does not flash, and server prefetch with hydration for the first view. Filters, sort and page live in the URL, so links can be shared and the back button works; they feed the query key. The cart is client state before checkout: a Zustand store with a selector per component, persist with partialize for items only, and skipHydration plus a rehydrate effect. The cart total is derived during render or with a selector, not stored. The checkout form is RHF with a Zod schema reused by the API route; server errors are shown with setError. Adding to a server cart is a mutation with an optimistic update (cancelQueries, snapshot, rollback). Theme is Context. I would not add Redux here: nothing needs an audit trail, and I can say what I would change if it did.
Key takeaways
- Ask "what kind of state?" first. Server data goes in a query cache, not in a client store.
- Redux Toolkit: slices plus Immer (mutate or return, never both), thunks (
dispatchnever throws,unwrapdoes),createSelectorfor computed values, tags in RTK Query. - TanStack Query: key factories and prefix invalidation,
cancelQueriesbefore optimistic writes,initialPageParamandmaxPagesfor infinite lists, new client per server request,staleTimeabove0for SSR, andquery()instead of the deprecatedprefetchQueryin new 5.x code. - Suspense:
datais never undefined, noenabled, watch the waterfalls. - Zustand: selectors (and
useShallow) decide renders, slices organise,persistneedspartialize,versionandskipHydration. No provider is a strength in the browser and a risk on the server. - Context re-renders every reader; atoms and selectors do not.
- Forms: RHF is uncontrolled, Zod gives input and output types, the server has the last word through
setError.
What's next
Revisit Lesson 13 for the wider library tour, and State Management Decisions for the sorting rules. For rendering and the re-render rules behind every answer here, read Frontend Depth, Lesson 0 (render and commit) and Frontend Depth, Lesson 1 (hooks and stale closures).
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- State Management Decisions: Local, Context, Server Cache, Global Store · Senior Frontend Depth
- State and Rendering: What Really Happens When State Changes · React in Depth
- “The API Is Fast but the UI Is Slow”: How to Solve It · Scenario Debugging
Part of the study guide
Related series