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()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¶
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.
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.
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¶
- API Client — how services interact with the providers
- Real-time — the chat WebSocket layer
- Design System — how
ThemeContextflows into UI