Architecture
An overview of how the starter is wired together: the provider tree, navigation, state, Relay data fetching, and the file layout.
Provider tree
src/App.tsx is the root. The app is wrapped with Sentry.wrap(...) and the providers are nested (outermost first):
<GlobalStoreProvider> // easy-peasy store + persistence
<FeatureFlagProvider> // Unleash (no-op until configured)
<RelayEnvironmentProvider>
<SafeAreaProvider>
<ScreenDimensionsProvider>
<ThemeProvider> // @artsy/palette-mobile theme
<GestureHandlerRootView>
<GlobalRetryErrorBoundary>
<SuspenseWrapper>
<Main /> // navigation entry pointsetupSentry() runs once at module load, before the component tree renders.
Navigation
Navigation uses react-navigation v7's static API, defined in src/Navigation.tsx.
- A native-stack
RootStacksplits into two conditional groups guarded by auth state read from the store:SignedIn(useIsLoggedIn) → theHomeTabsbottom-tab navigator (Home,News,Settings), plus top-level screens pushed over the tabs —Artwork(opened by tapping a Home rail card) and the dev-onlyDevMenu.SignedOut(useIsLoggedOut) → theLoginscreen.
- The param list is registered on the global
ReactNavigation.RootParamListnamespace viaStaticParamList<typeof RootStack>, souseNavigation()is typed everywhere without manual param types. createStaticNavigation(RootStack)produces the navigator. The exportedMaincomponent waits for the persisted store to rehydrate (useStoreRehydrated) before rendering, so the auth guards resolve to the correct group on first paint.
Screens live under src/Scenes/ (one folder per top-level screen).
WebView-backed screens (News)
The News tab is a nested native-stack (NewsFeed → NewsArticle) used as a tab's screen.
The gateway has no global news feed — getNewsArticles must be scoped to an entity (artist/gallery/auction house) and errors on an unfiltered call. So NewsFeed seeds itself from Home's public listings: it queries listings, derives the artists (creators) from them, and then fetches getNewsArticles scoped to those creators (only when there's at least one, so it never sends an empty filter). When there are no creators or no articles, it shows a small empty state.
Articles render in a react-native-webview, but navigation stays in react-navigation: NewsArticle's onShouldStartLoadWithRequest intercepts in-WebView link taps and, for a tap on a different article, calls navigation.push("NewsArticle", …) and returns false to block the in-WebView load — so the stack's back button walks the reading history. "Same vs. different article" is compared on the article id (newsArticleId), not raw URL equality, so canonicalizing redirects and #anchor links load in place rather than pushing a duplicate screen. Non-article links (category/author/ search) load in place. This is the pattern to reuse for any future WebView-backed feature that should keep native navigation.
State management
Global state uses easy-peasy, configured in src/store/GlobalStore.tsx:
- The store is created with
createStore(persist(GlobalStoreModel, { storage }))and persisted toAsyncStorage. - Typed hooks come from
createTypedHooks<GlobalStoreModel>(). Read state withGlobalStore.useAppState(...); dispatch throughGlobalStore.actions....
// Read
const isSignedIn = GlobalStore.useAppState((state) => state.auth.isSignedIn)
// Dispatch
GlobalStore.actions.auth.signOut()Models live in src/store/Models/ (AuthModel, ConfigModel, EnvironmentModel, and the root GlobalStoreModel).
Store versioning
The store is persisted with a STORE_VERSION. If you change a Model's shape, bump the version and add a migration so persisted state upgrades cleanly.
Relay & GraphQL
Data fetching uses Relay 20 against the Artnet GraphQL gateway (see Artnet Backend).
- Load queries through
useSystemQueryLoader(fromsrc/system/relay/) rather than callinguseLazyLoadQuerydirectly, and access the Relay environment throughuseSystemRelayEnvironmentrather thanuseRelayEnvironment. Today both are thin drop-in wrappers, but they are the single seam where cross-cutting behavior (offline guards, global fetch policy/key, environment resets) can be added later without touching call sites — mirroring Eigen/Energy. - Prefer Relay hooks (
useFragment,usePaginationFragment) over HOCs, and co-locate fragments with the component that consumes them. - Run
yarn relayafter changing any query or fragment. Generated artifacts live insrc/__generated__/and are never edited by hand (they're gitignored except for.gitkeep). - The Relay environment is set up in
src/relay/(network middlewares undersrc/relay/middlewares/) and provided viaRelayEnvironmentProviderinApp.tsx. yarn type-checkruns the Relay compiler beforetsc.
Logging
App code logs through the structured, leveled logger in src/system/logger/ — never console.* directly:
import { logger } from "system/logger"
logger.debug("cache hit", { key })
logger.info("user signed in", { userId })
logger.warn("slow request", { ms })
logger.error("failed to open app", err, { url })The logger is the single logging seam: in development it prints level-prefixed console output, and in production it is wired to Sentry (debug/info become breadcrumbs, warn captures a warning, error captures the passed Error as an exception). The minimum level defaults to debug in dev and info in production. Routing every call site through one module means future transports (file, remote, etc.) can be added in one place.
File organization
src/
├── App.tsx # Root component + provider tree
├── Navigation.tsx # Route definitions + navigation stack
├── Scenes/ # Top-level screens (Home, News, Login, Settings)
├── components/ # Shared UI components across scenes
├── helpers/ # Shared utilities
├── relay/ # Relay environment + network middlewares
├── store/ # easy-peasy global store + Models
├── system/ # Core infra
│ ├── devTools/ # Sentry setup
│ ├── logger/ # Structured leveled logger (Sentry-wired)
│ ├── providers/ # Theme, FeatureFlag providers
│ ├── relay/ # useSystemQueryLoader / useSystemRelayEnvironment
│ └── wrappers/ # Suspense + error-boundary wrappers
├── assets/ # Images and fonts
├── utils/test/ # Test helpers (setupTestWrapper, renderWithWrappers)
└── __generated__/ # Relay-generated artifacts (do not edit)
data/
└── schema.graphql # Artnet gateway GraphQL schema
e2e/
└── flows/ # Recorded agent-device .ad e2e checksConventions
- Use absolute imports from
src/(module-resolver root is./src). Only same-folder relative imports are allowed. - Do not import components/hooks directly from another Scene — extract shared code to
src/components/(UI) orsrc/helpers/(utilities). - Use
@artsy/palette-mobileprimitives (Flex,Text,Button,Input,Spacer,useColor,useSpace,Theme) rather than re-implementing them. - Use Luxon for date/time — do not add moment.
- Log through the
logger(system/logger) instead ofconsole.*. - Load Relay queries via
useSystemQueryLoaderand read the environment viauseSystemRelayEnvironment(not the raw Relay hooks). - Imports are auto-sorted by
eslint-plugin-simple-import-sort(runyarn lint), andeslint-plugin-react-native-a11yflags missing accessibility props on interactive components (atwarn). - Naming: components
PascalCase.tsx, hooksuseCamelCase, constantsUPPER_SNAKE_CASE, utilitiescamelCase.ts. - PR titles follow Conventional Commits (
feat,fix,docs,chore,refactor,test,ci,build,perf) — enforced by CI.