diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index e822c12..bde6d49 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -313,13 +313,21 @@ The current branch (`claude/update-react-typescript-He2rs`) has applied:
- **Pattern #1 (`useNitroEventState`)** — implemented + 1 pilot.
- **Pattern #3 (feature folder)** — **rejected**; the existing
`src/components//` + `src/hooks//` layout is kept.
-- **Pattern #4 (split god-hook)** — applied to the doorbell hook:
- `src/hooks/rooms/widgets/useDoorbellState.ts` (data) +
- `src/hooks/rooms/widgets/useDoorbellActions.ts` (actions).
-- **Pattern #2 (`useNitroQuery`)** — adapter prototype written, not yet
- enabled (needs `yarn add @tanstack/react-query`).
-- **Pattern #5 (Zustand store)** — skeleton written, not yet enabled
- (needs `yarn add zustand`).
+- **Pattern #4 (split god-hook)** — applied to:
+ - doorbell: `useDoorbellState` (data) + `useDoorbellActions`;
+ - poll: `usePollSubscriptions` (3 listeners) + `usePollActions`
+ (3 imperative actions). `useWordQuizWidget` migrated to import
+ `usePollActions` directly (it doesn't need the subscriptions).
+- **Pattern #2 (`useNitroQuery`)** — **enabled**: `@tanstack/react-query`
+ installed, `QueryClientProvider` mounted, real adapter in
+ `src/api/nitro-query/`, first migration on `OfferView`.
+- **Pattern #5 (Zustand store)** — **enabled**: `zustand` installed,
+ `createNitroStore` is now a real re-export, first migration converts
+ the `let isCreatingRoom` / `createRoomTimeout` singleton in
+ `NavigatorRoomCreatorView` to `useRoomCreatorStore`.
+- **Test infrastructure** — Vitest 3 + jsdom + @testing-library set up.
+ 22 smoke tests passing on the pure helpers
+ (`WiredCreatorTools.helpers.ts`) and the new Zustand store.
- **Bonus (error boundaries)** — `WidgetErrorBoundary` applied at
`RoomWidgetsView`.
@@ -327,28 +335,36 @@ The current branch (`claude/update-react-typescript-He2rs`) has applied:
## How to pick the next refactor PR
-Order of value/risk for the next contributor:
+Foundations #1–#3 (React Query, Zustand, Vitest) are **done**. Order of
+value/risk for the next contributor:
-1. **Enable React Query** (`yarn add @tanstack/react-query`) and migrate
- one read-only `useCatalog` fetch as a second pilot. Highest impact, low
- risk.
-2. **Enable Zustand** and migrate the `let isCreatingRoom` /
- `createRoomTimeout` singleton in `NavigatorRoomCreatorView`. Trivial,
- makes the Compiler stop complaining about cross-component variable
- writes.
-3. **Add tests** (still the #1 thing missing — see "What I'd fix" notes).
- Vitest + jsdom + a tiny mock layer for the renderer would unblock every
- refactor below.
-4. **Split `useCatalog`** — the biggest god-hook, following the doorbell
- pattern (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`
- as siblings under `src/hooks/catalog/`). Only do this *after* #1 and
- #2 in this list (React Query removes 60% of the file's responsibility,
- Zustand handles its UI state).
+1. **Migrate `useCatalog`'s read-only fetches to `useNitroQuery`.**
+ Biggest expected payoff (cache + dedup + loading state for free).
+ Move the page-tree fetch first; the imperative purchase/gift flows
+ stay where they are. Adds tests against the new hooks as you go.
+2. **Mount `usePollSubscriptions` once at room-session level** instead
+ of inside `useWordQuizWidget`. The shim in `usePollWidget` works for
+ now but is wrong design: subscriptions don't belong inside an actions
+ hook. Right place is probably `RoomWidgetsView` or wherever poll
+ state should be observable.
+3. **Split `useCatalog` along the doorbell/poll lines**
+ (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`,
+ siblings under `src/hooks/catalog/`). Only after #1 — React Query
+ removes ~60% of the file's responsibility, Zustand absorbs the UI
+ state slice.
+4. **Wider Vitest coverage**: add cases for `useDoorbellState` (event
+ reducer), `useNitroQuery` (timeout + cleanup), the smaller pure
+ formatters in `src/api/`. ~20 cases gets us to a meaningful smoke
+ baseline.
+5. **Per-tab split of `WiredCreatorToolsView`** (Monitor / Inspection /
+ Variables / Settings panels). Needs a tiny Zustand slice for the
+ shared state, then each tab moves to its own file. Unblocks the
+ React Compiler memoization on the parent module.
-Anything else (the per-tab `WiredCreatorTools` split, the
-`react-compiler/react-compiler` warnings, the `set-state-in-effect`
-sweep, the `LoginView` dialog split) is a downstream consequence of these
-five — easier and safer once the foundations are in place.
+Anything else (the `LoginView` dialog split, the
+`react-compiler/react-compiler` warnings on the remaining big files,
+the `set-state-in-effect` sweep) is a downstream consequence of the
+above — easier and safer once the foundations are in place.
---
diff --git a/src/hooks/rooms/widgets/index.ts b/src/hooks/rooms/widgets/index.ts
index b1632a6..9d3f9fb 100644
--- a/src/hooks/rooms/widgets/index.ts
+++ b/src/hooks/rooms/widgets/index.ts
@@ -10,6 +10,8 @@ export * from './useFilterWordsWidget';
export * from './useFriendRequestWidget';
export * from './useFurniChooserWidget';
export * from './usePetPackageWidget';
+export * from './usePollActions';
+export * from './usePollSubscriptions';
export * from './usePollWidget';
export * from './useUserChooserWidget';
export * from './useWordQuizWidget';
diff --git a/src/hooks/rooms/widgets/usePollActions.ts b/src/hooks/rooms/widgets/usePollActions.ts
new file mode 100644
index 0000000..2d6907d
--- /dev/null
+++ b/src/hooks/rooms/widgets/usePollActions.ts
@@ -0,0 +1,19 @@
+import { useRoom } from '../useRoom';
+
+/**
+ * Imperative poll actions. Stateless on purpose — split from
+ * usePollSubscriptions so components that only need to dispatch a
+ * vote / accept / reject don't also register the global subscription
+ * listeners.
+ */
+export const usePollActions = () =>
+{
+ const { roomSession = null } = useRoom();
+
+ return {
+ startPoll: (pollId: number) => roomSession?.sendPollStartMessage(pollId),
+ rejectPoll: (pollId: number) => roomSession?.sendPollRejectMessage(pollId),
+ answerPoll: (pollId: number, questionId: number, answers: string[]) =>
+ roomSession?.sendPollAnswerMessage(pollId, questionId, answers)
+ };
+};
diff --git a/src/hooks/rooms/widgets/usePollSubscriptions.ts b/src/hooks/rooms/widgets/usePollSubscriptions.ts
new file mode 100644
index 0000000..86e3dec
--- /dev/null
+++ b/src/hooks/rooms/widgets/usePollSubscriptions.ts
@@ -0,0 +1,47 @@
+import { RoomSessionPollEvent } from '@nitrots/nitro-renderer';
+import { DispatchUiEvent, RoomWidgetPollUpdateEvent } from '../../../api';
+import { useNitroEvent } from '../../events';
+
+/**
+ * Bridges the three poll-related renderer events (OFFER / ERROR / CONTENT)
+ * onto the UI event bus. Pure subscription — no React state, no return
+ * value. Mount this once where polls should be observable.
+ *
+ * This is the "subscriptions" half of the proposal #4 split for
+ * usePollWidget. The "actions" half is in usePollActions.
+ */
+export const usePollSubscriptions = (): void =>
+{
+ useNitroEvent(RoomSessionPollEvent.OFFER, event =>
+ {
+ const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.OFFER, event.id);
+
+ pollEvent.summary = event.summary;
+ pollEvent.headline = event.headline;
+
+ DispatchUiEvent(pollEvent);
+ });
+
+ useNitroEvent(RoomSessionPollEvent.ERROR, event =>
+ {
+ const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.ERROR, event.id);
+
+ pollEvent.summary = event.summary;
+ pollEvent.headline = event.headline;
+
+ DispatchUiEvent(pollEvent);
+ });
+
+ useNitroEvent(RoomSessionPollEvent.CONTENT, event =>
+ {
+ const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.CONTENT, event.id);
+
+ pollEvent.startMessage = event.startMessage;
+ pollEvent.endMessage = event.endMessage;
+ pollEvent.numQuestions = event.numQuestions;
+ pollEvent.questionArray = event.questionArray;
+ pollEvent.npsPoll = event.npsPoll;
+
+ DispatchUiEvent(pollEvent);
+ });
+};
diff --git a/src/hooks/rooms/widgets/usePollWidget.ts b/src/hooks/rooms/widgets/usePollWidget.ts
index 95d9d9c..746ae99 100644
--- a/src/hooks/rooms/widgets/usePollWidget.ts
+++ b/src/hooks/rooms/widgets/usePollWidget.ts
@@ -1,52 +1,17 @@
-import { RoomSessionPollEvent } from '@nitrots/nitro-renderer';
-import { DispatchUiEvent, RoomWidgetPollUpdateEvent } from '../../../api';
-import { useNitroEvent } from '../../events';
-import { useRoom } from '../useRoom';
+import { usePollActions } from './usePollActions';
+import { usePollSubscriptions } from './usePollSubscriptions';
-const usePollWidgetState = () =>
+/**
+ * @deprecated Prefer `usePollSubscriptions` (mount once, top-level) and
+ * `usePollActions` (anywhere a component dispatches a vote/accept/reject).
+ * This shim preserves the old `{ startPoll, rejectPoll, answerPoll }`
+ * shape for existing consumers, but each call also re-mounts the three
+ * subscription listeners — which is wrong if the hook is called from
+ * multiple places.
+ */
+export const usePollWidget = () =>
{
- const { roomSession = null } = useRoom();
+ usePollSubscriptions();
- const startPoll = (pollId: number) => roomSession.sendPollStartMessage(pollId);
-
- const rejectPoll = (pollId: number) => roomSession.sendPollRejectMessage(pollId);
-
- const answerPoll = (pollId: number, questionId: number, answers: string[]) => roomSession.sendPollAnswerMessage(pollId, questionId, answers);
-
- useNitroEvent(RoomSessionPollEvent.OFFER, event =>
- {
- const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.OFFER, event.id);
-
- pollEvent.summary = event.summary;
- pollEvent.headline = event.headline;
-
- DispatchUiEvent(pollEvent);
- });
-
- useNitroEvent(RoomSessionPollEvent.ERROR, event =>
- {
- const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.ERROR, event.id);
-
- pollEvent.summary = event.summary;
- pollEvent.headline = event.headline;
-
- DispatchUiEvent(pollEvent);
- });
-
- useNitroEvent(RoomSessionPollEvent.CONTENT, event =>
- {
- const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.CONTENT, event.id);
-
- pollEvent.startMessage = event.startMessage;
- pollEvent.endMessage = event.endMessage;
- pollEvent.numQuestions = event.numQuestions;
- pollEvent.questionArray = event.questionArray;
- pollEvent.npsPoll = event.npsPoll;
-
- DispatchUiEvent(pollEvent);
- });
-
- return { startPoll, rejectPoll, answerPoll };
+ return usePollActions();
};
-
-export const usePollWidget = usePollWidgetState;
diff --git a/src/hooks/rooms/widgets/useWordQuizWidget.ts b/src/hooks/rooms/widgets/useWordQuizWidget.ts
index 2074cf2..ecf73d7 100644
--- a/src/hooks/rooms/widgets/useWordQuizWidget.ts
+++ b/src/hooks/rooms/widgets/useWordQuizWidget.ts
@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { VoteValue } from '../../../api';
import { useNitroEvent } from '../../events';
import { useRoom } from '../useRoom';
-import { usePollWidget } from './usePollWidget';
+import { usePollActions } from './usePollActions';
const DEFAULT_DISPLAY_DELAY = 4000;
const SIGN_FADE_DELAY = 3;
@@ -16,7 +16,7 @@ const useWordQuizWidgetState = () =>
const [ questionClearTimeout, setQuestionClearTimeout ] = useState>(null);
const [ answerCounts, setAnswerCounts ] = useState