KhueApps
Home/React Native/Expo React Native Architecture: Feature Modules, Router, Query

Expo React Native Architecture: Feature Modules, Router, Query

Last updated: October 07, 2025

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.

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
ConcernDefault choiceNotes
NavigationExpo RouterCo-locates screens with code
Server stateTanStack QueryCaching, retries, background refresh
Local UI stateZustandSimple, small API, opt-in
FormsReact Hook FormEfficient re-rendering
ListsFlatList or FlashListPrefer FlashList for big lists
StylingStyleSheet + utility classesAvoid heavy runtime styling

Quickstart (managed Expo)

  1. Create a TypeScript app
npx create-expo-app@latest expo-arch --template blank-typescript
cd expo-arch
  1. Install core libs
npx expo install expo-router react-native-screens react-native-safe-area-context
npm i @tanstack/react-query zustand
  1. Enable Expo Router in Babel
// babel.config.js
module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: ['expo-router/babel'],
  };
};
  1. 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)

  1. Start with feature modules

    • Create features/<feature> with api.ts, store.ts, and components/.
    • Keep UI, state, and data logic close together.
  2. 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).
  3. Routing

    • Use Expo Router to colocate routes under app/.
    • Prefer shallow stacks; push complexity into feature components.
  4. 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.
  5. Theming and design system

    • Create components/ for shared primitives only.
    • Use StyleSheet.create; avoid runtime-computed style objects.
  6. 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.
  7. 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.

Series: React Native Intermediate tutorials

React Native