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:
| Thing | Case | Example |
|---|---|---|
| Component file | PascalCase.tsx | InvoiceRow.tsx |
| Hook file | camelCase.ts, matching the hook | useDisclosure.ts |
| Other TypeScript | kebab-case.ts | storage-keys.ts |
| Route file | Expo Router's rules | [id].tsx, (app)/, +not-found.tsx |
| Feature folder | kebab-case singular or plural noun | invoices/, auth/ |
| Screen component | <Noun><Verb>Screen | InvoiceDetailScreen |
| Zustand store hook | use<Feature>Store | useAuthStore |
| Query hook | use<Nouns> / use<Verb><Noun> | useInvoices, useCreateInvoice |
| Boolean prop | is / has / can prefix | isLoading, hasError |
| Handler prop | on<Event> | onSubmit, onSelectInvoice |
| Internal handler | handle<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:
src/ui/may never import fromsrc/features/.- 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.