Skip to content

State Management

All global state uses Zustand 5. Each store is a single file under ftl-frontend/src/stores/.

Stores Summary

File Export Purpose
auth.ts useAuthStore User identity, wallet, CFD margin, open positions
prices.ts usePriceStore Live instrument price map, equity recompute hook
flags.ts useFlagsStore, useFlag Public feature flags from /api/flags
ws.ts useWsStore WebSocket connection liveness and frame counter
notifications.ts useNotificationsStore Notification list, unread count
referral.ts useReferralStore Captured ?ref= and ?invite= query params (sessionStorage)
tutorial.ts useTutorialStore Onboarding tour step, sandbox positions, XP, badges
instrumentIndex.ts useInstrumentIndex uint16 index → instrument UUID mapping for msgpack WS frames
consent.ts Cookie consent state
theme.ts Theme toggle
tutorialDone.ts Persistent flag: tutorial completed at least once

Auth Store (useAuthStore)

State

Field Type Description
user User \| null Authenticated user from /api/me
loaded boolean True once the bootstrap /api/me call has settled
balance number Cash wallet balance in points
pnl number Total realized P&L
margin number Capital locked in open positions (used margin)
equity number balance + unrealizedPnl — liquidation value
freeMargin number equity - margin
marginLevel number \| null (equity / margin) × 100; null when no open positions (render as ∞)
marginHydrated boolean False until the first explicit freeMargin value arrives from the server
cfdPositions CfdPosition[] Open CFD positions, hydrated at bootstrap and patched by WS frames
tradesEpoch number Monotonic counter bumped after every trade; subscribers re-fetch on change
xp number XP points
level number Level derived from XP
referralCode string User's own referral code

Key Methods

setAuth(user) — Sets the user. If user.id differs from the currently stored user's id, atomically wipes all per-user fields (balance, pnl, cfdPositions, etc.) before populating the new user's data. This prevents data from a previous session leaking into a new one in multi-tab scenarios.

recomputeEquityFromPositions(prices) — Called on every WebSocket price tick. Recomputes equity, freeMargin, marginLevel, and margin from the current cfdPositions and the supplied price map. Mirrors the Lua formula: equity = balance + Σ((livePrice − openPrice) × lotSize × 5 × sign) where CONTRACT_SIZE=5. Skips the set call if no values changed.

syncFromCfdPortfolio(frame) — Applies a portfolio frame published by position_open.lua or position_close.lua. Updates balance, equity, usedMargin, freeMargin, marginLevel in one atomic set.

setCfdPositions(positions) — Replaces the full positions slice; used by bootstrap.

removeCfdPosition(positionId) — Drops one position; called when a WS portfolio frame carries lastEvent='position_close'.

logout() — Calls /api/auth/logout, wipes all local state, then hard-navigates to /.

clear() — Wipes local state without calling the server. Used when the server has already invalidated the session.

Bootstrap Sequence

On App mount, hydrateFromMe() runs:

  1. GET /api/mesetAuth(user), setBalance(balance), setPnl(pnl), setXP(xp, level), setReferralCode(referralCode).
  2. GET /api/portfoliosyncFromPortfolio(balance, totalPnl, positions) for margin/equity.
  3. GET /api/positions?status=opensetCfdPositions(positions), then recomputeEquityFromPositions() against current prices.

Calls 2 and 3 are chained (listPositions fires in the .finally() of getPortfolio), but both run fire-and-forget relative to the outer hydrateFromMe promise. setLoaded(true) fires in a .finally() on hydrateFromMe() — which resolves as soon as GET /api/me settles, before the portfolio and positions calls complete. This means RequireAuth stops rendering null quickly, but the margin/equity values arrive a moment later.

The same hydrateFromMe() runs again on every visibilitychange / focus event while a user is logged in. This catches the case where another browser tab swaps the ftl_access cookie to a different account.

Price Store (usePriceStore)

Holds prices: Record<string, number> — a flat map from instrument UUID to latest price. Updated on every WS binary frame via setPrice(instrumentId, price). After each setPrice, calls the _recomputeEquity callback (registered by App.tsx at mount via setNoiseEquityRecompute) so the wallet balance pill updates in real time without a polling interval.

In dev, the store is exposed as window.__priceStore for debugging.

Flags Store (useFlagsStore)

Fetches GET /api/flags (public endpoint, no auth required). Loaded on App mount and refreshed every 5 minutes when the tab is visible. Also reloaded on tab focus.

useFlag(key, defaultEnabled = true) returns the live value for one key. Defaults to true when the flag is absent — a missing flag never hides existing UI.

WS Store (useWsStore)

Mirrors WebSocket liveness (connected: boolean) and a frameCount counter. useWebSocket.ts is the only producer. Components that want to show a connection indicator read useWsStore(s => s.connected) without instantiating their own socket.

Referral Store (useReferralStore)

Persisted to sessionStorage under the key ftl-referral. Captures ?ref=CODE and ?invite=ID from any URL the user visits before reaching /register. Register.tsx reads the stored code as the initial value for the referral code input. Cleared after successful registration.

Tutorial Store (useTutorialStore)

Not persisted — lives in memory only. A sessionStorage flag (ftl-tutorial-active) marks an in-progress tour so App's bootstrap redirects back to /stadium on page refresh. Holds sandbox positions (mock data, never hits the real API) and a sandboxSquad roster. Cleared completely on finish() so sandbox data never appears in the real YourTeam view. In dev, exposed as window.__tutorialStore.

Instrument Index Store (useInstrumentIndex)

Maps byIdx: Record<number, string> — uint16 instrument index to instrument UUID. Hydrated by GlobalPriceSubscriber after fetching /api/instruments. Required for decoding compact msgpack WS frames, which carry a 2-byte index instead of a 36-character UUID.