Client State
React useState patterns for UI state, custom hooks, and the planned migration path to Zustand for global client state.
Client state in Trovella is currently managed entirely with React's built-in hooks: useState, useCallback, useMemo, and useRef. Zustand is accepted in the tech stack but has not been installed because no feature currently requires cross-component global state.
Current Patterns
Component-Local State
The most common pattern is useState for UI concerns that live and die with a single component:
// Pagination offset
const [offset, setOffset] = useState(0);
// Selected row in a table
const [selectedId, setSelectedId] = useState<string | null>(null);
// Dialog open/close
const [open, setOpen] = useState(false);
// Filter values
const [filters, setFilters] = useState<{
feature: string | undefined;
model: string | undefined;
stopReason: string | undefined;
}>({
feature: undefined,
model: undefined,
stopReason: undefined,
});
These values drive what gets passed to tRPC queries as input. When state changes, TanStack Query automatically refetches with the new input. See Server State for how this interaction works.
Feature-Scoped Custom Hooks
When a feature has enough state to become unwieldy in a single component, it is extracted into a custom hook. The AI playground demonstrates this pattern with two hooks:
usePlaygroundSettings manages 25+ form fields as individual useState calls and returns them as a stable useMemo object:
export function usePlaygroundSettings(): PlaygroundSettings {
const [message, setMessage] = useState("");
const [model, setModel] = useState<Model>("claude-sonnet-4-6");
const [thinking, setThinking] = useState(true);
// ... 20+ more fields
return useMemo(
() => ({
message,
setMessage,
model,
setModel,
thinking,
setThinking,
// ...
}),
[message, model, thinking /* ... */],
);
}
usePlaygroundStream manages the streaming AI call lifecycle -- it takes settings as input and returns stream state plus control handlers:
export function usePlaygroundStream(settings: PlaygroundSettings): UsePlaygroundStreamReturn {
const [streaming, setStreaming] = useState(false);
const [streamText, setStreamText] = useState("");
const abortRef = useRef<AbortController | null>(null);
const handleSubmit = useCallback(() => {
// Reset state, build request, start stream
}, [settings, streaming]);
const handleCancel = useCallback(() => {
abortRef.current?.abort();
setStreaming(false);
}, []);
return { stream: { streaming, streamText /* ... */ }, handleSubmit, handleCancel };
}
This separation keeps the settings panel and the stream display as independent concerns while the parent component composes them.
Utility Hooks
apps/web/src/hooks/use-mobile.ts provides a useIsMobile hook that listens to a media query:
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(false);
const [hasMounted, setHasMounted] = React.useState(false);
React.useEffect(() => {
setHasMounted(true);
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
// ...
}, []);
if (!hasMounted) return false;
return isMobile;
}
Note the hasMounted guard: it returns false during SSR and the first client render to prevent hydration mismatches. This is a common pattern when React state depends on browser APIs.
When to Add Zustand
Zustand will be added when any of these triggers are hit:
- Cross-subtree state -- two component subtrees that are not parent-child need to share UI state (e.g., a sidebar selection affecting a main content area on a different branch of the tree).
- State that outlives navigation -- client state that must persist when navigating between routes but does not belong in the URL (e.g., a draft that is not yet saved).
- Complex derived state -- when multiple pieces of state need to be combined with selectors, and prop drilling becomes unmaintainable.
Until one of these triggers is hit, React useState is simpler, has zero bundle cost, and is easier for AI agents to generate correctly.
Planned Zustand Conventions
When Zustand is introduced, these conventions will apply (from ADR-005):
- One store per feature domain, not one global store
- Stores live in the feature's directory (e.g.,
src/features/research/research-store.ts) - Use the
createfunction fromzustand, notcreateStore - Use
useShallowfor selector-based subscriptions to prevent unnecessary re-renders - Persist middleware only where explicitly needed (e.g., draft state)
- No Zustand for server data -- that stays in TanStack Query
When to Add nuqs
nuqs will be added when any of these triggers are hit:
- Shareable filters -- a list view with filters that should be bookmarkable or shareable via URL (e.g.,
/admin/ai-logs?feature=research&model=sonnet). - Back button expectations -- UI state where users expect the browser back button to undo a change (e.g., navigating between tabs or search results).
Currently, filter and pagination state is local to components and resets on navigation. This is acceptable for admin dashboards but will not be sufficient for user-facing search and discovery features.
Conventions
- Prefer
useStateover external state libraries for component-local concerns. Do not add Zustand for state that only one component reads. - Extract custom hooks at the feature level when a component accumulates more than 5-6 state variables. Name them
use<Feature><Concern>(e.g.,usePlaygroundSettings,usePlaygroundStream). - Use
useMemofor stable references when passing state objects to child components or hooks. This prevents unnecessary re-renders in children that depend on referential equality. - Use
useReffor mutable values that should not trigger re-renders -- abort controllers, timers, previous values. - Guard browser-only state with a mount check to prevent SSR hydration mismatches. See the
useIsMobilepattern above.
Related
- Server State -- how client state drives tRPC query inputs
- Form Handling -- controlled inputs as a specific case of client state
- ADR-005: Framework Decision -- rationale for the five-library state management stack