返回 Skill 列表
extension
分类: 开发与工程无需 API Key

react-clean-architecture

适用于React Native(Expo)的干净架构,使用TypeScript和Bun。在创建功能、重构代码或审查React Native项目中的代码时,请使用此技能。强制执行核心(领域)、基础设施(适配器)和UI层之间的严格分离。实现端口/适配器模式、错误的结果模式以及组件的原子设计。

person作者: jakexiaohubgithub

React Clean Architecture

Prescriptive architecture for React Native (Expo) applications with TypeScript and Bun.

Stack: React Native (Expo) • TypeScript • Bun • Zustand (client state) • React Query (server state)

Patterns: Ports/Adapters • Use Cases • Result Pattern • ViewModel • Atomic Design

Core Principle

Core depends on NOTHING. UI and Infrastructure can import from Core, never the other way around.

UI ──────────────┐
                 ├──▶ Core (entities, use cases, ports)
Infrastructure ──┘

Project Structure

src/
├── ui/                              # Global components and hooks
│   ├── components/                  # Atomic Design
│   │   ├── atoms/                   # e.g., Button, Text, Icon
│   │   ├── molecules/               # e.g., InputField, Card
│   │   ├── organisms/               # e.g., Header, Form
│   │   └── templates/               # e.g., PageLayout
│   ├── hooks/                       # Global hooks (useToggle, useDebounce)
│   └── theme/                       # Palette, fonts, spacing
│
├── modules/
│   ├── [bounded-context]/           # e.g., authentication, events, profile
│   │   ├── core/                    # Pure domain (no external dependencies)
│   │   │   ├── entities/            # Business types/interfaces
│   │   │   │   └── User.entity.ts
│   │   │   ├── ports/               # Interfaces (contracts)
│   │   │   │   └── AuthRepository.port.ts
│   │   │   └── usecases/            # Business logic
│   │   │       └── Login.usecase.ts
│   │   │
│   │   ├── infrastructure/          # Port implementations
│   │   │   └── adapters/
│   │   │       └── AuthApi.adapter.ts
│   │   │
│   │   └── ui/                      # React-specific for this context
│   │       ├── components/          # e.g., AuthenticationCard
│   │       ├── screens/             # e.g., LoginScreen.tsx
│   │       ├── hooks/               # e.g., useAuthentication.tsx
│   │       ├── stores/              # Zustand stores
│   │       │   └── auth.store.ts
│   │       └── viewModels/          # UI orchestration
│   │           └── useLogin.viewModel.tsx
│   │
│   ├── shared/                      # Shared code between bounded contexts
│   │   ├── analytics/
│   │   ├── storage/
│   │   ├── toaster/
│   │   └── utils/
│   │
│   └── app/                         # Application configuration
│       ├── dependencies/
│       │   ├── Dependencies.type.ts
│       │   ├── dependencies.dev.ts
│       │   ├── dependencies.prod.ts
│       │   └── dependencies.test-env.ts
│       ├── react/
│       │   ├── useDependencies.tsx
│       │   ├── render.tsx
│       │   └── renderHook.tsx
│       └── main.ts
│
├── constants/                       # TestIDs, screen names
├── types/                           # General types (ISO8601, DeepPartial)
└── utils/                           # Utility functions
    ├── strings/
    │   └── firstCharToUppercase.ts
    └── dates/
        └── isISO8601Before.ts

File Naming Conventions

| Type | Extension | Example | | -------------------- | ---------------- | ------------------------ | | Entity | .entity.ts | User.entity.ts | | Port | .port.ts | AuthRepository.port.ts | | Use Case | .usecase.ts | Login.usecase.ts | | Adapter | .adapter.ts | AuthApi.adapter.ts | | ViewModel | .viewModel.tsx | useLogin.viewModel.tsx | | Store | .store.ts | auth.store.ts | | Model (API response) | .model.ts | LoginResponse.model.ts |

React components: PascalCase.tsx (e.g., LoginScreen.tsx, AuthenticationCard.tsx)

Layer Rules

Core (/modules/[context]/core/)

Core is pure and unaware of the outside world.

✅ Defines entities (types/interfaces)
✅ Defines ports (dependency interfaces)
✅ Contains use cases (business logic)
✅ Uses Result pattern for errors
✅ Can import from: types/, utils/, other files in the same core

❌ NEVER import from infrastructure/
❌ NEVER import from ui/
❌ NEVER depend on React
❌ NEVER call APIs directly

Infrastructure (/modules/[context]/infrastructure/)

Implements ports defined in Core.

✅ Adapters implement ports
✅ Handles API calls, storage, external services
✅ Transforms external data → Core entities
✅ Returns Result<T, E>
✅ Can import from: core/ (ports, entities)

❌ NEVER contains business logic (just transformation/mapping)
❌ NEVER import from ui/

UI (/modules/[context]/ui/)

Everything React-specific for the bounded context.

✅ Screens, components, hooks specific to the context
✅ ViewModels orchestrate: use cases → stores
✅ Zustand stores for client state
✅ Can import from: core/ (entities, use cases, ports)
✅ Can call an adapter directly for simple CRUD (via React Query)

❌ NEVER business logic in components
❌ NEVER business logic in viewModels (delegate to use cases)

When to use a Use Case vs direct Adapter?

| Situation | Approach | | ----------------------------------------- | ---------------------------- | | Simple fetch, basic CRUD | Direct adapter + React Query | | Business logic, validation, orchestration | Use Case |

// ✅ Simple CRUD → direct adapter
const { itemRepository } = useDependencies();
const query = useQuery({
  queryKey: ["item", id],
  queryFn: () => itemRepository.getById(id),
});

// ✅ Business logic → use case
const { authRepository } = useDependencies();
const result = await new LoginUseCase(authRepository).execute({
  email,
  password,
});

Result Pattern

Explicit handling of successes and errors without exceptions.

// types/Result.ts
type Success<T> = { success: true; data: T };
type Failure<E> = { success: false; error: E };
type Result<T, E = Error> = Success<T> | Failure<E>;

const ok = <T>(data: T): Success<T> => ({ success: true, data });
const fail = <E>(error: E): Failure<E> => ({ success: false, error });

export { Result, Success, Failure, ok, fail };

Usage in a use case:

// modules/authentication/core/usecases/Login.usecase.ts
import { Result, ok, fail } from "@/types/Result";
import { User } from "../entities/User.entity";
import { AuthRepository } from "../ports/AuthRepository.port";
import { AuthError } from "../entities/AuthError.entity";

interface LoginParams {
  email: string;
  password: string;
}

export class LoginUseCase {
  constructor(private authRepository: AuthRepository) {}

  async execute(params: LoginParams): Promise<Result<User, AuthError>> {
    const result = await this.authRepository.login(params);

    if (!result.success) {
      return fail(result.error);
    }

    // Business logic here if needed
    return ok(result.data);
  }
}

Usage in a viewModel:

// modules/authentication/ui/viewModels/useLogin.viewModel.tsx
import { LoginUseCase } from "../../core/usecases/Login.usecase";

export const useLoginViewModel = () => {
  const { authRepository } = useDependencies();
  const [state, setState] = useState<LoginState>({ status: "idle" });

  const handlers = {
    login: async (email: string, password: string) => {
      setState({ status: "loading" });

      const result = await new LoginUseCase(authRepository).execute({
        email,
        password,
      });

      if (result.success) {
        setState({ status: "success", user: result.data });
      } else {
        setState({ status: "error", error: result.error });
      }
    },
  };

  return { state, handlers };
};

React Query

React Query handles server state (remote data, cache, synchronization).

Where to place React Query hooks?

useQuery / useMutation hooks live in the viewModel or in dedicated hooks within the bounded context.

modules/[context]/ui/
├── hooks/
│   ├── useItems.query.ts       # Reusable query
│   └── useCreateItem.mutation.ts
└── viewModels/
    └── useItemList.viewModel.tsx  # Can contain inline queries

Query Keys

Use a factory object for consistency and autocompletion:

// modules/items/ui/hooks/items.queryKeys.ts
export const itemsKeys = {
  all: ["items"] as const,
  lists: () => [...itemsKeys.all, "list"] as const,
  list: (filters: ItemFilters) => [...itemsKeys.lists(), filters] as const,
  details: () => [...itemsKeys.all, "detail"] as const,
  detail: (id: string) => [...itemsKeys.details(), id] as const,
};

Query Hook

// modules/items/ui/hooks/useItem.query.ts
import { useQuery } from "@tanstack/react-query";
import { useDependencies } from "@app/react/useDependencies";
import { itemsKeys } from "./items.queryKeys";

export const useItemQuery = (id: string) => {
  const { itemRepository } = useDependencies();

  return useQuery({
    queryKey: itemsKeys.detail(id),
    queryFn: () => itemRepository.getById(id),
    enabled: !!id,
  });
};

Mutation Hook

// modules/items/ui/hooks/useCreateItem.mutation.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDependencies } from "@app/react/useDependencies";
import { CreateItemUseCase } from "../../core/usecases/CreateItem.usecase";
import { itemsKeys } from "./items.queryKeys";

export const useCreateItemMutation = () => {
  const { itemRepository } = useDependencies();
  const queryClient = useQueryClient();

  const createItemUseCase = new CreateItemUseCase(itemRepository);

  return useMutation({
    mutationFn: (params: CreateItemParams) => createItemUseCase.execute(params),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: itemsKeys.lists() });
    },
  });
};

Usage in a ViewModel

// modules/items/ui/viewModels/useItemList.viewModel.tsx
import { useItemsQuery } from "../hooks/useItems.query";
import { useCreateItemMutation } from "../hooks/useCreateItem.mutation";

export const useItemListViewModel = () => {
  const itemsQuery = useItemsQuery();
  const createItemMutation = useCreateItemMutation();

  const state = {
    items: itemsQuery.data ?? [],
    isLoading: itemsQuery.isLoading,
    error: itemsQuery.error,
  };

  const handlers = {
    createItem: (params: CreateItemParams) => createItemMutation.mutate(params),
    refresh: () => itemsQuery.refetch(),
  };

  return { state, handlers };
};

React Query Conventions

| Rule | Example | | --------------------------------------- | -------------------------------------- | | Query hook naming | use[Entity].query.ts | | Mutation hook naming | use[Action][Entity].mutation.ts | | Query keys naming | [entity].queryKeys.ts | | Always invalidate after mutation | queryClient.invalidateQueries() | | Use case in mutation if business logic | new CreateItemUseCase(...).execute() | | Direct adapter in query if simple fetch | repository.getById(id) |

Dependency Injection

Dependency injection system based on React Context, with environment-based configuration.

Structure

modules/app/
├── dependencies/
│   ├── Dependencies.type.ts      # Dependencies interface
│   ├── dependencies.dev.ts       # Development implementation
│   ├── dependencies.prod.ts      # Production implementation
│   └── dependencies.test-env.ts  # Test implementation
├── react/
│   ├── useDependencies.tsx       # Dependencies access hook
│   └── DependenciesProvider.tsx  # React provider
└── main.ts                       # Application bootstrap

Dependencies.type.ts

Defines the contract of available dependencies in the app:

// modules/app/dependencies/Dependencies.type.ts
import { AuthRepository } from "@modules/authentication/core/ports/AuthRepository.port";
import { ItemRepository } from "@modules/items/core/ports/ItemRepository.port";
import { StorageService } from "@modules/shared/storage/Storage.port";

export interface Dependencies {
  // Repositories
  authRepository: AuthRepository;
  itemRepository: ItemRepository;

  // Services
  storageService: StorageService;
}

Environment-based implementations

// modules/app/dependencies/dependencies.prod.ts
import { Dependencies } from "./Dependencies.type";
import { AuthApiAdapter } from "@modules/authentication/infrastructure/adapters/AuthApi.adapter";
import { ItemApiAdapter } from "@modules/items/infrastructure/adapters/ItemApi.adapter";
import { AsyncStorageAdapter } from "@modules/shared/storage/AsyncStorage.adapter";

export const prodDependencies: Dependencies = {
  authRepository: new AuthApiAdapter(),
  itemRepository: new ItemApiAdapter(),
  storageService: new AsyncStorageAdapter(),
};
// modules/app/dependencies/dependencies.test-env.ts
import { Dependencies } from "./Dependencies.type";
import { AuthInMemoryAdapter } from "@modules/authentication/infrastructure/adapters/AuthInMemory.adapter";
import { ItemInMemoryAdapter } from "@modules/items/infrastructure/adapters/ItemInMemory.adapter";
import { InMemoryStorageAdapter } from "@modules/shared/storage/InMemoryStorage.adapter";

export const testDependencies: Dependencies = {
  authRepository: new AuthInMemoryAdapter(),
  itemRepository: new ItemInMemoryAdapter(),
  storageService: new InMemoryStorageAdapter(),
};

main.ts

Application bootstrap with environment-based dependency selection:

// modules/app/main.ts
import { Dependencies } from "@app/dependencies/Dependencies.type";

export class Main {
  public dependencies: Dependencies;

  constructor() {
    this.dependencies = this.setupDependencies();
  }

  setupDependencies(): Dependencies {
    let importPath;
    let dependencies: Dependencies;

    switch (process.env.NODE_ENV) {
      case "production":
        importPath = require("@app/dependencies/dependencies.prod");
        dependencies = importPath.prodDependencies;
        break;
      case "test":
        importPath = require("@app/dependencies/dependencies.test-env");
        dependencies = importPath.testDependencies;
        break;
      default:
      case "development":
        importPath = require("@app/dependencies/dependencies.dev");
        dependencies = importPath.devDependencies;
        break;
    }

    return dependencies;
  }
}

export const app = new Main();

React Provider

// modules/app/react/DependenciesProvider.tsx
import { createContext, ReactNode } from "react";
import { Dependencies } from "@app/dependencies/Dependencies.type";
import { app } from "@app/main";

export const DependenciesContext = createContext<Dependencies | null>(null);

export const DependenciesProvider = ({
  children,
  dependencies,
}: {
  children: ReactNode;
  dependencies?: Partial<Dependencies>;
}) => (
  <DependenciesContext.Provider
    value={{ ...app.dependencies, ...dependencies }}
  >
    {children}
  </DependenciesContext.Provider>
);

useDependencies Hook

// modules/app/react/useDependencies.tsx
import { useContext } from "react";
import { DependenciesContext } from "./DependenciesProvider";
import { Dependencies } from "@app/dependencies/Dependencies.type";

export const useDependencies = (): Dependencies => {
  const dependencies = useContext(DependenciesContext);

  if (!dependencies) {
    throw new Error("useDependencies must be used within DependenciesProvider");
  }

  return dependencies;
};

Usage

// In a viewModel or hook
const { authRepository, storageService } = useDependencies();

// In a test — partial override, other dependencies remain real
render(
  <DependenciesProvider dependencies={{ authRepository: mockAuthRepo }}>
    <ComponentUnderTest />
  </DependenciesProvider>
);

Workflows

Creating a new feature

Example: "Create an event" feature in the events bounded context

Step 1: Core — Entities

Define business types.

// modules/events/core/entities/Event.entity.ts
export interface Event {
  id: string;
  title: string;
  date: ISO8601;
  organizerId: string;
}

// modules/events/core/entities/EventError.entity.ts
export type EventError =
  | { type: "VALIDATION_ERROR"; message: string }
  | { type: "NETWORK_ERROR" }
  | { type: "UNAUTHORIZED" };

Step 2: Core — Port

Define the repository contract.

// modules/events/core/ports/EventRepository.port.ts
import { Result } from "@/types/Result";
import { Event, EventError } from "../entities/Event.entity";

export interface CreateEventParams {
  title: string;
  date: ISO8601;
}

export interface EventRepository {
  create(params: CreateEventParams): Promise<Result<Event, EventError>>;
  getById(id: string): Promise<Result<Event, EventError>>;
  list(): Promise<Result<Event[], EventError>>;
}

Step 3: Core — Use Case (if business logic needed)

// modules/events/core/usecases/CreateEvent.usecase.ts
import { Result, ok, fail } from "@/types/Result";
import { Event, EventError } from "../entities/Event.entity";
import {
  EventRepository,
  CreateEventParams,
} from "../ports/EventRepository.port";

export class CreateEventUseCase {
  constructor(private eventRepository: EventRepository) {}

  async execute(params: CreateEventParams): Promise<Result<Event, EventError>> {
    // Business validation
    if (params.title.length < 3) {
      return fail({ type: "VALIDATION_ERROR", message: "Title too short" });
    }

    if (new Date(params.date) < new Date()) {
      return fail({
        type: "VALIDATION_ERROR",
        message: "Date must be in future",
      });
    }

    return this.eventRepository.create(params);
  }
}

Step 4: Infrastructure — Adapter

Implement the port.

// modules/events/infrastructure/adapters/EventApi.adapter.ts
import { Result, ok, fail } from "@/types/Result";
import { Event, EventError } from "../../core/entities/Event.entity";
import {
  EventRepository,
  CreateEventParams,
} from "../../core/ports/EventRepository.port";
import { EventApiResponse } from "./EventApiResponse.model";

export class EventApiAdapter implements EventRepository {
  private baseUrl = "https://api.example.com";

  async create(params: CreateEventParams): Promise<Result<Event, EventError>> {
    try {
      const response = await fetch(`${this.baseUrl}/events`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(params),
      });

      if (!response.ok) {
        return fail({ type: "NETWORK_ERROR" });
      }

      const data: EventApiResponse = await response.json();
      return ok(this.mapToEntity(data));
    } catch {
      return fail({ type: "NETWORK_ERROR" });
    }
  }

  private mapToEntity(response: EventApiResponse): Event {
    return {
      id: response.id,
      title: response.title,
      date: response.date,
      organizerId: response.organizer_id, // snake_case → camelCase
    };
  }

  // ... other methods
}

Step 5: Register the dependency

// modules/app/dependencies/Dependencies.type.ts
import { EventRepository } from "@modules/events/core/ports/EventRepository.port";

export interface Dependencies {
  // ... others
  eventRepository: EventRepository;
}

// modules/app/dependencies/dependencies.prod.ts
import { EventApiAdapter } from "@modules/events/infrastructure/adapters/EventApi.adapter";

export const prodDependencies: Dependencies = {
  // ... others
  eventRepository: new EventApiAdapter(),
};

Step 6: UI — Query Keys

// modules/events/ui/hooks/events.queryKeys.ts
export const eventsKeys = {
  all: ["events"] as const,
  lists: () => [...eventsKeys.all, "list"] as const,
  details: () => [...eventsKeys.all, "detail"] as const,
  detail: (id: string) => [...eventsKeys.details(), id] as const,
};

Step 7: UI — Mutation Hook

// modules/events/ui/hooks/useCreateEvent.mutation.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDependencies } from "@app/react/useDependencies";
import { CreateEventUseCase } from "../../core/usecases/CreateEvent.usecase";
import { eventsKeys } from "./events.queryKeys";

export const useCreateEventMutation = () => {
  const { eventRepository } = useDependencies();
  const queryClient = useQueryClient();

  const createEventUseCase = new CreateEventUseCase(eventRepository);

  return useMutation({
    mutationFn: createEventUseCase.execute.bind(createEventUseCase),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: eventsKeys.lists() });
    },
  });
};

Step 8: UI — ViewModel

// modules/events/ui/viewModels/useCreateEvent.viewModel.tsx
import { useState } from "react";
import { useCreateEventMutation } from "../hooks/useCreateEvent.mutation";

interface FormState {
  title: string;
  date: string;
}

export const useCreateEventViewModel = () => {
  const [form, setForm] = useState<FormState>({ title: "", date: "" });
  const mutation = useCreateEventMutation();

  const state = {
    form,
    isLoading: mutation.isPending,
    error: mutation.data?.success === false ? mutation.data.error : null,
  };

  const handlers = {
    setTitle: (title: string) => setForm((f) => ({ ...f, title })),
    setDate: (date: string) => setForm((f) => ({ ...f, date })),
    submit: () => mutation.mutate({ title: form.title, date: form.date }),
  };

  return { state, handlers };
};

Step 9: UI — Screen

// modules/events/ui/screens/CreateEventScreen.tsx
import { useCreateEventViewModel } from "../viewModels/useCreateEvent.viewModel";

export const CreateEventScreen = () => {
  const { state, handlers } = useCreateEventViewModel();

  return (
    <View>
      <TextInput
        value={state.form.title}
        onChangeText={handlers.setTitle}
        placeholder="Event title"
      />
      <TextInput
        value={state.form.date}
        onChangeText={handlers.setDate}
        placeholder="YYYY-MM-DD"
      />
      {state.error && <Text>{state.error.message}</Text>}
      <Button
        title="Create"
        onPress={handlers.submit}
        disabled={state.isLoading}
      />
    </View>
  );
};

Refactoring existing code

Identify violations

  1. Business logic in a component or viewModel → extract to Use Case
  2. Direct API call in a component → extract to Adapter
  3. Inline type or any → create an Entity
  4. Hardcoded dependency → extract to Port + Adapter

Refactoring process

1. Identify the violation
2. Create the target file (use case, adapter, entity)
3. Extract the code
4. Update imports
5. Verify Core doesn't import from UI/Infrastructure
6. Register new dependencies if needed

Example: extracting an API call from a component

Before (violation):

// ❌ Direct API call in component
const EventList = () => {
  const [events, setEvents] = useState([]);

  useEffect(() => {
    fetch("https://api.example.com/events")
      .then((r) => r.json())
      .then(setEvents);
  }, []);
};

After (clean):

// ✅ Adapter + Query + ViewModel
const EventList = () => {
  const { state } = useEventListViewModel();

  return <FlatList data={state.events} />;
};

Code Review Checklist

See references/code-review-checklist.md for the complete checklist.

References