Titandocs

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 switches

Client state — Zustand

One store per axis of state. Never a god store.

StoreOwnsPersisted
walletExclusive auth: canton | evm | auth0, plus providersReadyno
sessionEngine session public state (status, id, expiry) — no key materialboots from a persisted record
onboardingPost-connect usernametitan:onboarding:v1
active-partyActive trader party (dev/demo)titan:active-party:v1
marketPairs catalog, engine per-market config, enabledMarkets, tickersno
pending-tpslTP/SL parked on an unfilled LIMIT orderno — an intent
preferencesnumberFormat, language, hideValues, slippage, orderbookCollapsed, shieldedtitan: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 field

Export 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 store

Both are hooks rather than store fields because both are derived. Storing a derived value is how it goes stale.

On this page