Skip to content

feat(chat): convert turn state from useReducer to Zustand store with createSelectors#31144

Merged
ashleeradka merged 2 commits into
mainfrom
devin/1779202702-lum-1623-turn-state-zustand
May 19, 2026
Merged

feat(chat): convert turn state from useReducer to Zustand store with createSelectors#31144
ashleeradka merged 2 commits into
mainfrom
devin/1779202702-lum-1623-turn-state-zustand

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka commented May 19, 2026

Prompt / plan

Convert the turn state machine from useReducer + prop-drilling to a Zustand store with direct named actions and createSelectors atomic selectors. Eliminates all prop-drilling of turnState, dispatchTurn, and turnStateRef across the component tree.

Why needed:

  • useReducer required prop-drilling state and dispatch through 3+ layers (ChatPageChatRouteContentChatComposer → hooks)
  • MutableRefObject<TurnState> (turnStateRef) was a workaround for synchronous reads during ~50ms streaming cadence — useTurnStore.getState() replaces this pattern natively
  • No selector support meant every consumer re-rendered on every state change

What changed:

  • Turn store at domains/messaging/turn-store.ts — 25 direct named actions replacing dispatch({ type: ... }) pattern
  • Action naming: on* for SSE-event reactions (onTextDelta, onStreamError, onPollReconciled), imperative for user/system actions (requestSend, cancelGeneration, resetTurn)
  • Wrapped with createSelectorsuseTurnStore.use.phase(), useTurnStore.use.statusText(), etc.
  • Zero useShallow — every subscriber uses per-field atomic selectors
  • Narrowed selector function signatures: isSending/isThinking accept { phase }, shouldShowThinkingIndicator accepts { phase, activeToolCallCount }, isSendDisabled/shouldShowAssistantBubble take only UIContext (unused state param removed)
  • ChatPage uses useLayoutEffect for store reset (prevents stale state flash between navigations)
  • dispatchTurn removed from ChatStore — turn state is a separate domain

Safety:

  • Pure turnReducer is preserved as an export — all 114 existing reducer tests run against it unchanged
  • 26 reconciliation tests + 6 chat-store tests pass
  • TypeScript compilation clean across all changed files
  • No behavioral change — only render optimization and prop-drilling elimination

CONVENTIONS.md updates:

  • Unified selector patterns section: createSelectors + .use.field() is the default; useShallow is explicitly wrong for stores owned in this repo
  • Turn-store guidance with action naming conventions
  • No lib/ inside domains convention
  • Open-source documentation guidance in root AGENTS.md

References:

Test plan

  • 114 pure reducer regression tests: pass
  • 26 reconciliation integration tests: pass
  • 6 chat-store tests: pass
  • TypeScript tsc --noEmit: clean
  • CI (Lint, Type Check & Build): green

Link to Devin session: https://app.devin.ai/sessions/d20746d75d8f4d688baddacd8f87b0ac
Requested by: @ashleeradka


Open in Devin Review

@linear
Copy link
Copy Markdown

linear Bot commented May 19, 2026

LUM-1623

@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 0 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ead273a17d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/web/src/domains/chat/chat-page.tsx Outdated

const [messages, setMessages] = useState<DisplayMessage[]>([]);
const [turnState, dispatchTurn] = useReducer(turnReducer, INITIAL_TURN_STATE);
const dispatchTurn = useTurnStore((s) => s.dispatch);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope turn state to a chat page instance

Switching from useReducer to the module-level useTurnStore makes turn state global for the entire app lifecycle, so it no longer resets when ChatPage unmounts/remounts. If a user navigates away mid-turn (or another ChatPage instance mounts), the next page can inherit stale phase/activeTurnId and show the wrong sending/stop state. The prior reducer-based state was instance-local and avoided this bleed-through.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in c5892ff. Added a useEffect in ChatPage that resets the store to INITIAL_TURN_STATE on mount, so navigating away mid-turn and returning starts with a clean slate (matching the prior useReducer instance-local behavior).

Comment on lines +160 to +165
const originalDispatch = useTurnStore.getState().dispatch;
useTurnStore.setState({
dispatch: (event: DomainEvent) => {
dispatchedEvents.push(event);
originalDispatch(event);
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore store dispatch instead of nesting test wrappers

Each createHarness() call wraps useTurnStore.dispatch around the current dispatch implementation, but never restores the base action. Because the store is singleton state, wrappers stack across tests and every dispatch triggers multiple wrapper layers, inflating dispatchedEvents and making assertions order-dependent/flaky. Resetting dispatch to a known base implementation per test avoids cumulative side effects.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in c5892ff. Replaced the wrapping approach with a self-contained dispatch spy that sets state AND captures events without referencing any previous dispatch function. Each createHarness() call now installs a fresh dispatch, eliminating the stacking issue.

@devin-ai-integration
Copy link
Copy Markdown
Contributor

Testing Results

Ran unit tests on the PR branch to verify the Zustand store integration.

Zustand Store Integration (26/26 pass)

Tests exercise useTurnStore.getState().dispatch() and useTurnStore.setState() — the exact patterns used by all consumers after the refactor. Proves store dispatch correctly applies the reducer, events are captured, and state resets properly between operations.

 26 pass, 0 fail, 65 expect() calls
 Ran 26 tests across 1 file. [119.00ms]
Pure Reducer Regression (114/114 pass)

Confirms turnReducer is completely unchanged — all phase transitions, tool call tracking, UI surface events, interruption events, and queue management work identically.

 114 pass, 0 fail, 212 expect() calls
 Ran 114 tests across 1 file. [28.00ms]
TypeScript Compilation

Zero new TS errors in any file touched by this PR. All pre-existing errors are from @/generated/api/ (gitignored generated code, unrelated).

Not Tested
  • End-to-end chat UI: No backend available in test environment. This is a purely internal refactor with identical external behavior — if the store correctly dispatches events (proven above), the UI behaves the same.
  • Streaming performance: Would require instrumented app. The optimization is structural (Zustand selector architecture).

Devin session

…rilling

- Replace turnReducer-based dispatch with 25 named Zustand actions
- Action naming: on* for SSE events, imperative for user/system actions
- Rename reconcilePoll → onPollReconciled (event-reaction naming)
- Remove turnState/dispatchTurn/turnStateRef prop drilling from
  ChatRouteContent, ChatComposer, and all hooks
- Components read directly from useTurnStore(selector)
- Stream handlers use ctx.turnActions.<action>() pattern
- Keep turnReducer as exported pure function for test coverage
- Add turn-store conventions to CONVENTIONS.md

Closes LUM-1623

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration devin-ai-integration Bot changed the title feat(chat): convert turn state machine from useReducer to Zustand store feat(chat): convert turn state machine from useReducer to Zustand store with direct named actions May 19, 2026
…ctions

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration devin-ai-integration Bot force-pushed the devin/1779202702-lum-1623-turn-state-zustand branch from c5892ff to 029266c Compare May 19, 2026 19:20
@devin-ai-integration devin-ai-integration Bot changed the title feat(chat): convert turn state machine from useReducer to Zustand store with direct named actions feat(chat): convert turn state machine from useReducer to Zustand store May 19, 2026
@devin-ai-integration devin-ai-integration Bot changed the title feat(chat): convert turn state machine from useReducer to Zustand store feat(chat): convert turn store from useReducer to Zustand with direct named actions May 19, 2026
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE

Significant improvement. dispatchTurn + turnStateRef are threaded through at least 10 hooks and the entire route content component — eliminating that prop drilling while replacing dispatch({ type: "..." }) with named action calls is a net gain in both ergonomics and debuggability. getState() replacing MutableRefObject<TurnState> is the correct Zustand pattern for non-React reads: synchronous, never stale, no ref-sync overhead. The pure reducer is preserved in the file for test isolation, which is the right call.


One real issue: ChatRouteContent calls useTurnStore() bare

Line 513:

const turnState = useTurnStore();

This subscribes to the entire store — all 6 state fields plus all 24 action functions. set() returns a new store object on every update, so ChatRouteContent re-renders on every onTextDelta(), onToolUseStart(), onActivityThinking() — 50ms cadence during streaming. That directly contradicts the PR's stated goal ("Selector-based subscriptions let each consumer re-render only when its slice changes — critical during streaming").

The store already has the right hook for this use case:

export function useTurnState(): TurnState {
  return useTurnStore(useShallow((s) => ({
    phase: s.phase,
    pendingQueuedCount: s.pendingQueuedCount,
    activeToolCallCount: s.activeToolCallCount,
    activeTurnId: s.activeTurnId,
    lastTerminalReason: s.lastTerminalReason,
    statusText: s.statusText,
  })));
}

The fix in ChatRouteContent is one word:

// Before
const turnState = useTurnStore();

// After
const turnState = useTurnState();

useTurnState() re-renders only when one of the 6 state fields changes — action function references never cause a re-render. Every other consumer in this PR does this correctly: hooks call useTurnStore.getState() for non-React reads; anything that needs to subscribe reactively should use useTurnState().


Secondary: useEffect vs useLayoutEffect for the mount reset

In ChatPage:

useEffect(() => {
  useTurnStore.setState(INITIAL_TURN_STATE);
}, []);

useEffect runs after the browser paints. If there's stale state (e.g. phase: "streaming") from a previous ChatPage session, the first paint frame renders with that stale state before the reset fires. useLayoutEffect runs after DOM commit but before paint, preventing the visible flash. Low-stakes in practice (state should usually be idle between navigations), but the lifecycle is subtler than it looks.


Everything else is solid

  • useTurnState() and useTurnActions() both correctly use useShallow — action references are stable so useTurnActions() won't re-render ✅
  • Named actions (requestSend, cancelGeneration, onStreamError) map cleanly to call sites; the on* vs imperative naming convention documented in CONVENTIONS is clear ✅
  • isStale() guard on event handlers that shouldn't fire on an idle/errored store — defensive and correct ✅
  • turnStateRef fully retired — 5 fewer refs to maintain, state reads are synchronous and always fresh ✅
  • Reducer preserved for test isolation — pure function, no Zustand involved, confirms every state transition ✅
  • Test helpers updated with full TurnActions mock using satisfies TurnActions constraint — catches shape drift ✅
  • Codex P1 (store lifetime, stale state on remount) addressed via useEffect reset ✅
  • Codex P2 (test dispatch stacking) addressed ✅

Bot review note: Both Devin and Codex reviewed the pre-rebase commit ead273a17d, which no longer exists in the branch. The current HEAD is the squashed 369f9acf68 + merge-main 029266ce2d. Their P1/P2 findings are addressed in the current code, but they haven't seen the final squashed form — specifically the useTurnStore() bare call above.

@devin-ai-integration
Copy link
Copy Markdown
Contributor

All three findings from the bot review addressed in f09a3e03f4:

1. Bare useTurnStore() in ChatRouteContent — Replaced with atomic createSelectors selectors: useTurnStore.use.phase(), useTurnStore.use.activeToolCallCount(), useTurnStore.use.statusText(). Zero useShallow — each selector subscribes to exactly one field.

Additionally narrowed the selector function signatures throughout:

  • isSending/isThinking → accept { phase: TurnPhase } instead of full TurnState
  • shouldShowThinkingIndicator → accept { phase, activeToolCallCount }
  • isSendDisabled/shouldShowAssistantBubble → removed unused _state param entirely (only used UIContext)
  • getThinkingStatusText → accept { statusText } (and inlined at call site in ChatRouteContent)

2. useEffectuseLayoutEffect — Changed in ChatPage to prevent stale state flash between navigations.

3. Merge conflict markers in ChatComposer — Resolved; turnState prop removed entirely (reads phase from store via useTurnStore.use.phase()).

All 146 tests pass (114 reducer + 26 reconciliation + 6 chat-store).

@devin-ai-integration devin-ai-integration Bot changed the title feat(chat): convert turn store from useReducer to Zustand with direct named actions feat(chat): convert turn state from useReducer to Zustand store with createSelectors May 19, 2026
@ashleeradka ashleeradka merged commit 805d9b6 into main May 19, 2026
3 checks passed
@ashleeradka ashleeradka deleted the devin/1779202702-lum-1623-turn-state-zustand branch May 19, 2026 20:23
devin-ai-integration Bot added a commit that referenced this pull request May 19, 2026
Resolved conflicts in 10 files where both branches removed their
respective dispatch prop-threading:
- Our branch removed dispatchInteraction (interaction Zustand store)
- Main's #31144 removed dispatchTurn (turn Zustand store)
Resolution: remove both dispatchers, use both Zustand stores directly.

Also incorporates main's #31194 (organization store migration) and

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
#31110 (OpenAPI spec sync).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant