react-clean-architecture
Clean Architecture for React Native (Expo) with TypeScript and Bun. Use this skill when creating features, refactoring code, or reviewing code in React Native projects. Enforces strict separation between Core (domain), Infrastructure (adapters), and UI layers. Implements ports/adapters pattern, Result pattern for errors, and atomic design for components.
$ 安裝
git clone https://github.com/benaor/claude-config /tmp/claude-config && cp -r /tmp/claude-config/.claude/skills/react-clean-architecture ~/.claude/skills/claude-config// tip: Run this command in your terminal to install the skill
name: react-clean-architecture description: Clean Architecture for React Native (Expo) with TypeScript and Bun. Use this skill when creating features, refactoring code, or reviewing code in React Native projects. Enforces strict separation between Core (domain), Infrastructure (adapters), and UI layers. Implements ports/adapters pattern, Result pattern for errors, and atomic design for components.
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
- Business logic in a component or viewModel → extract to Use Case
- Direct API call in a component → extract to Adapter
- Inline type or
any→ create an Entity - 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
- references/file-templates.md — Complete templates for each file type
- references/code-review-checklist.md — Code review checklist
Repository
