Skip to main content
This document covers the architecture patterns and structure of the Soundcheck mobile app.
Stack: Expo SDK 57, React Native 0.86.3, React 19.2, Expo Router ~57. Auth uses @clerk/expo ^4.8; icons use lucide-react-native; analytics/flags use posthog-react-native. UI style follows the OS (userInterfaceStyle: automatic); ThemeContext resolves a persisted preference — System (the default) tracks the device color scheme, while an explicit Light/Dark selection under You → Settings → Appearance overrides it.

Expo Router

The app uses Expo Router for file-based navigation. Routes are defined by the file structure in the app/ directory. There are four tabs — events, mailbox, browse, you (the tab bar labels them Gigs / Mailbox / Browse / You).
The Browse tab is a launcher for secondary surfaces: customers, dashboard, files, inventory, leads, org-engagements, payments, setlists, songs, sponsors, tasks, team-availability, teams, venues. Most of these are feature-flag gated (see Feature flags). The tab bar (components/AnimatedTabBar.tsx) is wrapped in <RequireAppAccess> and hides individual tabs via useHasVisibleMenuEntries(...) plus the mobile-inbox-top-right / floating-bottom-nav flags. Gigs (events) always stays; Mailbox is removed from the tab bar when mobile-inbox-top-right is on; You stays when Profile (and other You entries) remain visible.

State management

Context providers

Server state uses TanStack React Query v5 (see Data fetching); cross-cutting app state uses React Context. The provider stack spans app/_layout.tsx and lib/providers/AppProviders.tsx:

Available contexts

Context providers live in lib/context/: The React Query client is not a lib/context provider — it lives in lib/query/QueryProvider.tsx (wraps QueryClientProvider with the shared queryClient and an AppState focusManager bridge).

Using contexts


Styling & theme

Native appearance support uses userInterfaceStyle: 'automatic' and expo-system-ui; applying these native changes requires rebuilding and reinstalling the app, not just a JavaScript reload or OTA update.

NativeWind v4

Styling is primarily NativeWind v4 — Tailwind utility classes via className, configured in tailwind.config.js with a global.css (imported in _layout.tsx) that defines semantic HSL tokens mirroring the web app’s globals.css (--primary, --secondary, --destructive, --success, --warning, --info, --finance, plus .dark overrides). Newer surfaces use a shadcn-for-RN layer in components/ui/ built on @rn-primitives/* + class-variance-authority.
The brand primary is now an indigo (--primary: 239 84% 67%), not the legacy purple. #9b87f5 survives only as the app-icon/splash background (app.config.js) and the switch-active accent.

Legacy token system

The older constants/theme/ token set (tokens/colors.ts, typography.ts, shadows.ts + useThemeColors) still supplies typography, shadows, and theme colors to native components. Prefer NativeWind classes for new work.

Component architecture

The app has a single UI layer: NativeWind v4 + @rn-primitives in components/ui.
  • New components → components/ui/ (NativeWind + @rn-primitives, icons via lucide-react-native re-exported through lib/icons/). Form sheets use components/ui/bottom-sheet-modal.tsx.
  • Gluestack is removed. npm run check:gluestack is a permanent ban (empty allowlist).

Data fetching

TanStack React Query + service layer

Server state is managed with React Query v5. Query hooks live in lib/query/hooks/ (e.g. useCustomersQuery, useEngagementsQuery, useEventDetailsQuery) and call typed service classes in lib/api/ (OrganizationService, EventService, CrewOfferService, …). Those services share an axios transport, lib/api/httpClient.ts, which injects the Clerk token.
Use React Query for data fetching. Don’t hand-roll fetch/cache hooks; the legacy useRefreshableData hook has been removed.

Token management

Clerk JWT tokens are automatically attached to requests:

Custom hooks

Available hooks

Example: tab focus hook


Performance optimizations

Tab preloading

All tabs are preloaded for instant switching:

Data caching

React Query handles caching and background revalidation:
  • Stale-While-Revalidate: cached data shows instantly, fresh data fetches in the background
  • Focus refetch: a focusManager AppState bridge (in QueryProvider) revalidates on app foreground
  • Pull-to-Refresh: invalidates/refetches the relevant queries

Component optimization


Error handling

Error boundary

The app includes a global error boundary:

API error handling


Best practices

Component guidelines

  1. Keep components small - Split large components into smaller ones
  2. Use TypeScript - All components should be fully typed
  3. Prefer composition - Build complex UIs from simple components
  4. Memoize when needed - Use memo() for expensive renders

State management guidelines

  1. Colocate state - Keep state as close to where it’s used as possible
  2. Context for global state - Use context for truly global state
  3. Avoid prop drilling - Use context when passing props through many levels

Performance guidelines

  1. Lazy load screens - Only when not in tab navigation
  2. Use FlatList - For long lists instead of ScrollView
  3. Optimize images - Use appropriate sizes and formats
  4. Minimize re-renders - Use useMemo and useCallback appropriately

Feature flags

Nearly every mobile surface is PostHog feature-flag gated. Two things drive visibility:
  • lib/constants/featureFlags.ts — re-exports the flag catalog generated from flags/registry.json into lib/generated/featureFlags.ts: SHARED_FEATURE_FLAGS (keys shared with web, such as crew-availability-offers, departments, event-chats, open-shifts) and MOBILE_FEATURE_FLAGS (mobile-only keys such as ORG_ENGAGEMENTS, TEAM_AVAILABILITY, MAILBOX_TOP_RIGHT). Add or retire flags in the registry and run npm run generate:flags; never edit the generated file.
  • lib/navigation/menuRegistry.ts — each menu entry declares an optional flag, tab, group, and requiresRole. lib/navigation/menuVisibility.ts (useVisibleMenuEntries) resolves visibility in order: role gate → no-flag entries always visible → local EXPO_PUBLIC_FF_* override → PostHog evaluation.
Flags are effectively org-targeted. On identify, PostHogContext calls posthog.group('organization', orgId) and seeds person properties (organization_ids, active_organization_id/role, is_organization_owner/admin, environment) so flags can target by membership and active org, then reloads flags.
Because targeting keys off the org, a flag scoped to an organization_id only lights up on mobile once that org is the active org. See the rollout matrix in Mobile feature flag rollout.

Native projects

The ios/ and android/ directories are gitignored and generated by expo prebuild from app.config.js — never commit them. See Environment Setup for prebuild commands.