Why this architecture
A pragmatic Expo app balances DX with long-term maintainability. This guide shows a feature-first structure using:
- Expo Router for filesystem-based navigation
- TanStack Query for server/state syncing
- Zustand for local/ephemeral UI state
- Small, testable feature modules instead of giant “global” folders
This fits intermediate React Native developers building multi-screen apps that evolve quickly.
Recommended structure
Keep boundaries explicit and keep shared code small.
/ (project)
├─ app/ # Expo Router routes
│ ├─ _layout.tsx # Providers, stacks
│ └─ index.tsx # Home route
├─ features/
│ └─ todos/
│ ├─ api.ts # Remote calls (fetch/query)
│ ├─ store.ts # Local UI state (Zustand)
│ └─ components/ # Feature UI
├─ components/ # Truly shared UI (buttons, typography)
├─ lib/ # Cross-cutting libs (http client, theme)
├─ assets/
├─ babel.config.js
└─ tsconfig.json
Concern | Default choice | Notes |
---|---|---|
Navigation | Expo Router | Co-locates screens with code |
Server state | TanStack Query | Caching, retries, background refresh |
Local UI state | Zustand | Simple, small API, opt-in |
Forms | React Hook Form | Efficient re-rendering |
Lists | FlatList or FlashList | Prefer FlashList for big lists |
Styling | StyleSheet + utility classes | Avoid heavy runtime styling |
Quickstart (managed Expo)
- Create a TypeScript app
npx create-expo-app@latest expo-arch --template blank-typescript
cd expo-arch
- Install core libs
npx expo install expo-router react-native-screens react-native-safe-area-context
npm i @tanstack/react-query zustand
- Enable Expo Router in Babel
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['expo-router/babel'],
};
};
- Add the app directory and files shown below. Then run:
npx expo start
Minimal working example
A tiny app that fetches todos (mocked) with React Query and toggles UI state with Zustand.
// app/_layout.tsx
import React from 'react';
import { Stack } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack screenOptions={{ headerTitle: 'Todos' }} />
</QueryClientProvider>
);
}
// app/index.tsx
import React from 'react';
import { View, Text, Pressable, StyleSheet, ActivityIndicator } from 'react-native';
import { useQuery } from '@tanstack/react-query';
import { fetchTodos } from '../features/todos/api';
import { useTodoUI } from '../features/todos/store';
export default function Home() {
const { data, isLoading, error } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
const toggled = useTodoUI((s) => s.toggled);
const toggle = useTodoUI((s) => s.toggle);
if (isLoading) return <ActivityIndicator style={{ marginTop: 24 }} />;
if (error) return <Text>Failed to load todos</Text>;
return (
<View style={styles.container}>
{data?.map((t) => (
<Pressable key={t.id} onPress={() => toggle(t.id)} style={styles.item}>
<Text style={styles.title}>
{t.title} {toggled[t.id] ? '✓' : ''}
</Text>
</Pressable>
))}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 12, backgroundColor: '#fff' },
item: { padding: 12, borderRadius: 8, backgroundColor: '#f2f2f2' },
title: { fontSize: 16 },
});
// features/todos/api.ts
export type Todo = { id: number; title: string; completed: boolean };
export async function fetchTodos(): Promise<Todo[]> {
await wait(300);
return [
{ id: 1, title: 'Buy milk', completed: false },
{ id: 2, title: 'Write report', completed: false },
{ id: 3, title: 'Call Alex', completed: true },
];
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// features/todos/store.ts
import { create } from 'zustand';
type UIState = {
toggled: Record<number, boolean>;
toggle: (id: number) => void;
};
export const useTodoUI = create<UIState>((set) => ({
toggled: {},
toggle: (id) => set((s) => ({ toggled: { ...s.toggled, [id]: !s.toggled[id] } })),
}));
This example compiles on a fresh Expo app with the listed deps.
Implementation steps (from blank to scalable)
Start with feature modules
- Create features/<feature> with api.ts, store.ts, and components/.
- Keep UI, state, and data logic close together.
Separate server vs. client state
- Use TanStack Query for anything fetched or derived from the backend.
- Use Zustand or component state for ephemeral UI data (toggled, dialogs, inputs).
Routing
- Use Expo Router to colocate routes under app/.
- Prefer shallow stacks; push complexity into feature components.
Data fetching patterns
- One file per endpoint in features/<feature>/api.ts.
- Wrap mutations with optimistic updates when safe.
- Configure sensible defaults: staleTime and retry.
Theming and design system
- Create components/ for shared primitives only.
- Use StyleSheet.create; avoid runtime-computed style objects.
Environment and config
- Put configuration in app.config.ts and read via expo-constants.
- Keep secrets server-side; use runtime tokens from secure storage if needed.
Testing surface
- Test pure logic (api mappers, stores) with unit tests.
- Keep components small to ease snapshot/interaction tests.
Pitfalls to avoid
- Mixing server and client state in one global store (hard to cache/invalidate).
- Deep “utils/” and “helpers/” buckets; prefer feature folders and local helpers.
- Inline objects/functions in props causing re-renders; memoize and use StyleSheet.
- Non-keyed lists or unstable keys; always use stable keyExtractor.
- Huge context providers at the root; scope providers to features when possible.
- Blocking the JS thread with heavy work; offload to native modules or background tasks.
- OTA updates with native changes; keep native upgrades and OTA releases in sync.
Performance notes
- Rendering
- Use React.memo for pure list rows; prefer FlashList for very large datasets.
- Provide getItemLayout and fixed item heights when possible.
- Avoid layout thrash: consistent paddings/margins, minimal nesting.
- State
- React Query: set a realistic staleTime (e.g., minutes) and gcTime to reduce refetching.
- Co-locate Zustand slices per feature to minimize unnecessary updates.
- Images and assets
- Use expo-image for better caching and placeholders.
- Preload critical images and fonts during splash.
- Animations
- Prefer react-native-reanimated for smooth, JSI-powered animations.
- Startup
- Lazy-load rarely used screens; split by route boundaries.
When to choose what
- Need optimistic updates, pagination, and cache? Use TanStack Query.
- Simple UI flags or form steps? Use Zustand or component state.
- Cross-feature concerns (auth, theme)? Small dedicated contexts.
- Heavy lists or chat? Use FlashList and memoized row components.
Tiny FAQ
Can I replace Zustand with Redux Toolkit?
- Yes. For large teams needing strict patterns/middleware, Redux Toolkit is solid. Keep server state in Query regardless.
Do I need Expo Router for every app?
- Not required, but it simplifies route setup and co-locates files, which scales well.
How do I share code between features?
- Promote only stable, truly reusable pieces into components/ or lib/. Keep most logic inside features/.
How should I handle errors globally?
- Use React Query’s onError handlers and a top-level error boundary. Surface user-friendly messages at feature boundaries.
When should I eject from managed Expo?
- Only when you need custom native modules not supported in managed workflow or require deep native customization.