State
TanStack Query for anything the BFF owns, Zustand with slim selectors for everything the browser owns.
Server state — TanStack Query
The QueryClient lives in main.tsx:
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5_000,
refetchOnWindowFocus: false,
retry: (count, error) =>
// Only transient failures. A 4xx is a permanent answer.
!(error instanceof ApiError && error.status < 500) && count < 3,
},
},
});Two decisions carry most of the weight:
Retry only transient failures
A 4xx means the request was wrong — retrying produces the same 4xx three more times and delays
the error the user needs to see. Network errors and 5xx retry; ApiError.status < 500 does not.
One error toast per 30s
QueryCache.onError throttles to a single toast per 30-second window. Without it, a backend
outage with eight live queries produces eight toasts, then eight more on every refetch.
Party-scoped keys must include the party. Otherwise switching the active trader leaves positions showing the previous account until something happens to invalidate them.
queryKey: ['positions', party, market] // ✅
queryKey: ['positions', market] // ❌ stale across account switchesClient state — Zustand
One store per axis of state. Never a god store.
| Store | Owns | Persisted |
|---|---|---|
wallet | Exclusive auth: canton | evm | auth0, plus providersReady | no |
session | Engine session public state (status, id, expiry) — no key material | boots from a persisted record |
onboarding | Post-connect username | titan:onboarding:v1 |
active-party | Active trader party (dev/demo) | titan:active-party:v1 |
market | Pairs catalog, engine per-market config, enabledMarkets, tickers | no |
pending-tpsl | TP/SL parked on an unfilled LIMIT order | no — an intent |
preferences | numberFormat, language, hideValues, slippage, orderbookCollapsed, shielded | titan:prefs:v2 |
Theme and density are not in preferences. They live in lib/useUiPrefs.ts under
titan-theme / titan-density, applied as a .dark class and a data-density attribute on
<html> — because they must be applied before React hydrates to avoid a flash.
Selectors, always
const status = useWalletStore(selectStatus); // ✅ re-renders on status only
const everything = useWalletStore(); // ❌ re-renders on every fieldExport typed selectors next to the store — selectStatus, selectAnyConnected,
selectTickerOf(symbol), selectHideValues. Components never read the whole state.
This matters more here than in most apps: the tickers store updates on every WebSocket frame. A component subscribing to the whole store re-renders on every tick of every market it does not display.
Exclusive auth
stores/wallet.ts holds three connection slices, but only one can be live. Connecting one
disconnects the other two; on rehydrate, priority is canton > evm > auth0.
AppKit and Auth0 expose logout only through hooks, so the sync bridges register those functions
into the store (registerEvmLogout, registerAuth0Logout). That lets plain store actions —
disconnectEvm, disconnectActive — drive SDKs that otherwise could not be reached from outside
a component.
Reading the derived truth
const party = useParty(); // canton wallet → session → dev fallback
const market = useCurrentMarket(); // from the URL, not a storeBoth are hooks rather than store fields because both are derived. Storing a derived value is how it goes stale.