Skip to content

State Management

Tomoda uses React Context only — no Redux, no Zustand, no React Query, no MobX. The state surface is small enough that a set of focused providers covers the entire app; Auth, Theme, and Taxonomy are the only ones that touch persistent storage.

The provider stack

app/_layout.tsx composes every context provider in a fixed order. Outer providers can be consumed by inner ones:

<ThemeProvider>
  <AuthProvider>
    <AppWebSocketProvider>
      <NotificationsProvider>
        <ChatUnreadProvider>
          <LinkedAccountProvider>
            <TaxonomyProvider>
              <PartnerProvider>
                <PillProvider>
                  <CreateEventProvider>
                    <LocationProvider>
                      <FriendsProvider>
                        <MapDockProvider>
                          <SheetProvider>
                            <PermissionsProvider>
                              <PageHeaderProvider>
                                <RootLayoutNav />

AppWebSocketProvider sits high in the tree (just under AuthProvider) because the notification, chat-unread, and friends contexts below it all subscribe to its /ws/client stream. 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. No
ChatUnreadContext contexts/ChatUnreadContext.tsx Total unread chat count (excluding muted rooms). Live-updated from chat:new_message / chat:mark_read WS events; refresh() is the fallback. Powers the connect-tab badge. No
LinkedAccountContext contexts/LinkedAccountContext.tsx Linked-account list and the link / switch / unlink handlers. Lets tomoda and partner accounts hop into a linked standard consumer identity (separate JWT, re-auth on switch). See Authentication architecture → Linked accounts. No
TaxonomyContext contexts/TaxonomyContext.tsx Localized event/place taxonomy vocabulary + label lookups (getTagLabel, categoryLabel); fetched from /discovery/taxonomy. Yes — @taxonomy:v2:<locale>
PartnerContext contexts/PartnerContext.tsx List of partners the caller belongs to + membership lookup helpers. Powers the (partner) shell gate and per-partner role checks. No
PillContext contexts/PillContext.tsx Single-slot inline pill/banner queue (showPill / hidePill); a new pill exits the current one before promoting. No
CreateEventContext contexts/CreateEventContext.tsx Create/edit-event modal visibility + draft event No
LocationContext contexts/LocationContext.tsx Device location, permission state, last-known coords No
FriendsContext contexts/FriendsContext.tsx Friend list, friend-request inbox/outbox, helpers; invalidated by friend:graph_changed WS pushes No
MapDockContext contexts/MapDockContext.tsx Map-overlay UI dock state (filters, focused entity) No
SheetContext contexts/SheetContext.tsx Bottom-sheet visibility + isNavHidden (hides bottom dock when a sheet covers it) 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

Loads the user's friends and inbound/outbound requests once on auth, then exposes mutating helpers (addFriend, acceptRequest, etc.). Backed by services/friendService.ts. Held in memory only; the friends list re-fetches on screen focus where needed.

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.

CreateEventContext — global "create" modal

The + button anywhere in the app calls openCreateModal(); the root layout renders the modal once at the top of the tree so it covers every route. Closing on route change is wired in RootLayoutNav via a prevPathnameRef.

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 merges notification:* envelopes into a cursor-paged inbox (and drops rows whose referenced entity is gone), while ChatUnreadContext trusts the authoritative unread total the backend ships in chat:new_message / chat:mark_read. 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.

MapDockContext — map overlay state

Owns dock visibility and the currently-focused map entity (event, user, cluster). Shared between the map view, filter chips, and detail sheets.

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 no Redux / Zustand / React Query?

  • Small global surface. Only auth, theme, friends, location, and a handful of UI flags are truly cross-cutting. Everything else is route-local state.
  • Server cache concerns are simple. No optimistic mutations or invalidation graphs that would need React Query.
  • Bundle size matters. Skipping a state lib saves both JS and mental overhead.

If a future feature needs more cross-cutting structured state (e.g. notification queues, offline mutations), the migration path is to introduce a single new context — not to onboard a library platform-wide.

Next