>_devkit
react-native-expo
skills/react-native-expo/

references/structure.md

Project structure

The tree

app/                              Expo Router. Routes and layouts only.
  _layout.tsx                     Root providers, splash, font loading
  +not-found.tsx
  (auth)/
    _layout.tsx
    sign-in.tsx                   export { SignInScreen as default } from '@/features/auth/SignInScreen'
  (app)/
    _layout.tsx                   Tabs or Stack
    index.tsx
    invoices/
      _layout.tsx
      index.tsx
      [id].tsx
      new.tsx
src/
  ui/                             The design system. Portable to any client.
    tokens/
      colors.ts  spacing.ts  typography.ts  radius.ts  motion.ts  index.ts
    theme/
      ThemeProvider.tsx  useTheme.ts  themes.ts
    atoms/
      Button.tsx  Text.tsx  Input.tsx  Avatar.tsx  Icon.tsx  Spinner.tsx
    molecules/
      Field.tsx  SearchBar.tsx  ListRow.tsx  EmptyState.tsx  Toast.tsx
    templates/
      ScreenScaffold.tsx  ListScreenTemplate.tsx  FormScreenTemplate.tsx
  features/
    invoices/
      InvoiceListScreen.tsx
      InvoiceDetailScreen.tsx
      components/
        InvoiceRow.tsx
        InvoiceStatusBadge.tsx
      api.ts                      TanStack Query hooks for this feature
      queryKeys.ts
      schema.ts                   zod schemas + inferred types
      store.ts                    Zustand, only if the feature needs client state
      utils.ts
    auth/
      ...
  lib/
    api/          client.ts  errors.ts  interceptors.ts
    storage/      mmkv.ts  secure.ts  storage-keys.ts
    i18n/         index.ts  useTranslation.ts
    format/       date.ts  currency.ts  number.ts
    analytics/    index.ts
    errors/       ErrorBoundary.tsx  report.ts
  hooks/          useDisclosure.ts  useDebouncedValue.ts  useAppState.ts
  config/         env.ts
  types/          global.d.ts  nav.d.ts
translations/     en.json  fr.json
assets/           fonts/  images/

app/ sits at the root, not inside src/, unless you set expo-router/entry accordingly. Either works; pick one and use it in every project. The tree above assumes the root form, which is the Expo default.

Naming

Follow the house standard in the naming-conventions skill. For a React Native project that resolves to:

ThingCaseExample
Component filePascalCase.tsxInvoiceRow.tsx
Hook filecamelCase.ts, matching the hookuseDisclosure.ts
Other TypeScriptkebab-case.tsstorage-keys.ts
Route fileExpo Router's rules[id].tsx, (app)/, +not-found.tsx
Feature folderkebab-case singular or plural nouninvoices/, auth/
Screen component<Noun><Verb>ScreenInvoiceDetailScreen
Zustand store hookuse<Feature>StoreuseAuthStore
Query hookuse<Nouns> / use<Verb><Noun>useInvoices, useCreateInvoice
Boolean propis / has / can prefixisLoading, hasError
Handler propon<Event>onSubmit, onSelectInvoice
Internal handlerhandle<Event>handleSubmit
testID<screen>-<element>-<role>invoice-detail-pay-button

One exported component per file, and the filename equals the component name.

Path aliases

// tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "paths": { "@/*": ["./src/*"] }
  }
}

One alias, not five. @/ui/atoms/Button, @/features/invoices/api. Multiple aliases (@ui, @features, @lib) look tidier and consistently confuse tooling, Jest config, and new joiners.

Import direction

Dependencies flow one way. Nothing below may import from something above it:

config, types
    v
lib
    v
ui/tokens -> ui/atoms -> ui/molecules -> ui/templates
    v
hooks
    v
features
    v
app

Two rules follow, and both are worth enforcing mechanically:

  1. src/ui/ may never import from src/features/.
  2. A feature may never import another feature's internals.
// eslint.config.js
{
  rules: {
    'no-restricted-imports': ['error', {
      patterns: [
        { group: ['@/features/*'], message: 'ui/ and lib/ must not depend on features.' },
        { group: ['**/index'], message: 'No barrel imports. Import the file directly.' },
      ],
    }],
  },
}

Scope the first pattern to src/ui/ and src/lib/ with an ESLint files override. For real cross-feature enforcement use eslint-plugin-boundaries or eslint-plugin-import-x with zone rules; hand-written patterns get stale.

Start with eslint-config-expo, which since SDK 55 ships the React Compiler lint rules. Add eslint-plugin-react-hooks (it is included), and treat its warnings as errors. An exhaustive-deps warning is a bug you have not hit yet.

No barrel files

Import from the file that defines the thing.

import { Button } from '@/ui/atoms/Button';        // yes
import { Button } from '@/ui/atoms';               // no

Barrels cost three things in a React Native project specifically:

  • Fast Refresh. Touching one file in a barrel invalidates every consumer of

the barrel. On a large app this turns a colour tweak into a multi-second reload.

  • Circular imports. Modules inside a folder importing through their own

barrel is the classic way to get undefined is not a function at runtime, with a stack trace that points nowhere useful.

  • Bundle size. Metro's tree shaking is weaker than a web bundler's.

export * reliably drags in code you never referenced.

The one defensible exception is src/ui/tokens/index.ts re-exporting leaf token modules, because tokens are values, cheap, and imported everywhere. If you take that exception, use explicit named re-exports, never export *.

Expo Router specifics

Route groups (auth) and (app) organise without appearing in the URL. Use them for the authenticated/unauthenticated split, and gate in the group layout:

// app/(app)/_layout.tsx
export default function AppLayout() {
  const status = useAuthStore((s) => s.status);
  if (status === 'loading') return <SplashScreen />;
  if (status === 'signedOut') return <Redirect href="/(auth)/sign-in" />;
  return <Stack />;
}

Redirect in a layout, never in a screen's useEffect. The effect version flashes the protected screen before it bounces and races with deep links.

Typed routes on, in app.json:

{ "expo": { "experiments": { "typedRoutes": true } } }

That makes router.push('/invoices/{id}') a compile error when the route moves, which is why you do not need a ROUTES constants file.

unstable_settings.initialRouteName matters for deep links. Without it, a user opening a link to a detail screen lands there with no back stack.

Keep nesting shallow. Three levels of directory under app/ is usually a sign that a route group would express the same thing flatly.

What goes in lib/ versus hooks/ versus a feature

  • lib/ is infrastructure: things that talk to the outside world, or that have

no React in them at all. API client, storage, i18n setup, formatters, error reporting.

  • hooks/ is generic React behaviour with no domain knowledge. If it could go

in a blog post as-is, it goes here.

  • Everything else belongs to a feature. When in doubt, put it in the feature.

Moving code up is easy; untangling a premature lib/utils.ts that forty files import is not.

There is no src/utils/. It becomes a landfill in every project that has one. Formatters go in lib/format/, feature-specific helpers in features/<f>/utils.ts, and anything that fits neither is probably a hook.

src/config/env.ts

Parse and validate at boot, so a misconfigured build fails at startup rather than at the payment screen:

import { z } from 'zod';

const schema = z.object({
  apiUrl: z.string().url(),
  environment: z.enum(['development', 'staging', 'production']),
  sentryDsn: z.string().url().optional(),
});

export const env = schema.parse({
  apiUrl: process.env.EXPO_PUBLIC_API_URL,
  environment: process.env.EXPO_PUBLIC_ENV,
  sentryDsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
});

EXPO_PUBLIC_ variables are inlined into the bundle at build time and are readable by anyone with the app. They are configuration, never secrets. Any value that must stay secret belongs to the backend, not to a mobile build.