Engineering

Redux Toolkit Re-Renders: What We Keep Out of State

Abhishek Bahukhandi

Abhishek Bahukhandi

•9 min read
Taqari cover art for a deep dive on Redux Toolkit re-renders and interview session state
Taqari cover art for a deep dive on Redux Toolkit re-renders and interview session state
Redux Toolkit re-renders traced through a live AI interview session: what belongs in the session slice, what stays in a ref, and why the audio path skips React.

A live mock interview is one of the least Redux-friendly screens we could have built. Audio frames arrive every 64 milliseconds, a timer ticks every second, an AI agent rewrites the code editor mid-sentence, and a compiler streams per-test-case results back over SSE. Get the state boundaries wrong and Redux Toolkit re-renders a 1,300-line component on every one of those events.

This post is the actual decision record for our interview session slice: what we put in global state, what we deliberately kept out, and the one rule that decides which. It is written from the code in this repo, including the parts we would do differently.

What the interview session slice actually holds

The session slice is not a cache of server data. It is a contract between four things that all need to agree on the same facts at the same instant:

  • The voice agent — a WebSocket loop that both reads the editor contents and writes to them through tool calls.
  • The code editor — a CodeMirror instance the candidate types into.
  • The compiler stream — an SSE subscription that fills in per-test-case results as the judge finishes them.
  • The report writer — which needs the full transcript after the session ends.

That last one matters more than it looks. The reason the editor's text lives in Redux at all is that the agent has tools named write_code, edit_lines and insert_code. When the interviewer says "try memoising that" and then writes the memo table into the editor itself, two independent owners are mutating one buffer. A useState inside the editor component cannot serve a writer that lives outside the editor component.

The three consumers that forced it global

Concretely, three facts have more than one writer or more than one reader, and those are exactly the ones that earned a place in the slice:

  1. Editor text and language — written by the candidate and by the agent, read by the compiler submit path.
  2. The compiler run — the run id is minted by the submit call, the results are written by the SSE hook, and they are read by both the results panel and the agent.
  3. The session id — minted by the token call, read by the proctoring hook, the report call and the transcript.

Everything else in that slice is there by accretion, not by design. Which brings us to the rule.

The rule: state goes in Redux only if two independent owners must agree on it. If one component owns it, it is useState. If it changes faster than you are willing to render, it is a ref — and the audio path is entirely refs.

Global, local, or a ref

Most "should this go in Redux" advice stops at two options. Real-time UI needs a third, because the question is not only who owns this value but how often does it change.

Global: anything two owners must agree on

The three facts above, plus the booleans that gate the layout — whether the editor pane is open, whether the mic is live, whether the socket is connected. These change on human timescales, a handful of times per session, and a re-render is the correct response to them.

Local: anything one component owns

The chat popup's message list is useState in the page component, not Redux, because nothing else reads it. The Redux style guide is blunt about the general case, under "Avoid Putting Form State In Redux": connecting forms to Redux "often involves dispatching actions on every single change event, which causes performance overhead and provides no real benefit."

The same page carves out the exception we are standing in — "WYSIWYG live previews of edited item attributes." An AI interviewer reading and rewriting your buffer is that exception. We pay the per-keystroke dispatch cost knowingly, and the mitigation is in the next section rather than in the slice.

A ref: anything faster than a render

The audio path never enters Redux. Not the PCM chunks, not the playback queue, not the analyser, not the "is the interviewer currently speaking" flag. These live in refs on the page component:

const audioContextRef = useRef(null);      // 16 kHz capture
const playbackContextRef = useRef(null);   // 24 kHz playback
const audioQueueRef = useRef([]);          // queued output chunks
const isPlayingAudioRef = useRef(false);
const playbackAnalyserRef = useRef(null);

A ref write does not schedule a render. That is the entire point. At 64 ms chunks, routing capture through a reducer would mean roughly 16 dispatches per second per direction, each one producing a new slice object and waking every subscribed component — to move bytes that no component ever displays.

The test we apply

Does any pixel change as a direct function of this value? If no pixel depends on it, it does not belong in state of any kind — global or local. Audio samples fail that test. The amplitude of those samples does drive pixels, and we will come back to how that is drawn without state.

Why Redux Toolkit re-renders more often than you expect

Two behaviours in react-redux explain nearly every surprise re-render, and both are documented rather than mysterious.

useSelector compares by reference, not shallowly

From the react-redux hooks reference: when an action is dispatched, useSelector() "will do a reference comparison of the previous selector result value and the current result value. If they are different, the component will be forced to re-render." It "uses strict === reference equality checks by default, not shallow equality."

That is the difference from the old connect() API, and the docs spell out the consequence: "With useSelector(), returning a new object every time will always force a re-render by default." The three documented fixes are one useSelector per field, a memoised selector, or passing shallowEqual as the equality function.

Subscribing to a slice couples you to every field in it

Our page does this:

const {
  leetCodeText, leetCurrentTime, leetIsConnected,
  leetCompilerRunId, leetCompilerStatus, leetCompilerTestResults,
  /* ...a dozen more */
} = useSelector((state) => state.leetInterview);

This is not the "new object every render" bug — Immer hands back the same slice reference when that slice did not change, so the === check does hold between unrelated dispatches. The cost is subtler and strictly worse than it looks: the component is now subscribed to every field in the slice. Any reducer that touches any key produces a new slice object, and the whole page re-renders.

The 1 Hz timer

An interval dispatches the elapsed-time string once per second. Nothing else in the slice changed, but the slice reference did, so the page component and the editor both re-render — about 2,700 renders over a 45-minute session, to update one clock. The timer is the clearest case of a value that should have been local to the component that renders it, with Redux reading the start timestamp only.

The keystroke problem

Every character typed into CodeMirror dispatches setLeetCodeText. Because the editor is a controlled component fed from the slice, that round trip is on the typing path. Our mitigation is to keep the editor's own internal state out of React's render cycle entirely — the same approach we described in building the in-browser editor with CodeMirror 6 — so the re-render lands on a component whose expensive subtree does not actually rebuild.

One more documented asymmetry is worth internalising: unlike connect(), useSelector() "does not prevent the component from re-rendering due to its parent re-rendering, even if the component's props did not change." Narrowing a selector buys nothing if a parent above it re-renders anyway. That is what React.memo is for, and it is why the ordering matters — fix the parent's subscription first, then memoise children.

How the audio stream stays out of the render path

The visualiser is the part people expect to be impossible without state. It is a canvas, an AnalyserNode, and a requestAnimationFrame loop that React never learns about:

const draw = () => {
  animationFrameIdRef.current = requestAnimationFrame(draw);
  analyserNode.getByteFrequencyData(dataArray);
  // ...paint bars straight onto the 2D context
};

React renders this component twice per session: once when the analyser is null, once when it arrives. Everything after that is the browser's animation loop writing pixels. Sixty frames a second of visibly reactive UI, zero dispatches, zero renders.

Two paths through the interview session: the audio path bypasses Redux and React entirely, while the control path dispatches to the session slice and re-renders subscribed components AUDIO PATH — refs only, no dispatch, no render Microphone AudioWorklet 64 ms chunks WebSocket wsRef.current voice model Playback queue audioQueueRef 24 kHz context AnalyserNode frequency bins canvas rAF loop 60 fps CONTROL PATH — dispatch, then render Agent tool call write_code SSE results per test case dispatch() immer draft → new slice session slice one new reference editor + results panel narrow selectors whole-slice subscriber re-renders on any field

The same discipline is what makes barge-in work: cancelling in-flight model output has to happen inside the socket handler, not after a render, which we unpacked in interruption handling in a voice agent.

Reading fresh state inside a long-lived closure

Here is the problem that pushes people back toward globals. The WebSocket onmessage handler is installed once. A value captured from useSelector at install time is frozen at that render's value — the classic stale closure. Re-installing the handler on every state change would tear down the socket subscription constantly.

The escape hatch is useStore:

const store = useStore();
// inside the handler, built fresh on every message:
const ctx = { dispatch, getState: () => store.getState().leetInterview, currentCodeRef };

Tool handlers receive getState rather than a snapshot, so they always read current values without the component subscribing to them. The hooks reference notes that useStore "should probably not be used frequently" and to prefer useSelector — correct advice, and a long-lived event handler that must not re-subscribe is the narrow case where it is the right tool. The distinction worth holding onto: useSelector is for values you want to render; store.getState() is for values you want to read.

Normalising the session slice

The compiler results are the one genuinely list-shaped thing in the slice, and they arrive incrementally: the SSE stream first learns the total case count, then fills in verdicts one at a time as the judge finishes them.

We seed an array of placeholders and patch by index:

updateDsaCompilerTestResult(state, action) {
  const { caseIndex, result } = action.payload;
  if (state.dsaCompilerTestResults[caseIndex]) {
    state.dsaCompilerTestResults[caseIndex] = {
      ...state.dsaCompilerTestResults[caseIndex], ...result,
    };
  }
}

Index-keyed patching is the lightweight version of what the style guide calls normalising relational state, and it is the right shape here because the case index is the natural key and the set is bounded and ordered. Patching one entry beats replacing the array: a wholesale replace makes every subscriber re-render even for cases whose verdict did not move. We still do a full replace at the terminal status, which is the next thing to clean up. If this slice ever grows to unbounded entities with real ids, createEntityAdapter is the upgrade path rather than hand-rolled lookups.

Derive, rather than store

The progress counter is the mistake to avoid: we keep { finished, total } in state when finished is just a count over the results array. The style guide's "Keep State Minimal and Derive Additional Values" exists for exactly this, and the cost is the usual one — two sources of truth that can disagree if a reducer updates one and forgets the other.

The fix is a memoised selector. With Reselect 5, which Redux Toolkit re-exports as createSelector, the default memoiser is weakMapMemoize: it keeps a cache entry per distinct argument set with no size limit, so a shared selector no longer thrashes when several components call it with different arguments the way Reselect 4's single-entry cache did. The practical upshot is that you can define one selector module-level and use it everywhere.

What we would change, in order

None of this needed a profiler to find; it needed someone to read the subscription list out loud.

  1. Move the elapsed-time string out of Redux. Keep the start timestamp in the slice, compute the display locally in the component that renders the clock. This alone removes the large majority of renders in a session.
  2. Replace whole-slice destructuring with per-field selectors. Many small subscriptions, each returning a primitive, so a compiler update stops waking the voice UI.
  3. Derive finished instead of storing it, behind a createSelector.
  4. Stop the terminal-status full array replace, and patch the last case like every other case.

The broader lesson is that "put it in Redux" and "put it in a component" are not the only two moves. A real-time feature has a third category — values that change faster than the UI needs to re-render — and refs plus the Web Audio graph handle it better than any state container will. Redux earns its place for the handful of facts that two owners genuinely have to agree on. Everything else is cheaper somewhere else.

If you want to see the result rather than read about it, the free AI mock interview runs this exact session loop in your browser. The compiler side of it — how those per-case results reach the UI in the first place — is in streaming test results with Redis and SSE.

Frequently asked questions

How do I stop unnecessary re-renders with Redux Toolkit?

+

Subscribe to the narrowest value you need. useSelector compares results with strict reference equality, so selecting a whole slice re-renders your component whenever any field in it changes. Use one selector per field, a memoized createSelector, or shallowEqual as the equality function.

Does useSelector use shallow equality?

+

No. The react-redux docs are explicit that useSelector uses strict === reference checks by default, unlike connect(), which compared mapState fields shallowly. That is why a selector returning a freshly built object re-renders on every dispatch until you pass shallowEqual or memoize it.

What should go in Redux versus component state?

+

Put a value in Redux only when two independent owners must agree on it. If a single component both writes and reads it, useState is cheaper and simpler. The Redux style guide specifically advises keeping form state local and dispatching once the edit is complete.

Should real-time audio data go in Redux state?

+

No. Audio frames change far faster than any UI needs to repaint, and no component renders the raw samples. Keep them in refs and the Web Audio graph, then draw amplitude onto a canvas in a requestAnimationFrame loop so React never re-renders for them at all.

How do I read current Redux state inside a WebSocket handler?

+

Call useStore() and read store.getState() inside the handler. A value captured from useSelector is frozen at that render, producing a stale closure, while re-installing the handler on every change tears down the socket. useSelector is for rendering; getState is for reading.

When should I use createEntityAdapter for session state?

+

Reach for it once you hold unbounded entities with real ids that arrive and update independently. For a short, ordered, index-keyed list such as per-test-case results, patching one entry in place is lighter and already gives you the lookup benefit normalization is for.

Sources

Did you find this helpful?

Share this guide with your circle.

#redux toolkit re-renders#useselector rerender#redux global state vs local state#redux toolkit session state#normalizing redux state#createselector memoized selector