# Architecture & Refactor Plan > Status: **living document**, last updated 2026-05-10. > This file describes the structural direction the codebase is moving in. > Read it before starting a non-trivial refactor — half the value comes from > staying consistent, not from each individual change. ## Table of contents 1. [Where the project stands today](#where-the-project-stands-today) 2. [Five structural improvements](#five-structural-improvements) 1. [Event subscriptions as derived state](#1-event-subscriptions-as-derived-state) 2. [Server requests as queries](#2-server-requests-as-queries) 3. [Feature folders](#3-feature-folders) 4. [Splitting god-hooks](#4-splitting-god-hooks) 5. [Unified UI store](#5-unified-ui-store) 3. [Bonus: error boundaries](#bonus-error-boundaries) 4. [What's already in place](#whats-already-in-place) 5. [How to pick the next refactor PR](#how-to-pick-the-next-refactor-pr) --- ## Where the project stands today The codebase is a React 19.2 client for the Nitro renderer (Habbo-style hotel client). Most of the architectural pressure comes from the renderer's **event-bus + composer/parser** model: the UI talks to the server by sending composers and listening to incoming message events. Almost every piece of state in this app is "the latest value seen on a given event". That model creates two kinds of friction with modern React: 1. **`useEffect` everywhere** — `react-hooks/set-state-in-effect` reports ~328 violations across ~280 files. Most are legitimate event-driven updates, but the pattern hides the intent (it reads as "imperative setState on mount/effect" rather than "subscribe to a stream"). 2. **God-hooks** — `useCatalog` (~1100 lines), `useChat`, `useWiredTools`, `useInventoryFurni` all bundle data fetching, UI state, side effects, and computed values into a single export. Components import the whole thing for one field; the React Compiler skips memoization. Two big files (`WiredCreatorToolsView.tsx` 4493→3901 lines, `LoginView.tsx` 1700) further compound the problem: the Compiler logs "Compilation Skipped: Existing memoization could not be preserved", which means manual `useMemo`/`useCallback` are not even helping. The improvements below are ordered so that each one makes the next one easier. --- ## Five structural improvements ### 1. Event subscriptions as derived state **Problem.** Pattern repeated hundreds of times: ```ts const [foo, setFoo] = useState(initial); useNitroEvent(SomeEvent, e => setFoo(e.payload)); ``` or with the message channel: ```ts const [data, setData] = useState(null); useMessageEvent(SomeParser, e => { const parser = e.getParser(); if (!parser) return; setData(parser.field); }); ``` The shape of the code obscures the intent ("`foo` IS the latest event payload") and makes the lint think we're doing imperative setState in an effect. **Solution.** Two thin hooks (`src/hooks/events/useNitroEventState.ts` and `useMessageEventState.ts`): ```ts const foo = useNitroEventState(SomeEvent, e => e.payload, initial); const data = useMessageEventState(SomeParser, e => e.getParser()?.field ?? null, null); ``` Internally the selector closure is held in a ref refreshed in commit phase (`useLayoutEffect`), so a new selector identity per render does not force re-subscription. The listener is registered once. **Status.** Implemented + adopted in `OfferView.tsx`, `useAvatarInfoWidget` (figure/badges/group merge), and `useInventoryFurni` (extracted pure reducers consumed by `useMessageEvent` setters). **Adoption.** Organic: when a contributor sees a clean "derive-from-single-event" case, they convert it. **Do not sweep-replace.** The majority of existing subscriptions have side effects, multi-state updates, conditional filters, or state-machine semantics that lose information when forced into a single selector. **Companions** (all implemented in `src/hooks/events/`): - `useNitroEventReducer(types, reducer, initial)` — multiple event types collapsing into one owned state slice (analogous to `useReducer` but driven by renderer events). - `useMessageEventReducer(eventTypes, reducer, initial)` — same shape on the server message channel; accepts a single type or an array of types that all feed the same reducer. - `useExternalSnapshot(subscribe, getSnapshot)` — `useSyncExternalStore` wrapper pairing the renderer's `EventDispatcher.subscribe()` with the `getXxxSnapshot()` getters added in renderer 2.1.0. Use this for readonly views over manager state. Eight pre-built consumers live in `src/hooks/session/useSessionSnapshots.ts` (userData / activeRoomSession / ignoredUsers / groupBadges / soundVolumes / roomUserList + scalar derivations `useIsUserIgnored`, `useGroupBadge`), each with defensive `typeof` guards against a stale renderer bundle. **Note (2026-05-18):** the first three pilot migrations (`useSessionInfo`, `useChatWidget.ownUserId`, `AvatarInfoWidgetAvatarView` Ignore-menu) were rolled back in `e142efd` after a persistent runtime error `(intermediate value)() is undefined` at `ToolbarView.tsx:46` that the vite-alias fix (`790ad2b`) and defensive guards (`c35a2d4`) could not eliminate. Suspected interaction: `useBetween` + `useSyncExternalStore` + React Compiler. Before retrying any migration here, exercise the snapshot hooks from a non-`useBetween` consumer in a low-blast-radius widget first to isolate the cause. For state owned outside the listener (the `useState` + `setState(prev => applyX(prev, event))` pattern), keep using `useNitroEvent` / `useMessageEvent` and extract the reducer as a pure function for testability. See `src/hooks/inventory/useInventoryFurni.reducers.ts` and `src/hooks/rooms/widgets/avatarInfo.reducers.ts` for the convention. --- ### 2. Server requests as queries **Problem.** A request/response pair against the server today looks like: ```ts useEffect(() => { SendMessageComposer(new GetXComposer()); }, []); useMessageEvent(YParser, e => { setData(e.getParser().data); }); ``` There is no caching, no deduplication, no retry, no loading or error state, no devtools. Every consumer rolls its own. The same request fires multiple times if multiple components mount it. **Solution.** Wrap composer/parser pairs in a TanStack Query adapter (`@tanstack/react-query` is in the same family as `@tanstack/react-virtual` which is already a dependency): ```ts const { data, isLoading } = useNitroQuery({ request: () => new GetXComposer(), parser: YParser, select: e => e.getParser().data, }); ``` **Status.** Adapter prototype written (`src/api/nitro-query/createNitroQuery.ts`). Not wired up because `@tanstack/react-query` is **not yet installed** — deliberately left as a `yarn add` step the team can approve. **To enable.** ```sh yarn add @tanstack/react-query @tanstack/react-query-devtools ``` Then mount the provider in `src/index.tsx`: ```tsx ``` **Migration order suggested.** 1. Read-only catalog data (`useCatalog` page fetches) — biggest win, lowest risk because it's mostly read. 2. Inventory tabs. 3. Navigator search results. 4. Marketplace listings. Push messages (events the server emits without the client asking) keep using `useMessageEventState` — they're not requests. --- ### 3. Feature folders ~~(adopted)~~ — **rejected, keep the current layout** > **Update:** an earlier version of this document proposed a > `src/features//` layout (vertical slices). The pilot on the > doorbell widget showed that the existing `src/components//` + > `src/hooks//` split is the convention the team wants to keep. > The pilot has been rolled back; this section is left as a record of > the decision. **Current convention** (the one to follow): - **Views** live under `src/components///*.tsx` (e.g. `src/components/room/widgets/doorbell/DoorbellWidgetView.tsx`). - **Hooks** live under `src/hooks///*.ts` (e.g. `src/hooks/rooms/widgets/useDoorbellState.ts`). Multiple hooks for the same widget go in the same folder as siblings, not in a per-widget subfolder. - **Pure helpers / constants / types** that are specific to one view go in sibling files next to the view (see `src/components/wired-tools/WiredCreatorTools.{types,constants,helpers}.ts` for the established pattern). - **Cross-cutting** utilities continue to live under `src/api/` and `src/common/`. Discoverability is acceptable as long as the **naming** is consistent — `useDoorbellState` / `useDoorbellActions` / `DoorbellWidgetView` are greppable in seconds even though they live in three separate directory trees. --- ### 4. Splitting god-hooks **Problem.** `useCatalog.ts` is ~1100 lines. It owns: - Server fetch lifecycle (request/parser pairs) - UI state (selected page, current product, filters) - Side effects (purchases, gift composer dispatch) - Computed values (pricing display, page tree) - Cross-cutting helpers (currency lookup, club level checks) Every component that imports `useCatalog()` for one field re-runs the whole thing. The Compiler can't memoize it (too large). Tests can't be written against a single concern. **Solution.** Split by responsibility, not by entity: ```ts useCatalogData() // server data, returns { pages, currentPage, isLoading } useCatalogUiState() // ui state, returns { selectedNode, setSelectedNode, filters, ... } useCatalogActions() // imperative actions, returns { purchase, gift, openOffer } ``` Inside, `useCatalogData` uses `useNitroQuery` (#2). `useCatalogUiState` uses a Zustand slice (#5). `useCatalogActions` is a stateless export — just functions that compose composers. **Status.** Pilot done on `useDoorbellWidget`: - `src/hooks/rooms/widgets/useDoorbellState.ts` — the users list, derived from three events using a `useNitroEventReducer`-like pattern. - `src/hooks/rooms/widgets/useDoorbellActions.ts` — `answer(name, flag)`. - `src/hooks/rooms/widgets/useDoorbellWidget.ts` kept as a deprecated shim that composes the two so existing consumers don't break. It's a small hook so the split looks almost theatrical, but the shape is the same one we want to apply to `useCatalog`. **Migration order suggested.** Largest pain first, moving down: 1. `useCatalog` (~1100 LOC) — but only after #2 is enabled (server fetches collapse to a few `useNitroQuery` calls, removing 60% of the file). 2. `useChatInputWidget` (~500 LOC) 3. `useWiredTools` (~600 LOC) 4. `useInventoryFurni` (~300 LOC) --- ### 5. Unified UI store **Problem.** Cross-feature UI state lives in: - React Context (e.g. `UiSettingsContext`) - Custom hooks with module-level singletons (`useNavigator`'s implicit cache) - `let foo = ...` module-level mutable variables — flagged by the React Compiler as "Writing to a variable defined outside a component or hook is not allowed" (currently 5+ violations) - `localStorage` reads in effects There is no single source of truth, no devtools, no time-travel. **Solution.** Adopt **Zustand** for cross-feature UI state. Each feature owns one slice: ```ts // src/state/wired-tools.ts (or src/components/wired-tools/wiredToolsStore.ts) export const useWiredToolsStore = create()((set) => ({ activeTab: 'monitor', setActiveTab: (tab) => set({ activeTab: tab }), // ... })); ``` Components subscribe to **specific keys** (Zustand re-renders only the subscribers whose selected slice changed): ```ts const activeTab = useWiredToolsStore(s => s.activeTab); ``` This eliminates the `let isCreatingRoom = false` module-level pattern and makes the state ispezionable in dev tools. **Status.** Skeleton written (`src/state/createNitroStore.ts`), not yet adopted — `zustand` is not yet installed. Same reason as #2: deliberately a follow-up `yarn add` step. **To enable.** ```sh yarn add zustand ``` Then convert the smallest singleton first (suggestion: the `isCreatingRoom`/`createRoomTimeout` pair in `NavigatorRoomCreatorView.tsx` — it's a clean 5-line conversion). **Do not** wholesale-replace Context. Some Contexts (theming, i18n) are fine as-is. Zustand is for *application* state, not *configuration* state. --- ## Bonus: error boundaries `react-error-boundary` is already a dependency. A widget crashing in a room (e.g. malformed pet data in `InfoStandWidgetFurniView`) currently takes down the whole UI. **Solution.** Wrap each widget root in ``. Implementation lives at `src/common/error-boundary/WidgetErrorBoundary.tsx`. **Status.** Implemented + applied to `RoomWidgetsView` as the umbrella for all in-room widgets, **plus** a per-widget pass that wraps each of the 13 direct children of `RoomWidgetsView` and each of the 20 sub-widgets in `FurnitureWidgetsView`. A crash in any single widget now silently logs through `NitroLogger` and renders `null` for that widget only — its siblings keep rendering. Each boundary carries a `name` prop matching the widget so the log line identifies the culprit. --- ## What's already in place The current branch (**`feat/react19-modernization`**, PR #2) has applied: ### Toolchain - React 19.2 / `react-dom` 19.2 / `@types/react` 19.2. - TS 6 for build + **TS 7 native preview** (`tsgo`) for `yarn typecheck`. - ESLint 10 + `typescript-eslint` 8 + `eslint-plugin-react-hooks@7` + `eslint-plugin-react-compiler`. - Vite 8 + React Compiler 1.0 (`babel-plugin-react-compiler`). - `` mounted; `App.tsx` made idempotent for the double-mount. ### React 19 idioms - **`forwardRef` → `ref` prop** on 7 layout/component files (11 call sites). - **`` → ``** on 6 contexts. - **Native `