State Management¶
Server data lives in TanStack Query; genuinely cross-cutting non-server state (auth, theme, device location, UI coordination) lives in a small set of React Context providers. There is no Redux, Zustand, or MobX. Query owns the read cache, request dedup, background revalidation, and keyed invalidation; contexts own the things a cache can't (the bearer token, the active theme, the WebSocket connection, sheet/permission coordination). See Data layer for the query half and API Client for the transport under it.
The provider stack¶
app/_layout.tsx composes the providers in a fixed order — QueryClientProvider sits outermost so every context and screen can use the cache:
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
<AppWebSocketProvider>
<NotificationsProvider>
<ChatUnreadSync /> {/* renders null: owns WS → cache writes */}
<TaxonomySync />
<FriendsSync />
<ToastProvider>
<LocationProvider>
<SheetProvider>
<PermissionsProvider>
<PageHeaderProvider>
<RootLayoutNav />
AppWebSocketProvider sits high in the tree (just under AuthProvider) because the notification context and the three *Sync mounts below it subscribe to its /ws/client stream. The *Sync components render null — they own the WebSocket subscriptions for a shared query and write its cache; the query itself (useChatUnread / useFriends / useTaxonomy) is consumed directly by any screen with no provider needed. CaptureContext is not in this stack: it is scoped to the (capture)/ route group and provided by (capture)/_layout.tsx.
The contexts¶
| Context | File | Owns | Persisted? |
|---|---|---|---|
AuthContext |
contexts/AuthContext.tsx |
user, token, refreshToken, login/logout/refresh, OTP cooldowns |
Yes — @auth_token, @refresh_token, @auth_user |
ThemeContext |
contexts/ThemeContext.tsx |
Active ThemeDetails, themeId (light / dark / system / named) |
Yes — @user_theme |
AppWebSocketProvider |
contexts/WebSocketContext.tsx |
Single /ws/client connection per signed-in user, typed subscribe(eventType, handler) API for live push (notifications, chat-list staleness, friend-graph deltas, event-detail changes, parsed items). Exponential-backoff reconnect, auto-teardown on sign-out. See Real-time. |
No |
NotificationsContext |
contexts/NotificationsContext.tsx |
Cursor-paged inbox cache + unread count; cold-loaded on auth, mutated in-place from notification:* WS pushes, and reconciles rows whose referenced moment/event no longer exists. Powers the tab badge and the notifications screen. Deliberately kept a context (paginated + WS-mutated + optimistic) rather than a query. |
No |
ChatUnreadContext |
contexts/ChatUnreadContext.tsx |
Query + Sync, not a provider. useChatUnread() is a staleTime: Infinity query for the total unread chat count; ChatUnreadSync owns the WS subscriptions and setQueryDatas the authoritative total from chat:new_message / chat:mark_read. Powers the connect-tab badge. Exports chatUnreadQueryKey. |
No |
TaxonomyContext |
contexts/TaxonomyContext.tsx |
Query + Sync. useTaxonomy() fetches the localized event/place taxonomy from /discovery/taxonomy; TaxonomySync bridges it into the module-level setActiveTaxonomy label lookups (getTagLabel, categoryLabel). Exports taxonomyQueryKey(locale). |
Yes — @taxonomy:v2:<locale> (Query cache is memory-only; the AsyncStorage copy is the offline fallback) |
FriendsContext |
contexts/FriendsContext.tsx |
Query + Sync. useFriends() / close-friends query (staleTime: Infinity, optimistic toggleClose); FriendsSync invalidates on friend:graph_changed and reconciles on WS reconnect. Exports friendsQueryKey / closeFriendsQueryKey. |
No |
LocationContext |
contexts/LocationContext.tsx |
Device location, permission state, last-known coords, 30s foreground presence heartbeat. Kept a context (a heartbeat, not fetched data). | No |
SheetContext |
contexts/SheetContext.tsx |
Bottom-sheet visibility + isNavHidden (hides bottom dock when a sheet covers it) |
No |
ToastContext |
contexts/ToastContext.tsx |
Single-slot toast HUD (showToast / hideToast); a new toast triggers the current one's exit before mounting (newest wins). |
No |
PermissionsContext |
contexts/PermissionsContext.tsx |
Serialized permission-prompt queue + the in-app "open settings" modal; re-checks on return from system Settings. | No |
PageHeaderContext |
contexts/PageHeaderContext.tsx |
Per-route header config (title, actions, back behavior) | No |
CaptureContext |
contexts/CaptureContext.tsx |
In-flight publish: lat/lng, picked place, customLabel, companions[], rating, captured media. Scoped to the (capture)/ route group (provider lives in (capture)/_layout.tsx). Reset on publish or cancel. |
No |
AuthContext — the heavy lifter¶
AuthContext is the only context with side effects beyond storage:
- Login methods:
login,register,loginWithGoogle,loginWithApple,loginWithLine,loginWithPasskey. - Token cache: every login mutator calls
setCachedToken()soutils/tokenManager.tsholds the bearer in memory (avoids AsyncStorage I/O on every request). - Auto-refresh: when
tokenandrefreshTokenare both present,setupTokenRefresh()starts a 4-minute interval that calls/auth/refreshwhen the access token is within 5 minutes of expiry. See API Client. - Session-expired bus: subscribes to
DeviceEventEmitterforAUTH_SESSION_EXPIRED; the HTTP layer emits this on a 401, and the provider clears the session. - Language sync: when
user.languagechanges (server is source of truth), it callsi18n.changeLanguage(). - OTP cooldowns: tracks per-purpose 60-second cooldowns via
useRefso OTP-consuming components can poll withuseOTP(purpose).
ThemeContext — light/dark/system + named themes¶
const savedThemeId = await AsyncStorage.getItem('@user_theme');
// 'light' | 'dark' | 'system' | 'navy' | 'lavender' | ...
When themeId === 'system', the provider derives the active theme from useColorScheme(). All theme objects come from constants/Themes.ts. The provider memoizes the active ThemeDetails and exposes it as theme. See Design System.
FriendsContext — friend graph (query + Sync)¶
useFriends() loads the user's friends and inbound/outbound requests off a shared Query cache (friendsQueryKey, staleTime: Infinity) and exposes mutating helpers with optimistic writes (toggleClose). FriendsSync (rendered null in the stack) owns the friend:graph_changed WS subscription — it invalidates the friends cache on a graph delta and reconciles both caches on a WS reconnect. Backed by services/friendService.ts; the socket, not focus-polling, keeps it fresh.
LocationContext — expo-location wrapper¶
Wraps expo-location permission requests + position polling. Exposes a stable location object so consumers (map, discover, near-you) don't each spin up their own watcher.
PageHeaderContext — declarative headers¶
Routes render useEffect(() => setHeaderConfig({...}), [...]); the root layout renders <PageHeader/> based on the current config. This keeps the header out of <Stack/>'s screenOptions and lets it react to dynamic state (e.g. unread counts).
TaxonomyContext — localized vocabulary cache¶
Fetches the event/place taxonomy from /discovery/taxonomy for the active locale, caches it under @taxonomy:v2:<locale>, and exposes label lookups so components render localized category / tag names from canonical strings. On network failure it keeps the cached copy and falls back to canonicals.
NotificationsContext + ChatUnreadContext — live inbox + badges¶
Both are cold-loaded on auth and then kept live off the /ws/client stream rather than polling. NotificationsContext stays a context (it merges notification:* envelopes into a cursor-paged inbox with three optimistic ops and drops rows whose referenced entity is gone — useInfiniteQuery + setQueryData surgery would add risk for little gain). ChatUnreadContext is a query (useChatUnread) whose ChatUnreadSync trusts the authoritative unread total the backend ships in chat:new_message / chat:mark_read, with staleTime: Infinity so navigation never re-polls a value the socket owns. See Real-time.
SheetContext — bottom-sheet coordinator¶
When a bottom sheet (NavBottomSheet, SnapBottomSheet) takes the screen, it sets isNavHidden = true. The root layout uses isNavHidden to hide the bottom dock so the sheet's controls aren't covered.
Data layer (TanStack Query)¶
Server reads flow through TanStack Query hooks in frontend/hooks/, one use<Thing>.ts per cacheable resource, over the same services/*.ts transport. The client singleton and its defaults live in frontend/lib/queryClient.ts (see API Client → Data layer).
- Query keys live in the domain hook, not a central factory. Each hook exports a
<thing>QueryKeytuple factory next to it (momentQueryKey,planDetailQueryKey,eventDetailQueryKey,allPlansQueryKey,userProfileQueryKey, …). Invalidation sites import the name from the domain so a grep finds every writer. - A context with live updates becomes a query + a
*Syncmount. The three converted so far areChatUnread,Friends,Taxonomy(each auseX()query the screens consume, plus anXSyncthat owns the WS subscriptions and writes the cache). A WS-hydrated query carries the authoritative value (setQueryData), setsstaleTime: Infinity+refetchOnMount: false, and reconciles only on a WS-reconnect gap — it never auto-refetches a value the socket owns. - Screen-focus refetch is opt-in via
hooks/useRefetchOnFocus.ts(React Native has no window-focus event, sorefetchOnWindowFocusis off globally). - Errors surface through
components/ui/AppErrorBoundary.tsx(QueryErrorResetBoundary+ a themed, Sentry-reported Retry) wrapping the screen tree. - The two big detail screens (
plans/[id],events/[id]) decompose into per-resource query hooks (hooks/usePlanDetail.ts,hooks/useEventDetail.ts) plus feature components that each own their local state anduseMutations.
AsyncStorage keys¶
| Key | Owner | Purpose |
|---|---|---|
@auth_token |
utils/tokenStore.ts |
Current JWT access token (read/written only via the token-store seam) |
@refresh_token |
utils/tokenStore.ts |
Long-lived refresh token (read/written only via the token-store seam) |
@auth_user |
AuthContext |
Cached User object |
@admin_mode_unlocked |
AuthContext |
Admin role toggle (cleared on logout) |
@user_theme |
ThemeContext |
Selected theme id |
@taxonomy:v2:<locale> |
TaxonomyContext |
Cached localized taxonomy payload per locale |
device_id |
AuthContext (web fallback) |
Synthetic device fingerprint when react-native-device-info is unavailable |
Various services/storageService.ts keys |
service layer | Cached chat rooms, messages, link previews |
Why TanStack Query for reads, Context for the rest?¶
- Server cache is a real problem now. As the app grew, screens hand-rolled
useState+useEffect+ loading/error/cancel over the services, and a web of WebSocket handlers manually refetched them. Query replaces that with a shared cache, request dedup, background revalidation, and keyed invalidation — the socket handlersinvalidateQueries/setQueryDatainstead of chasing every consumer. - Context keeps what a cache can't model. The bearer token, the active theme, the single WebSocket connection, device location, and UI coordination (sheets, permission prompts, page header) are not fetched data; they stay providers.
- No Redux / Zustand / MobX. Global mutable app state is small; between Query (server data) and a handful of contexts (cross-cutting non-server state), there is nothing left for a store library to own.
Some surfaces deliberately stay hand-rolled contexts because they fit Query poorly — the paginated, WS-mutated notifications inbox; the location heartbeat; the capture wizard draft; the realtime chat message stream.
Next¶
- API Client — how services interact with the providers
- Real-time — the chat WebSocket layer
- Design System — how
ThemeContextflows into UI