cswork / libraryOpen practice workspace

Articles

Redux Toolkit Interview Questions: Entity State, Async Thunks, and Selector Stability

Prepare for Redux Toolkit interviews with entity adapters, requestId race guards, selector reference stability, and task-board test cases.

A Redux Toolkit interview question often starts innocently: “How would you store tasks for a board?” Then the interviewer edits one task, starts two fetches, and asks why an old response overwrote the current board or why every card rerendered. The answer connects three contracts: entity identity, async acceptance, and selected-value identity. Knowing that createSlice, createEntityAdapter, and createAsyncThunk exist is only the first step.

This guide uses a small task-board exercise to make those contracts observable. For adjacent practice, try Design State for a Three-Column Work Board, then justify exactly which fields belong in Redux, which stay local to a component, and which are server-owned.

Evidence boundary: Redux Toolkit and React Redux official documentation supports the APIs and default selector comparison described here. The board, request race, and answer rubric are illustrative exercises, not an employer-specific interview report. Code is a teaching sketch; adapt types and API contracts to your installed versions and app.

The exercise: an editable board with two fetches

Assume a board has 2,000 tasks. A task has a stable id, columnId, title, and updatedAt. The UI shows three columns, a selected task editor, and a count by column. A user opens board A, quickly switches to board B, then edits one title. The fetch for A resolves after B. Your design must keep B on screen, update only the intended entity, and avoid deriving a new array on every unrelated store change.

Start by identifying four to six urgent questions: Who owns remote task data? Is id globally unique or only board-scoped? What makes an async result current? Which components need a task object versus a derived list? How does a title edit preserve references? What does the app show when a fetch fails or is canceled? State the assumptions before producing a slice. If IDs are only unique within boards, key them by a composite (boardId, taskId) or keep separate board-scoped stores; do not let task 17 on board A overwrite task 17 on board B.

For this exercise, assume globally unique task IDs and a single active board. A normalized shape might be:

tasks.ids = ["t1", "t2", ...]
tasks.entities = { "t1": { id: "t1", columnId: "todo", ... }, ... }
tasks.activeBoardId = "B"
tasks.request = { status: "loading", requestId: "r2", error: null }

The ids array defines collection order; entities supports lookup by ID. Normalization is valuable when many components address records by ID and edits should not require searching every nested board column. It is not a command to put all transient UI state in the same slice. An unsaved input draft, hover state, and modal focus can remain local unless cross-component behavior requires shared ownership.

What createEntityAdapter solves—and what it does not

createEntityAdapter provides a conventional {ids, entities} state shape, CRUD-style update helpers, and selectors such as selectById and selectAll. With a sortComparer, it can maintain a sorted ids collection. It does not choose your business identity or resolve conflicts with the server. You must pick a stable ID or provide selectId, decide how to merge updates, and keep board membership coherent.

For a task-title edit, adapter.updateOne(state, {id, changes: {title}}) expresses a narrow entity update. A card selecting that task can receive a changed object; a card selecting a different unchanged task can retain its selected object reference. This is the intended locality, assuming reducers produce immutable updates and no broad replacement destroys every reference. Redux Toolkit's createSlice uses Immer internally for the standard reducer syntax, so a reducer can write updates in a mutation-like style while Immer produces the immutable result.

Do not claim normalization automatically prevents every rerender. Calling setAll after every keystroke can replace the whole collection. Sorting may change ids when the edited field is part of the sort comparator. A parent that selects selectAll receives an array representing the collection and may rerender even if a child card's record is unchanged. React parents can also rerender children for reasons unrelated to Redux. Explain the exact selector and reference chain, then measure the app if performance matters.

An adapter also does not make a server response trustworthy. If the response for board A arrives after board B, setAll will faithfully install the wrong data unless the async acceptance rule rejects it. Entity structure solves lookup and update shape; request identity solves temporal ownership.

Guard a stale async thunk response

createAsyncThunk dispatches pending, fulfilled, and rejected lifecycle actions and attaches a unique requestId in action metadata. It also exposes cancellation-related mechanisms such as signal and abort, and a condition option can prevent some launches. For the single-active-board exercise, store the latest request ID and board ID when pending arrives. Apply fulfilled or rejected only when both match the currently accepted request.

// Teaching sketch: `tasksAdapter` and `fetchBoard` are defined elsewhere.
builder
  .addCase(fetchBoard.pending, (state, action) => {
    state.activeBoardId = action.meta.arg;
    state.request.status = 'loading';
    state.request.requestId = action.meta.requestId;
    state.request.error = null;
  })
  .addCase(fetchBoard.fulfilled, (state, action) => {
    if (state.activeBoardId !== action.meta.arg ||
        state.request.requestId !== action.meta.requestId) return;
    tasksAdapter.setAll(state, action.payload);
    state.request.status = 'succeeded';
    state.request.requestId = null;
  })
  .addCase(fetchBoard.rejected, (state, action) => {
    if (state.activeBoardId !== action.meta.arg ||
        state.request.requestId !== action.meta.requestId) return;
    state.request.status = action.meta.aborted ? 'idle' : 'failed';
    state.request.error = action.error.message ?? 'Request failed';
    state.request.requestId = null;
  });

Trace the race. Request r1 for A starts. The user switches; r2 for B starts and becomes current. B fulfills, so the adapter installs B and clears the current request ID. A fulfills later, but its requestId (and board argument) no longer matches, so it cannot replace B. If A fails later instead, it also cannot set B's error state. The reducer guard is a correctness condition independent of network cancellation.

Canceling A when the user leaves can save work, but cancellation can race with completion or fail to stop downstream effects. The fulfilled guard remains useful. Conversely, a request ID alone is not a complete cache design: the slice above intentionally stores only one active board. If the product needs multiple boards cached simultaneously, represent requests and entities per board or use a server-state solution with an explicit cache policy.

Do not confuse an old response with an old edit. A user may edit task B locally while B's fetch is in flight; applying the response might overwrite that edit even though the request ID is current. Define a separate conflict policy: block editing until load, merge by server version, preserve an optimistic patch, or refetch after mutation. The acceptance guard solves request ordering, not every concurrent-write problem.

Make selector identity part of the answer

React Redux's useSelector compares the previous and next selector results using strict reference equality by default. If a selector returns a new object or array every time it is called, a store update can trigger a component rerender even when the underlying useful values are unchanged. For example, useSelector(state => ({todo: countTodo(state), done: countDone(state)})) allocates a new object. It is not enough to say, “The numbers did not change.” The selected reference did.

Use narrow selectors for stable primitives or individual entities. A task card can select selectTaskById(state, id); a count badge can select a primitive count. For derived arrays or objects, createSelector memoizes the result while its input selectors return the same references. Declare the memoized selector at a stable scope, or use an appropriate selector factory when each component instance needs independent arguments. Memoization is not free: an expensive result still recomputes when its inputs change, and a broad input such as the entire root state invalidates more often than needed.

Suppose a title edit updates task t1. A card for t1 should observe a changed task object. A card for t2 can observe the same task object if the reducer preserved its reference. A count derived only from columnId need not change numerically, but a selector that depends on the whole entities object may still recompute; a memoized selector can return the same result if designed around stable inputs or if its output is a primitive. Do not promise zero render work without checking the exact dependency graph.

selectAll is convenient for small lists, but a 2,000-task board may benefit from selecting ordered IDs at the column level and letting each card select its entity by ID. If dragging changes the order, the column rerenders appropriately; a title edit need not reconstruct every card prop. Verify the layout with React DevTools Profiler rather than optimizing solely by intuition. The goal is correct, understandable updates first, then measured performance.

React Redux's development checks can warn about selector result instability. Treat such a warning as a clue to inspect reference allocation, not as proof that Redux Toolkit is slow. Also distinguish a selector's cached result from a React component's memoization: React.memo compares props, while useSelector subscribes to store-derived results. One does not automatically replace the other.

When should this be RTK Query instead?

If the dominant problem is fetching, caching, invalidation, polling, and deduplicating server data, RTK Query may be a better fit than hand-writing a custom thunk and one-active-request slice. createAsyncThunk is useful for explicit workflows and orchestration, but it does not automatically define a full server cache or mutation invalidation policy. Explain why you are using it for the exercise: the interviewer wants the lifecycle and stale-result guard visible.

An app can combine approaches. RTK Query can own server-fetched task lists; a small slice can own client-only board interaction state. Alternatively, the adapter can hold a local editable working copy while API endpoints remain the source of truth. State where the authoritative version lives and how conflicts are resolved. Avoid duplicating the same remote list in two places without an explicit synchronization plan.

The choice also depends on scope. A task board confined to one page may not need a global Redux store. If multiple routes, editors, and background actions need coherent access, a shared store can be justified. “Use Redux because there are many components” is weak; identify the ownership, update frequency, offline needs, and testability that make the trade-off worthwhile.

Tests that expose the real bugs

Test the reducer with a two-task fixture: update t1, then assert t1.title changed, t2 content did not, and the intended order remains. For the async race, dispatch pending A, pending B, fulfilled B, fulfilled A; assert the active board and entities still describe B. Repeat with rejected A after B to ensure an old error does not appear on the current board. Test a rejected current request separately so the UI gets a usable error message. If cancellation is used, assert the current-request cleanup behavior.

For selectors, test outputs rather than only snapshots. After editing t1, selectById(t2) should preserve its reference under the chosen reducer operation. If a memoized derived selector promises stable output for an unrelated change, assert that reference too. Then run a component-level test or profiler trace for one edit and one drag. A reducer unit test cannot prove how React parents, keys, and props affect rendering.

Include failure modes beyond the happy path: duplicate IDs in server data, a task moved to a missing column, out-of-order responses, partial API data, retry after cancellation, and an optimistic edit rejected by the server. A good interview answer states which invariants are guaranteed by the slice and which require server or UI behavior. It does not hide ambiguity behind “Redux handles it.”

A concise interview answer

“I would use stable task IDs and normalized entity state because cards are edited and selected independently. The adapter gives me consistent update helpers, but I still define board membership and server conflict rules. For fetches, each thunk has a request ID; only the latest request for the active board may update entities or error state, so an old A response cannot overwrite B. Components select the smallest stable values they render. Derived arrays or objects need a stable memoized selector when reference identity matters under useSelector. I would test the A/B response race and one-task edit, then profile the real render path.”

Rehearse that answer with the request order reversed, then make task IDs board-scoped. If your design still works—or you can explain the necessary state-shape change—you understand the boundaries rather than just the API names. Start with Design State for a Three-Column Work Board and draw the state transitions before writing code.

Sources and Further Reading

Research checked September 23, 2026. Confirm the relevant Redux Toolkit and React Redux versions before copying version-sensitive code.