Skip to content

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() so utils/tokenManager.ts holds the bearer in memory (avoids AsyncStorage I/O on every request).
  • Auto-refresh: when token and refreshToken are both present, setupTokenRefresh() starts a 4-minute interval that calls /auth/refresh when the access token is within 5 minutes of expiry. See API Client.
  • Session-expired bus: subscribes to DeviceEventEmitter for AUTH_SESSION_EXPIRED; the HTTP layer emits this on a 401, and the provider clears the session.
  • Language sync: when user.language changes (server is source of truth), it calls i18n.changeLanguage().
  • OTP cooldowns: tracks per-purpose 60-second cooldowns via useRef so OTP-consuming components can poll with useOTP(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.

LocationContextexpo-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>QueryKey tuple 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 *Sync mount. The three converted so far are ChatUnread, Friends, Taxonomy (each a useX() query the screens consume, plus an XSync that owns the WS subscriptions and writes the cache). A WS-hydrated query carries the authoritative value (setQueryData), sets staleTime: 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, so refetchOnWindowFocus is 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 and useMutations.

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 handlers invalidateQueries/setQueryData instead 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