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