Wallow's React apps (apps/wallow-auth, apps/wallow-web) split client state across exactly two
stores with a hard boundary between them. Getting this boundary right keeps the cache
authoritative for anything the API can answer and keeps ephemeral UI state out of the network
layer entirely.
TanStack Query is the backend-data store. It is the single source of truth for anything that
came from — or is derived from — an API response: organizations, members, apps, settings, MFA
status, the current user. Every one of those has exactly one key, and that key is generated:
@bc-solutions-coder/sdk/query emits a {operation}Options() factory, a {operation}QueryKey()
builder, and a {operation}Mutation() factory for every operation in the OpenAPI document, so
every useQuery(organizationsGetAllOptions({ client })) call anywhere in either app resolves to
the same cache entry — there is no per-component duplicate fetch and no way for two screens to
disagree about whether an organization was just archived. Freshness is controlled by staleTime
per query (see The current user query below), and cache invalidation is
explicit: a mutation's onSuccess sweeps the entries its write affects.
Zustand is the UI-only store. It holds state that has no server representation and no
queryKey: sidebar collapsed/expanded, which step a multi-step wizard is on, whether a modal is
open, the active tab. If a piece of state can be re-derived by calling the API again, it does not
belong in a Zustand store — it belongs behind a generated options factory instead.
The repo has exactly one Zustand store today: useNavStore in
packages/navigation/src/nav-store.ts, exported by
@bc-solutions-coder/navigation. import { create } from "zustand"
appears in that one file and nowhere else in packages/*/src or apps/*/src.
Three shared packages stand between an app and these stores, and all three are mandatory routes
rather than conveniences: @bc-solutions-coder/query is where react-query
itself comes from, @bc-solutions-coder/auth owns the one query
that answers "who is signed in", and @bc-solutions-coder/navigation owns the nav store.
useNavStore (packages/navigation/src/nav-store.ts) is the shell's global nav state and the
model for any UI store a fork adds. Three things about it are load-bearing:
- Two independent axes, never derived from each other.
isNavCollapsedis desktop-only — whether the persistent rail is narrowed to icons.isMobileNavOpenis mobile-only — whether the overlay drawer is showing. Collapsing the rail must not close a drawer, and opening the drawer says nothing about how the rail should look when the viewport grows back. - It is a store rather than
useStatebecause of the component tree, not the data. The controls that flip these flags live inAppShell's main column; the rail and drawer that read them are siblings. Neither can pass props to the other. - It is a module-global singleton, so
zustandis a peer dependency of@bc-solutions-coder/navigationand the store is exported from exactly one entry. Two resolved copies would mean two stores and a toggle that silently stops moving the rail — the same hazard class@bc-solutions-coder/queryexists to solve forQueryClient.
Subscribe with a selector so a component only re-renders for the slice it reads:
import { useNavStore } from "@bc-solutions-coder/navigation";
const isNavCollapsed = useNavStore((state) => state.isNavCollapsed);
const toggleNavCollapsed = useNavStore((state) => state.toggleNavCollapsed);@bc-solutions-coder/query (packages/query) is the one place TanStack Query enters this
workspace. It has a single browser-safe entry, ., exporting two things:
- the whole react-query surface, re-exported by reference —
useQuery,useMutation,useQueryClient,QueryClient,QueryClientProvider,queryOptions, everything else. The re-export is a wildcard on purpose: a hand-kept list would lag react-query, and the first missing symbol is where the facade starts eroding. createQueryClient()— the shared client factory every app wires into its router context and its__rootQueryClientProvider. Its policy is the contract:retry: false(no silent backoff, deterministic tests) and a fresh client per call, so one SSR request never shares a cache with another.
Import react-query symbols from @bc-solutions-coder/query, never @tanstack/react-query
directly. That holds for the apps and for every other shared package — forms, auth,
testing all go through the facade too. Only packages/query itself declares the react-query
dependency.
The rule is machine-enforced rather than a convention: the repo-root .oxlintrc.json carries a
no-restricted-imports entry for @tanstack/react-query, so a direct import fails
pnpm lint (and CI) with a message pointing at the facade. The only exemptions are
packages/query/** itself and the handful of packages/sdk files that need react-query's
types to describe the generated artifacts.
Two things go wrong without it. Version drift is the boring one; the sharp one is identity —
two react-query copies in one dependency graph mean two QueryClientProvider contexts, and a
useQuery from copy B mounted inside a provider from copy A throws No QueryClient set at
runtime, in the browser, with a stack that points at neither package.
// Every consumer — apps and shared packages alike.
import { useMutation, useQuery, useQueryClient } from "@bc-solutions-coder/query";The same import from the raw package fails with the rule's own message: "Import it from
@bc-solutions-coder/query instead. The facade owns the pinned version and the shared client
defaults, and keeps one QueryClient context instance across the workspace."
A generated key is a single-segment array holding one object, not a hierarchical tuple:
organizationsGetByIdQueryKey({ client, path: { id } });
// [{ _id: "organizationsGetById", baseUrl: "/api", tags: ["Organizations"], path: { id } }]_id is the operation name, tags are the operation's OpenAPI tags, and the call arguments
(path, query, body, headers) round out the identity. Two consequences follow, and both
drive the rules below:
- There is no key prefix to invalidate by. Nothing is built parent-from-child, so
invalidateQueries({ queryKey: ["orgs"] })matches nothing. Subtree sweeps go through the curatedinvalidationshelpers instead. - The key embeds
baseUrl, not the transport. A server-side instance and a browser instance built with the samebaseUrlemit byte-identical keys, which is what lets an SSR-primed cache hydrate in the browser instead of refetching.createWallowSdk'sinternalOrigindeliberately applies insidefetchonly, so it never reaches the key.
- No inline
queryKeyliterals anywhere. Every key comes from the generated{operation}QueryKey()builder in@bc-solutions-coder/sdk/query— usually indirectly, because{operation}Options()already carries it. WritinguseQuery({ queryKey: ["orgs", id], ... })by hand in an app is not allowed, even for a one-off screen: it silently forks the cache from every other call site that reads the same data. - Invalidate through
invalidations, never by prefix.@bc-solutions-coder/sdk/queryexports two predicates over the flat keys, and they are the only supported sweeps:queriesWithTag(tag)— every cached entry carrying that OpenAPI tag, i.e. everything one backend controller serves.queriesForOperation(exemplarKey)— every cached entry for one operation whatever arguments it was called with. Pass a key built by that operation's{operation}QueryKey(), so no call site depends on the generator's internal_idspelling.
- Bind every call to an explicit client. Generated factories take
{ client }; pass the request-scoped instance fromcreateWallowSdk(...). There is no module-global client to configure and nothing to bootstrap before first use. - Never re-implement a generated artifact. If an operation is missing a factory, the fix is to
regenerate (see TypeScript SDK), not to hand-roll a
useMutationwith its ownmutationFnand cache wiring. - One-time secrets live in component/mutation state, in NEITHER store. MFA enrollment's QR
code and TOTP secret, or a freshly minted OAuth client secret, are shown to the user exactly
once and must never be cached or persisted: they are not backend data with a stable identity
(a second fetch mints a new secret, it doesn't return the old one), and they are not
reusable UI state either. Keep them in local component state (or the resolved value of a
useMutationcall) that unmounts with the screen — seeapps/wallow-auth/src/features/mfa-enroll/ components/MfaEnrollForm.tsx, which threads the enrollment secret and QR URI throughuseStatescoped to the enroll flow, never through Zustand or the query cache.
@bc-solutions-coder/auth (packages/auth) owns the workspace's auth state — who is signed in,
what they may do, and the beforeLoad primer routes gate on. One browser-safe entry, .:
| Export | What it is |
|---|---|
currentUserQuery(client) |
The canonical queryOptions for the signed-in user; resolves null when anonymous. |
useCurrentUser(client) |
useQuery(currentUserQuery(client)) — how a screen reads the user. |
ensureCurrentUser({ queryClient, client }) |
ensureQueryData over the same query, for a route's beforeLoad. |
type CurrentUser |
The API's response plus the sub the SDK's requireAuth guard keys off. |
hasRole, hasPermission, isAdmin |
Membership over CurrentUser.roles / .permissions (isAdmin = hasRole(user, "admin")). Roles are case-insensitive, permissions case-sensitive — both mirror the server. |
requireAuth, loginRedirect |
The SDK's route guards, re-exported by reference so an app's auth imports come from one package. |
No app defines its own current-user query. Before this package existed, wallow-web and
wallow-auth each carried one and they had already drifted (wallow-auth's copy had no staleTime
and no sub). Two definitions of "who is signed in" is what the package exists to end. Neither
is authorization: hasRole/hasPermission gate UI, and the API re-checks every role and
permission on every request.
Nothing here imports a router — useCurrentUser and ensureCurrentUser take the client and the
query client as arguments, which screens read off their router context.
Both apps read the signed-in user through currentUserQuery from @bc-solutions-coder/auth
rather than each maintaining its own auth-state store. It is the generated operation and the
generated key (usersGetCurrentUserQueryKey), so an invalidation raised anywhere in the app
reaches it:
import { currentUserQuery, ensureCurrentUser, useCurrentUser } from "@bc-solutions-coder/auth";
// In a component.
const { data: user } = useCurrentUser(sdk.client);
// In a route's `beforeLoad` — the same query, primed into the cache before the gate runs.
const gateUser = await ensureCurrentUser({ queryClient, client: sdk.client });useCurrentUser is useQuery(currentUserQuery(client)) and ensureCurrentUser is
ensureQueryData over the same options, so all three read one cache entry. Reach for
currentUserQuery yourself — with useQuery from @bc-solutions-coder/query — only when a call
site needs to override an option.
Two behavioural contracts its callers depend on:
- A 401 is the answer "anonymous", not a failure. The SDK's
getCurrentUsersoftens it and the query resolvesnull, which is why a signed-out visitor reaches a route's login gate instead of its error boundary. Only 401 is soft — a 500 still reaches the caller, or a backend outage would sign every real user out. - A 30-second
staleTime. Paired withensureQueryData(notfetchQuery) it is what keeps abeforeLoadrunning on every navigation from re-reading the user on each route change — the cache, not a separate auth store, is what both apps treat as "am I signed in, and as whom."
Pass the request-scoped client (context.sdk.client), never a module-global one: that
instance is what carries the session cookie and the internal origin an SSR render needs, so one
query works on both sides.
There is no step for adding a key or a factory: both are generated. Adding a backend endpoint and regenerating is what makes its query and mutation artifacts exist.
-
Regenerate after the OpenAPI document changes, and commit
openapi/v1.jsontogether withpackages/sdk/src/generated/**. CI fails on drift between the snapshot and a live API. -
Re-export the artifacts you need through the feature's
api.tsseam. Each feature has onesrc/features/<name>/api.ts— a thin re-export over@bc-solutions-coder/sdk/query, listing the generated factories that feature uses plus theinvalidationspredicates. Routes and components import from./api, never from the SDK entry directly, so "api.tsis the only data import" stays true and one file shows a feature's whole data surface:// src/features/organizations/api.ts export { organizationsAddMemberMutation, organizationsGetMembersOptions, organizationsGetMembersQueryKey, queriesForOperation, queriesWithTag, } from "@bc-solutions-coder/sdk/query";
-
Call the generated factory from the component or route loader, binding the request-scoped client. The hooks come from the facade, the factories from the seam:
import { useQuery } from "@bc-solutions-coder/query"; import { organizationsGetMembersOptions } from "../api"; const members = useQuery(organizationsGetMembersOptions({ client: sdk.client, path: { id } }));
-
Write through the matching mutation factory, and sweep what the write invalidated:
import { useMutation, useQueryClient } from "@bc-solutions-coder/query"; import { organizationsAddMemberMutation, organizationsGetMembersQueryKey, queriesForOperation, } from "../api"; const queryClient = useQueryClient(); const addMember = useMutation({ ...organizationsAddMemberMutation({ client: sdk.client }), onSuccess: (): void => { void queryClient.invalidateQueries( queriesForOperation(organizationsGetMembersQueryKey({ client: sdk.client, path: { id } })), ); }, });
Reach for
queriesWithTag("Organizations")instead when a write invalidates a whole feature's worth of reads rather than one operation's.
- Frontend Setup — app bootstrap, shared packages, styling, and testing.
- Component Library — the
@bc-solutions-coder/uicatalog the nav shell composes onto. - Forms — how a form submits through one of those generated mutation factories, and how the failure it returns is split between the field messages and the form-level banner.
- TypeScript SDK — the SDK's four entry points and the BFF
session model the query layer's
queryFns run inside. - Integration Cookbook — the
features/<name>/api.tsseam convention these rules are consumed through, end to end.