Architecture¶
This page orients an engineer opening frontend/ for the first time. It maps the
product to the code, shows how one request flows through the layers, and points at
the exact files to read next.
What the app is, in code terms¶
Tomoda's frontend is a single Expo Router codebase (React Native + React 19) that ships three targets from one tree: an iOS binary, an Android binary, and a static web bundle. The signed-in experience is a consumer app around a few product verticals, each with a stable home in the tree:
| Vertical | What the user does | Route home | Feature components | Service(s) |
|---|---|---|---|---|
| Discovery / map | Browse a map + feed of nearby places, events, friends | app/(tomoda)/discover/*, place-map.tsx |
discover/, map/, location/ |
discoveryService, locationService, searchService |
| Plan | Save ideas, build plans, promote a plan to an event | app/(tomoda)/plan.tsx, app/(social)/plans/* |
planner/ |
planService, itemService |
| Events | View/host/join an event, check in | app/(social)/events/[id].tsx, hub/horizon/* |
events/ |
eventService, eventCheckinService |
| Chat | 1:1 and group messaging | app/(tomoda)/connect/* |
chat/ |
chatService |
| Capture | Take a photo, check in, post a moment | app/(capture)/* (native only) |
capture/, composer/ |
checkinService, presignedUpload |
| Moments / feed | Post + browse ephemeral moments | discover/feed.tsx, app/(social)/m/[id].tsx |
feed/, moments/ |
momentService |
| Profile / hub | Settings, profile, security, friends | app/(tomoda)/hub/* |
profile/, settings/ |
userService, friendService |
| Auth | Sign in, register, complete profile | app/auth/* |
auth/ |
authService |
| Partner / internal | Partner ops + Tomoda-team admin | app/(partner)/*, app/(internal)/* |
partner/ |
partnerService, adminService |
Counts, for scale: 46 route screens, 13 contexts, 24 services, 43 hooks, 54
components/ui/ primitives, 4 locale bundles.
The one rule¶
The frontend is strictly layered, and one rule keeps the layers honest:
A route never calls
fetch. A context never calls rawfetch. A service never imports React.
Reads flow down through a TanStack Query hook over a service; cross-cutting
non-server state lives in a context; the /ws/client socket writes the same Query
cache so a screen never re-polls a value the socket owns.
First 30 minutes: a reading path¶
Read these files, in order, to see the whole spine end to end:
app/_layout.tsx— the root: provider nesting, the auth gate, font/Sentry boot.contexts/AuthContext.tsx— who the user is; howisAuthenticated/claimsdrive routing.utils/apiFetch.ts+utils/api.ts— the transport floor every service sits on.hooks/useEventDetail.ts— the canonical per-resource Query hook (key factory +invalidate…Querieshelper + a live-updates subscription).services/eventService.ts— a transport-only service with its enum unions.components/ui/Page.tsx+components/layout/ScreenContainer.tsx— the page shell every screen composes.app/(tomoda)/hub/friends/index.tsx— a normal screen showing all of the above composed together.
Entry + routing¶
app/_layout.tsx is the root. It boots Reanimated (must be the first import),
initializes Sentry at module load, installs a Hermes unhandled-rejection swallower
for SessionExpiredError, and eagerly loads the Bricolage Grotesque + icon fonts
before first paint. Layout() builds the provider tree; RootLayoutNav is the
auth-gated router.
Auth gating has two layers:
Stack.Protectedguards do the broad split by readingaccount_typeoff the JWT claims:authis shown only when unauthenticated or the profile is incomplete;(tomoda)+(capture)+place-maprequireaccount_type ∈ {standard, synthetic};(partner)requirespartner;(internal)requirestomoda;index(marketing landing) and(social)are always available so unauthenticated deep links and OG previews render.- Three imperative redirects cover transitions the guards can't: authed but
incomplete profile →
/auth/complete-profile; a deep URL the guard rewrote to/→ replay the capturedinitialPathRef; landing on the marketing root or an auth screen while signed in →defaultLandingFor(accountType)(/discoverstandard,/dashboardpartner,/managertomoda).
Route groups:
| Group | Nav | Purpose |
|---|---|---|
(tomoda) |
hidden Tabs, chrome via AppNav |
Consumer shell: discover, plan, search, connect, notifications, hub |
(capture) |
Stack under CaptureProvider; web Redirect→/discover |
Native-only capture wizard (camera → checkin/compose) |
(social) |
no layout, file-system routes | Shareable/unauthed deep links: events/[id], u/[handle], m/[id], plans/[id], plans/join/[token] |
(partner) |
hidden Tabs, redirect-guarded |
Partner ops shell |
(internal) |
hidden Tabs, redirect-guarded |
Tomoda-team admin |
auth |
Stack, cross-fade |
login / register / forgot-password / complete-profile |
Navigation chrome is data-driven from one file, components/layout/navConfig.ts
(STANDARD_NAV / PARTNER_NAV / TOMODA_NAV, selected by account type).
AppNav picks MobileNav vs SidebarNav; the dock stays mounted across immersive
routes and slides away via a hidden flag rather than remounting. Full detail:
Routing.
Provider nesting¶
The exact order in app/_layout.tsx (each provider depends on those above it):
State layer: contexts¶
contexts/ holds the non-server state every part of the app shares. Five are
depended on by essentially every screen (★).
| Context | Owns | Key hook |
|---|---|---|
| AuthContext ★ | user, claims, token, isAuthenticated, isLoading; login/logout/refresh; the token-refresh timer |
useAuth(), useOTP(purpose) |
| ThemeContext ★ | active theme + id, persistence | useTheme() |
| ToastContext ★ | transient toast queue | useToast() |
| PageHeaderContext ★ | the current header config for the global header | useSetPageHeader(config) |
| WebSocketContext ★ | the single /ws/client socket, typed fanout, reconnect |
useWsSubscription(), useWsConnected() |
| NotificationsContext | unread count + list, WS-driven | useNotifications() |
| ChatUnreadContext | total chat unread badge | useChatUnread() |
| FriendsContext | friend graph + close-friends set | useFriends() |
| TaxonomyContext | localized category/tag labels | useTaxonomy(), useCategoryLabel() |
| LocationContext | device location + sharing state | useLocationContext() |
| PermissionsContext | camera / media / location permission state | usePermissions() |
| CaptureContext | in-flight capture draft (mounted only in (capture)) |
useCapture() |
| SheetContext | nav-hide toggle + sheet portal host | useSheet() |
contexts/authTypes.ts is a leaf holding the identity enums (AccountType,
UserRole, Visibility, Gender, User), re-exported through AuthContext so
consumers import them from the context. More: State Management.
Data layer: TanStack Query¶
Server data is not held in contexts. Each cacheable resource has one use<Thing>()
hook in hooks/, co-located with a <thing>QueryKey factory (no central key file,
so a grep finds every writer) and often an invalidate<Thing>Queries(qc, id) helper.
- The pattern.
hooks/useEventDetail.tsexports three key factories (publicEventQueryKey/eventDetailQueryKey/eventParticipantsQueryKey) plusinvalidateEventQueries(qc, eventId)that invalidates all three as a set, so the refresh / live-update / edit-save paths can't drift apart.hooks/useStashItems.tsis the cursor-paginateduseInfiniteQueryshape. Copy one of these for a new read. - WebSocket writes the cache, two ways. Invalidate for query-backed data:
useEventLiveUpdatessubscribes toevent:updatedand callsinvalidateEventQueriesso a host's edit applies live. Direct setState for local-state-backed streams:useInboxChatsmutates its list onchat:new_message/chat:mark_read(bump unread, reorder to top). - Transport floor.
utils/apiFetch.tsexposesapiFetch(path, init)— it prependsAPI_URLto any relative path, injects the bearer, and on a non-/auth/*401 runs a single-flight refresh + one retry — andapiJson<T>(path, init), which isapiFetch+handleResponsein one and is what nearly every service method calls.utils/api.tsownshandleResponse(error parsing, a Sentry HTTP breadcrumb,SessionExpiredErrorfor dead-session 401s).utils/tokenManager.tscaches the bearer in memory and runs the proactive refresh timer;utils/tokenStore.tspersists tokens (AsyncStorage as source of truth, mirrored to the OS Keychain so the share extension can post without opening the app). More: API Client.
Services¶
services/ holds 24 transport-only files, one per backend domain, none importing
React. Each owns its request/response shapes and its enum unions, declared as
string-literal types next to the interface that uses them (e.g. EventStatus,
EventParticipantStatus above interface Event in eventService.ts, with a
comment tying them to the Go model to update in lockstep). Cross-service shared
unions live at their canonical owner (visibility.ts exports EventVisibility;
identity enums live in authTypes.ts).
UI system¶
One page shell, composed from three pieces:
useSetPageHeader(config)sets the global header config on focus (drives the desktop/global header;screenOwnsHeaderlets a screen own its own mobile header).components/layout/ScreenContainer.tsx— transparent wrapper, centers content atmaxWidth: 900on desktop, full-bleed on mobile.components/ui/Page.tsx— the collapsible-on-mobile / static-on-desktop chrome, exposed as a render prop so the screen owns its scroll view:<Page title back>{scrollProps => <AnimatedFlashList {...scrollProps} />}</Page>.
The 54 components/ui/ primitives are the shared vocabulary: rows (UserRow,
PlaceRow, SettingsRow, InfoRow), cards (EventCard, AccentCard), chips
(Chip, IconChip, MetaChip, TagChipRow), avatars (UserAvatar,
AvatarStack), controls (Button, IconButton, SearchField, Input,
VisibilityPicker), state (EmptyState, Loader, Skeleton, Toast), and
surfaces (Sheet, ConfirmDialog, Lightbox). Feature folders import ui/, never
each other.
Styling is a makeStyles(theme) factory in a co-located *.styles.ts,
consumed via useThemedStyles(makeStyles) (a memoized factory(theme)). Hot-list
primitives take theme as a prop instead of re-reading context. Tokens live in
constants/ (Themes.ts, Typography.ts, spacing.ts, radius.ts).
Sheets wrap TrueSheet in components/ui/Sheet.tsx. Consumers own the body via
SheetView (auto height) or SheetScrollView (fixed-height scroll region); nested
pickers are sub-views of one sheet (swap body + back chevron), never a second
stacked sheet. See Components.
Keyboard handling is one pattern, on react-native-keyboard-controller:
KeyboardProvider wraps the app once (app/_layout.tsx); hooks/useKeyboard.ts
(useKeyboard / useKeyboardVisible) is the only keyboard-state source;
components/ui/KeyboardAwareScroll.tsx is the scroll container for normal screens
(web falls back to Animated.ScrollView); docked bars that ride the keyboard use the
library's KeyboardStickyView. The one rule: sheets keep TrueSheet's native
keyboard handling, so never nest a keyboard-controller view inside a TrueSheet.
Android verification is deferred to Keyboard Android Handoff.
Realtime¶
AppWebSocketProvider opens one /ws/client?token=<jwt> socket. A typed
AppWsEventType union enumerates the push types (notifications, chat:new_message,
chat:mark_read, friend:graph_changed, event:updated, item.parsed), kept in
lockstep with the backend dispatcher. Handlers live in a Map<type, Set<handler>>;
dispatch isolates each handler's exceptions so one bad subscriber can't break the
fanout. It reconnects with exponential backoff and force-reconnects on AppState
'active' (mobile OSes freeze the socket while backgrounded). Screens subscribe with
useWsSubscription<T>(type, handler). The chat surface additionally runs a per-room
session socket via chatService. More: Real-time.
One tree, three targets¶
Metro resolves .native.tsx / .web.tsx per platform; a bare .tsx is the shared
type surface. Most platform divergence is inline Platform.OS checks; the two large
file-suffix splits are:
- Map —
MapView.tsxis the shared prop contract (theMapMarkerunion,MapViewProps).MapView.web.tsxrenders theMapEngineReact tree with HTML overlays;MapView.native.tsxuses the@maplibre/maplibre-react-nativeSDK. Detail sheets (map/details/) are shared. See Maps. - Camera —
TomodaCamera.native.tsxis the real camera; web is a stub (and(capture)redirects to/discoveron web anyway).
Cross-cutting invariants¶
Violating one of these is a review-blocker:
- Typed values, never raw strings. Finite value-sets are string-literal unions
next to their interface;
tscrunsstrict: true; noanyin service signatures, noasto widen an enum. - No raw
fetchabove the service layer. Reads go through a Query hook, writes through a service; every response funnels throughhandleResponse. - Every user-facing string is translated through
react-i18nextwith a real key in all four locale bundles.defaultValueont()is banned (it ships English to non-English users). - Circular-dep-free. Types that would close a cycle live in leaf modules
(
authTypes.ts,visibility.ts).madgereports zero cycles. - God components are decomposed into a thin container + co-located pieces + a
use<Thing>hook (plan.tsx→usePlanTabActions+planner/*;discover/map.tsx→useMapMarkers/useMapLocation/useMapDeepLink+map/*).
Cross-links¶
- Routing — Expo Router conventions, route groups, the auth gate
- State Management — the contexts and the Query data layer
- API Client — services,
apiFetch/apiJson,handleResponse, token refresh - Real-time — the
/ws/clientand chat WebSocket clients - Components — the feature-folder map and
ui/primitives - Style Guide — colors, typography, spacing, motion