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:
GET /api/me→setAuth(user),setBalance(balance),setPnl(pnl),setXP(xp, level),setReferralCode(referralCode).GET /api/portfolio→syncFromPortfolio(balance, totalPnl, positions)for margin/equity.GET /api/positions?status=open→setCfdPositions(positions), thenrecomputeEquityFromPositions()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.