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 nine focused providers cover the entire app, and Auth + Theme 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>
    <ToastProvider>
      <CreateEventProvider>
        <LocationProvider>
          <FriendsProvider>
            <MapDockProvider>
              <SheetProvider>
                <PageHeaderProvider>
                  <RootLayoutNav />

The nine 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
FriendsContext contexts/FriendsContext.tsx Friend list, friend-request inbox/outbox, helpers No
LocationContext contexts/LocationContext.tsx Device location, permission state, last-known coords No
CreateEventContext contexts/CreateEventContext.tsx Create/edit-event modal visibility + draft event No
PageHeaderContext contexts/PageHeaderContext.tsx Per-route header config (title, actions, back behavior) No
ToastContext contexts/ToastContext.tsx Toast queue + showToast() No
SheetContext contexts/SheetContext.tsx Bottom-sheet visibility + isNavHidden (hides bottom dock when a sheet covers it) No
MapDockContext contexts/MapDockContext.tsx Map-overlay UI dock state (filters, focused entity) 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).

ToastContext — single toast queue

useToast() returns showToast({ message, kind, duration }). Backed by components/ui/Toast.tsx.

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 AuthContext Current JWT access token
@refresh_token AuthContext Long-lived refresh token
@auth_user AuthContext Cached User object
@admin_mode_unlocked AuthContext Admin role toggle (cleared on logout)
@user_theme ThemeContext Selected theme id
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