Frontend state decisions (UI)#
Decisions behind how the React app holds state. Primary code: src/index.tsx,
eslint.config.mjs, src/hooks/useAsyncResource.ts.
UI-001 — The app renders under StrictMode#
Applies to: src/index.tsx
<StrictMode> wraps the root render, so development double-invokes renders and
effects.
Why: it was removed once because components failed under it. Those failures
were the bugs it exists to surface — impure renders and state written
synchronously from effects — not a problem with the mechanism, and removing it
left them in place while the app still paid for React’s development build. The
one component already hardened for it, AuthProvider, carries a comment
explaining the race it would otherwise lose, which is the shape of what the
rest of the app was hiding.
Cost: development renders roughly double. Production is unaffected.
Don’t: take it off to quiet a component that misbehaves under it. A component that breaks when its effect runs twice breaks in production too, just less often — on a remount, a fast navigation, or a replayed render.
UI-002 — set-state-in-effect is an error#
Applies to: eslint.config.mjs
@eslint-react/set-state-in-effect is 'error', not the plugin’s default
warning.
Why: it catches statically what UI-001 catches at runtime. It stood at 133 warnings across 31 files while StrictMode was off, which is how a whole class of “the effect will fix it on the next render” state accumulated unnoticed. As a warning nothing failed on it, so the count only ever went up.
Don’t: suppress a finding with a disable comment. Every one of the 133 had
a derivation, a lazy initialiser, a key, or a callback ref behind it — see
UI-003.
UI-003 — State that follows an input is derived from it, not copied into state#
Applies to: the whole of src/; see src/hooks/useAsyncResource.ts
Where a value follows something the component already has — a prop, a route parameter, a fetched result — it is computed during render, not written into state by an effect watching that input. The recurring shapes are:
A request key.
loadingmeans “the settled result is not an answer to the request this render is asking for”, so nothing has to raise it on the way into an effect and a superseded request cannot leave it raised. This is whatuseAsyncResourcepackages, and whatuseWorkflowRunsalready did by hand.A draft with the value it was typed against. A text buffer is shown while it is still a draft of what the parent holds; a parent that sets something else supersedes it by being read past.
A
key. A dialog form is mounted per open and keyed on the thing being edited, so its starting state is computed from that thing.A callback ref. A measurement is taken when the node is attached, which is the same point in the commit a layout effect runs at.
Why: copying an input into state makes two sources of truth that a render
sits between. The first render after the input changes shows the previous
value, and under StrictMode the effect that corrects it runs twice. The
concrete failure this produced: ChatInterface mirrored the route’s thread id
into state and reconciled it from an effect, so back/forward and a freshly
admitted turn could each win; QueryConsole guarded its URL-restore effect with
a ref it cleared on entry, which a double-invoked effect defeats by design.
Don’t: reintroduce a useEffect whose body is setX(somethingDerivable).
If the value genuinely cannot be derived — it needs the clock, or a DOM
measurement — take it where it happens (a state updater, a ref callback) rather
than in an effect.
UI-004 — The browser tab is named by PageTitle, not a head-management library#
Applies to: src/components/PageTitle.tsx
All 26 call sites used react-helmet for a <title> and nothing else — no
meta, link or script. It is replaced by a component that sets
document.title from an effect and restores what index.html declared when
the last one unmounts.
Why: react-helmet is built on react-side-effect, which registers in
UNSAFE_componentWillMount. Under UI-001
that logs Using UNSAFE_componentWillMount in strict mode is not recommended … SideEffect(NullComponent) on every page. The package has not shipped since
2020, so it will not be fixed, and swapping to a maintained fork would have
kept a dependency whose entire remaining job is one assignment to
document.title.
Restoring on unmount is load-bearing, not tidiness: around a dozen routes (the query console, chat, toolsets, roles, …) set no title at all, so without it the tab would keep the previous page’s title after navigating to one of them.
Exactly one may be mounted at a time, which is why ReportView takes a
documentTitle prop instead of a page rendering a second one beside it. Helmet
resolved two claims by “innermost wins”, an order it got from registering
during render; effects run child-first, so the naive replacement silently
inverts that. Rather than depend on an ordering React does not promise, the
component warns in development when a second one mounts, and the two call sites
that nested (SpaceDetail, ReportVersionView) each name one owner. Fixing
those also revived ReportVersionView’s version-qualified title, which the
nested ReportView had been overwriting since it was written.
Don’t: reintroduce a head library for a title, or render a PageTitle
inside a component that a page may also title.
UI-005 — The query console’s run is state, and the address bar is an event#
Applies to: src/pages/QueryConsole.tsx
What the console is running — a typed query or a stored history entry — is
explicit state changed only by what the user did. location is deliberately
absent from the render-time derivation. A completed query publishes its
?h=<id> and records the id as one of its own; the address bar re-enters the
console only through a single effect, which adopts a ?h= the page did not
publish and does so once per URL.
Why: deriving the run from the URL means re-deriving it while the page’s own
navigate is still settling. navigate reaches the router through its history
listener, a commit after the state set beside it, and in that window the URL and
the run disagree — which read as “the user asked for a history entry”, ran it,
published a new URL, and raced again. Measured in the dev database: one
schema-panel query re-executed 222 times, once every ~2.5 seconds — the
query’s own duration, because each run was triggered by the previous one
finishing. It never settled, so nothing was ever displayed.
Why an effect here, against UI-002:
the history stack is an external system, not a value this render can compute,
which is the case UI-003
leaves to an effect. It carries the only set-state-in-effect disable in the
codebase, and it is narrow: one URL, adopted once.
Both guards are refs that are never cleared on read — the set of ids this
page published, and the last URL the restore acted on. That is the difference
from the justPushedRef this replaces, which cleared its flag as it read it and
so was defeated by StrictMode’s second pass (UI-001).
Don’t: compare the URL against the last one the page navigated to. It is only eventually equal, and the render in between is the bug.
UI-006 — A feature configures itself at the foot of its own panel#
Applies to: src/components/ChatSessionsPanel.tsx,
src/components/SpaceReportsPanel.tsx, src/components/DashboardSidebar.tsx
Settings that belong to a whole feature sit in a bordered footer group at the bottom of that feature’s panel — a space’s sub-space and report actions, chat’s per-user gateway connections. The main sidebar names product areas and the reports pinned to them; it is not where a feature’s own configuration goes.
Why: “Chat Connections” as a top-level entry read as a peer of Chat, Spaces and Workflows, and it is not one — it configures chat, and the page it opens already carries a Back to Chat button. Reaching it meant leaving the conversation through navigation that never mentioned the conversation. The space panel had already settled the shape, so chat’s version is the same one rather than a second idea.
The other axis is the turn, and it is not this one. The confirmations pane is about the turn on screen, so it stays beside the transcript; the panel footer is for what outlives any single conversation. A new chat surface belongs to whichever of those it is about.
Collapsed panels keep the entry, as an icon with its tooltip: the footer is how the setting is reached at all now, so hiding it behind reopening the panel would make it harder to find than the sidebar entry it replaced.
A page opened from a conversation carries it back (src/utils/chatPaths.ts):
the link names the thread in its query string and the page’s back control
returns there, falling back to the landing only when it was reached some other
way. In the URL rather than in router state, because connections is where
someone follows a gateway’s authorization link and comes back — a reload must
not cost them the way back to what they were asking. /app/chat is deliberately
the landing rather than a resumed conversation, so a back link that simply
pointed at it could not be the way home.