Skip to content

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 raw fetch. 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.

User action tap / navigate
app/ · Expo Router route (no fetch)
screen.tsx reads hooks + contexts, dispatches intent
reads
contexts/ · non-server state
AuthContext bearer, session, claims
Theme · Toast · PageHeader · Location · Sheet
hooks/ · TanStack Query
use<Thing>() + co-located <thing>QueryKey
*Sync WS → invalidate / setQueryData
queryFn
services/ · pure transport (owns enum unions)
24 <domain>Service.ts
utils/apiFetch.ts apiFetch · apiJson · 401 refresh
utils/tokenManager.ts bearer cache
Backend
REST /api/v1
WebSocket /ws/client
The WebSocket feeds the *Sync mounts, which write the Query cache the hooks read — closing the loop without a re-fetch.

First 30 minutes: a reading path

Read these files, in order, to see the whole spine end to end:

  1. app/_layout.tsx — the root: provider nesting, the auth gate, font/Sentry boot.
  2. contexts/AuthContext.tsx — who the user is; how isAuthenticated / claims drive routing.
  3. utils/apiFetch.ts + utils/api.ts — the transport floor every service sits on.
  4. hooks/useEventDetail.ts — the canonical per-resource Query hook (key factory + invalidate…Queries helper + a live-updates subscription).
  5. services/eventService.ts — a transport-only service with its enum unions.
  6. components/ui/Page.tsx + components/layout/ScreenContainer.tsx — the page shell every screen composes.
  7. 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.Protected guards do the broad split by reading account_type off the JWT claims: auth is shown only when unauthenticated or the profile is incomplete; (tomoda) + (capture) + place-map require account_type ∈ {standard, synthetic}; (partner) requires partner; (internal) requires tomoda; 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 captured initialPathRef; landing on the marketing root or an auth screen while signed in → defaultLandingFor(accountType) (/discover standard, /dashboard partner, /manager tomoda).

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):

GestureHandlerRootView
QueryClientProvider lib/queryClient
SafeAreaProvider
ThemeProvider useTheme everywhere below
AppErrorBoundary catches app-state provider throws
AuthProvider gates routing on isAuthenticated / claims
AppWebSocketProvider /ws/client
NotificationsProvider
ChatUnreadSync · TaxonomySync · FriendsSync headless, render null
Toast · Location · Sheet · Permissions · PageHeader
RootLayoutNav reads useAuth → Stack.Protected tree
The three *Sync components render nothing: they bridge WebSocket pushes into the Query cache, sitting as siblings of the router rather than as providers.

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.ts exports three key factories (publicEventQueryKey / eventDetailQueryKey / eventParticipantsQueryKey) plus invalidateEventQueries(qc, eventId) that invalidates all three as a set, so the refresh / live-update / edit-save paths can't drift apart. hooks/useStashItems.ts is the cursor-paginated useInfiniteQuery shape. Copy one of these for a new read.
  • WebSocket writes the cache, two ways. Invalidate for query-backed data: useEventLiveUpdates subscribes to event:updated and calls invalidateEventQueries so a host's edit applies live. Direct setState for local-state-backed streams: useInboxChats mutates its list on chat:new_message / chat:mark_read (bump unread, reorder to top).
  • Transport floor. utils/apiFetch.ts exposes apiFetch(path, init) — it prepends API_URL to any relative path, injects the bearer, and on a non-/auth/* 401 runs a single-flight refresh + one retry — and apiJson<T>(path, init), which is apiFetch + handleResponse in one and is what nearly every service method calls. utils/api.ts owns handleResponse (error parsing, a Sentry HTTP breadcrumb, SessionExpiredError for dead-session 401s). utils/tokenManager.ts caches the bearer in memory and runs the proactive refresh timer; utils/tokenStore.ts persists 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:

  1. useSetPageHeader(config) sets the global header config on focus (drives the desktop/global header; screenOwnsHeader lets a screen own its own mobile header).
  2. components/layout/ScreenContainer.tsx — transparent wrapper, centers content at maxWidth: 900 on desktop, full-bleed on mobile.
  3. 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:

  • MapMapView.tsx is the shared prop contract (the MapMarker union, MapViewProps). MapView.web.tsx renders the MapEngine React tree with HTML overlays; MapView.native.tsx uses the @maplibre/maplibre-react-native SDK. Detail sheets (map/details/) are shared. See Maps.
  • CameraTomodaCamera.native.tsx is the real camera; web is a stub (and (capture) redirects to /discover on 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; tsc runs strict: true; no any in service signatures, no as to widen an enum.
  • No raw fetch above the service layer. Reads go through a Query hook, writes through a service; every response funnels through handleResponse.
  • Every user-facing string is translated through react-i18next with a real key in all four locale bundles. defaultValue on t() 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). madge reports zero cycles.
  • God components are decomposed into a thin container + co-located pieces + a use<Thing> hook (plan.tsxusePlanTabActions + planner/*; discover/map.tsxuseMapMarkers/useMapLocation/useMapDeepLink + map/*).
  • 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/client and chat WebSocket clients
  • Components — the feature-folder map and ui/ primitives
  • Style Guide — colors, typography, spacing, motion