AgentSkillsCN

expo-architecture

Expo Router 项目结构、导航模式与 iOS 应用的架构规范。适用于新建页面、配置导航、规划 Expo 新项目,或就文件组织与数据流动做出架构决策时使用。

SKILL.md
--- frontmatter
name: expo-architecture
description: >
  Expo Router project structure, navigation patterns, and architecture conventions for iOS apps.
  Use when creating new screens, setting up navigation, structuring a new Expo project,
  or making architectural decisions about file organization and data flow.

Expo Architecture — Project Structure & Patterns

Conventions for building Expo Router apps targeting iOS with TypeScript.

Project Structure

code
├── app/                          # Expo Router file-based routing
│   ├── _layout.tsx               # Root layout (providers, fonts, splash)
│   ├── index.tsx                 # Entry redirect or landing
│   ├── +not-found.tsx            # 404 fallback
│   ├── (auth)/                   # Auth group (unauthenticated)
│   │   ├── _layout.tsx
│   │   ├── login.tsx
│   │   ├── register.tsx
│   │   └── forgot-password.tsx
│   ├── (tabs)/                   # Main tab navigator (authenticated)
│   │   ├── _layout.tsx           # Tab bar configuration
│   │   ├── index.tsx             # Home tab
│   │   ├── explore.tsx           # Second tab
│   │   └── profile.tsx           # Profile tab
│   ├── (modals)/                 # Modal routes
│   │   ├── _layout.tsx           # Modal presentation config
│   │   └── settings.tsx
│   └── [id]/                     # Dynamic routes
│       └── detail.tsx
├── src/
│   ├── components/               # Reusable UI components
│   │   ├── ui/                   # Primitive/atomic components
│   │   │   ├── Button.tsx
│   │   │   ├── Text.tsx
│   │   │   ├── Input.tsx
│   │   │   └── Card.tsx
│   │   ├── forms/                # Form-specific components
│   │   ├── layout/               # Layout wrappers
│   │   │   ├── SafeArea.tsx
│   │   │   ├── ScreenWrapper.tsx
│   │   │   └── KeyboardAvoid.tsx
│   │   └── [feature]/            # Feature-specific components
│   ├── hooks/                    # Custom React hooks
│   │   ├── useAuth.ts
│   │   ├── useSupabase.ts
│   │   └── use[Feature].ts
│   ├── lib/                      # Core utilities and setup
│   │   ├── supabase.ts           # Supabase client initialization
│   │   ├── constants.ts          # App-wide constants
│   │   ├── storage.ts            # AsyncStorage helpers
│   │   └── utils.ts              # Pure utility functions
│   ├── services/                 # External API integrations
│   │   ├── api/                  # Supabase query functions
│   │   │   ├── auth.ts
│   │   │   ├── profiles.ts
│   │   │   └── [feature].ts
│   │   └── [third-party].ts      # Third-party SDK wrappers
│   ├── stores/                   # State management (Zustand)
│   │   ├── useAuthStore.ts
│   │   └── use[Feature]Store.ts
│   ├── types/                    # TypeScript type definitions
│   │   ├── database.ts           # Supabase generated types
│   │   ├── navigation.ts         # Route params
│   │   └── [feature].ts
│   └── theme/                    # Design tokens and theming
│       ├── colors.ts
│       ├── typography.ts
│       ├── spacing.ts
│       └── index.ts
├── assets/                       # Static assets
│   ├── images/
│   ├── fonts/
│   └── icons/
├── docs/                         # Documentation
│   ├── PRD.md
│   └── tasks/
├── supabase/                     # Supabase local dev
│   ├── migrations/
│   ├── functions/
│   └── seed.sql
├── app.config.ts                 # Expo config (dynamic)
├── CLAUDE.md                     # Claude Code project conventions
├── tsconfig.json
└── package.json

File Naming Conventions

TypeConventionExample
Screens (in app/)kebab-caseforgot-password.tsx
ComponentsPascalCaseProfileCard.tsx
HookscamelCase with use prefixuseAuth.ts
Utils/ServicescamelCaseformatDate.ts
TypescamelCasedatabase.ts
StorescamelCase with use prefixuseAuthStore.ts
ConstantscamelCase file, UPPER_SNAKE valuesconstants.tsMAX_RETRIES

Navigation Patterns

Root Layout Pattern

typescript
// app/_layout.tsx
export default function RootLayout() {
  const { session, isLoading } = useAuth();

  if (isLoading) return <SplashScreen />;

  return (
    <Stack screenOptions={{ headerShown: false }}>
      {session ? (
        <Stack.Screen name="(tabs)" />
      ) : (
        <Stack.Screen name="(auth)" />
      )}
      <Stack.Screen
        name="(modals)"
        options={{ presentation: 'modal' }}
      />
    </Stack>
  );
}

Protected Route Groups

  • (auth) — Screens visible when NOT authenticated
  • (tabs) — Main app screens, require authentication
  • (modals) — Modal overlays accessible from anywhere
  • (onboarding) — First-run experience

Navigation Rules

  1. Use Expo Router's <Link> for declarative navigation
  2. Use router.push() for imperative navigation
  3. Use router.replace() for auth redirects (no back button)
  4. Pass params via URL: router.push(/item/${id})
  5. Type your route params in src/types/navigation.ts

Component Architecture

Component Levels

  1. UI Primitives (src/components/ui/) — Styled wrappers around RN primitives. No business logic. Accept style props.
  2. Composites (src/components/forms/, layout/) — Combine UI primitives. Minimal logic. Reusable across features.
  3. Feature Components (src/components/[feature]/) — Feature-specific. Can use hooks and stores. NOT reusable across features.
  4. Screens (app/) — Full screen. Compose feature components. Handle navigation and route params.

Component Rules

  • Every component gets its own file (no multi-component files)
  • Props interface exported alongside component
  • Default exports for screens, named exports for components
  • Co-locate component-specific hooks in the same directory
  • No inline styles longer than 3 properties — extract to StyleSheet

State Management

State Location Decision Tree

code
Is it server state (from Supabase)?
  → Use React Query / TanStack Query with Supabase

Is it shared across multiple screens?
  → Use Zustand store

Is it form state?
  → Use React Hook Form or local useState

Is it only used in one component tree?
  → Use useState / useReducer

Is it derived from other state?
  → Use useMemo, don't store it

Zustand Store Pattern

typescript
// src/stores/useAuthStore.ts
import { create } from 'zustand';

interface AuthState {
  session: Session | null;
  setSession: (session: Session | null) => void;
  signOut: () => Promise<void>;
}

export const useAuthStore = create<AuthState>((set) => ({
  session: null,
  setSession: (session) => set({ session }),
  signOut: async () => {
    await supabase.auth.signOut();
    set({ session: null });
  },
}));

Data Flow

code
Supabase → src/services/api/[feature].ts → Hook or Query → Screen → Component
                                               ↑
                                          Zustand (if shared state)

Rules

  1. Screens never call supabase directly — always through src/services/api/
  2. Components never call API functions — screens pass data as props
  3. Mutations happen through service functions, not raw Supabase calls
  4. Real-time subscriptions set up in hooks, not in components

Performance Patterns

  • Use React.memo() for list item components
  • Use FlashList instead of FlatList for long lists
  • Lazy-load heavy screens with React.lazy + Suspense
  • Preload images with expo-image cache policies
  • Avoid anonymous functions in renderItem — extract named functions
  • Use useCallback for event handlers passed to child components

Error Handling

code
Screen level:    ErrorBoundary wraps each screen
API level:       try/catch in service functions, return typed errors
Component level: Conditional rendering for error/loading/empty states
Global level:    Root ErrorBoundary in _layout.tsx

Every screen should handle 4 states:

  1. Loading — Skeleton or spinner
  2. Error — Retry button + error message
  3. Empty — Helpful empty state with CTA
  4. Success — The actual content

New Screen Checklist

When creating a new screen:

  • Create route file in app/ with correct group
  • Add type-safe route params if needed
  • Wrap content in ScreenWrapper or SafeArea
  • Handle loading, error, empty, success states
  • Add to navigation if not auto-discovered
  • Verify it renders on iOS simulator