| 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 (entities/ports), Application (commands/queries/usecases with CQS), and Infrastructure (adapters + UI). Implements ports/adapters pattern, ViewModel pattern, 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 • CQS (Commands/Queries) • Use Cases • ViewModel • Atomic Design
Core Principle
Core depends on NOTHING. Application depends only on Core. Infrastructure depends on both.
Infrastructure ──┐
├── ui/ ├──▶ Application ──▶ Core
└── adapters/ ─┘ (commands, (entities,
queries, ports)
usecases)
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/
│ ├── shared/ # Cross-cutting concerns
│ │ ├── [cross-cutting-context]/ # e.g., monitoring, purchases
│ │ │ ├── core/
│ │ │ ├── application/
│ │ │ └── infrastructure/
│ │ │
│ │ └── infrastructure/ # Shared infrastructure
│ │ ├── config/
│ │ │ ├── dependencies/ # DI configuration (centralized)
│ │ │ │ ├── Dependencies.type.ts
│ │ │ │ ├── dependencies.dev.ts
│ │ │ │ ├── dependencies.prod.ts
│ │ │ │ └── dependencies.test-env.ts
│ │ │ └── main.ts # Application bootstrap
│ │ └── ui/
│ │ ├── hooks/
│ │ │ └── useDependencies.tsx
│ │ └── test-utils/ # Test utilities (centralized)
│ │ ├── render.tsx
│ │ └── renderHook.tsx
│ │
│ └── [modules-features]/ # e.g., authentication, events, profile
│ ├── core/ # Pure domain (no external dependencies)
│ │ ├── entities/ # Business types/interfaces
│ │ │ └── User.entity.ts
│ │ ├── errors/ # Domain error types
│ │ │ └── AuthError.error.ts
│ │ └── ports/ # Interfaces (contracts)
│ │ └── AuthRepository.port.ts
│ │
│ ├── application/ # Application layer (CQS + orchestration)
│ │ ├── commands/ # Write operations (mutations)
│ │ │ └── Login.command.ts
│ │ ├── queries/ # Read operations
│ │ │ └── GetUserById.query.ts
│ │ └── usecases/ # Complex multi-step orchestration
│ │ └── CompleteOnboarding.usecase.ts
│ │
│ └── infrastructure/ # External world adapters
│ ├── adapters/ # Port implementations (API, storage)
│ │ └── AuthApi.adapter.ts
│ └── ui/ # React-specific (framework adapter)
│ ├── 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
│
├── constants/ # TestIDs, screen names
├── types/ # General types (ISO8601, DeepPartial)
├── utils/ # Utility functions
│ ├── strings/
│ │ └── firstCharToUppercase.ts
│ └── dates/
│ └── isISO8601Before.ts
└── .claude/ # Claude agent configuration
File Naming Conventions
| Type | Extension | Example |
|---|
| Entity | .entity.ts | User.entity.ts |
| Error | .error.ts | AuthError.error.ts |
| Port | .port.ts | AuthRepository.port.ts |
| Command | .command.ts | Login.command.ts |
| Query | .query.ts | GetUserById.query.ts |
| Use Case | .usecase.ts | CompleteOnboarding.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 domain — entities and ports only, no logic.
✅ Defines entities (types/interfaces)
✅ Defines ports (dependency interfaces)
✅ Can import from: types/, utils/
❌ NEVER import from application/
❌ NEVER import from infrastructure/
❌ NEVER depend on React
Application (/modules/[context]/application/)
Application layer contains all business logic via CQS pattern.
✅ Commands (write operations)
✅ Queries (read operations)
✅ Use Cases (complex orchestration)
✅ Can import from: core/ (ports, entities)
❌ NEVER import from infrastructure/
❌ NEVER depend on React
❌ NEVER call APIs directly (use ports)
Infrastructure (/modules/[context]/infrastructure/)
External world adapters — includes both API adapters and UI.
Adapters (infrastructure/adapters/)
✅ Implements ports defined in Core
✅ Handles API calls, storage, external services
✅ Transforms external data → Core entities
✅ Can import from: core/ (ports, entities)
❌ NEVER contains business logic (just transformation/mapping)
UI (infrastructure/ui/)
React is treated as infrastructure — a framework adapter.
✅ Screens, components, hooks specific to the context
✅ ViewModels orchestrate: application → stores
✅ Zustand stores for client state
✅ Can import from: core/ (entities), application/ (commands, queries, use cases)
✅ Can call an adapter directly for simple CRUD (via React Query)
❌ NEVER business logic in components
❌ NEVER business logic in viewModels (delegate to application layer)
CQS (Command Query Separation)
The application layer follows CQS to separate reads from writes.
Commands (application/commands/)
Mutations only — modify state, return minimal data.
export class CreateEventCommand {
constructor(private eventRepository: EventRepository) {}
async execute(params: CreateEventParams): Promise<{ id: string }> {
if (params.title.length < 3) {
throw new ValidationError("Title too short");
}
return this.eventRepository.create(params);
}
}
Queries (application/queries/)
Read only — never modify state.
export class GetEventByIdQuery {
constructor(private eventRepository: EventRepository) {}
async execute(id: string): Promise<Event | null> {
return this.eventRepository.getById(id);
}
}
Use Cases (application/usecases/)
Complex orchestration — combines multiple commands/queries, multi-step workflows.
export class CompleteOnboardingUseCase {
constructor(
private userRepository: UserRepository,
private emailService: EmailService,
private settingsRepository: SettingsRepository,
) {}
async execute(params: OnboardingParams): Promise<void> {
const user = await new CreateProfileCommand(this.userRepository).execute(
params,
);
await this.emailService.sendWelcome(params.email);
await this.settingsRepository.initializeDefaults(user.id);
}
}
When to use what?
| Situation | Use |
|---|
| Simple read (fetch data) | Query |
| Simple write (create/update/delete) | Command |
| Multi-step workflow | Use Case |
| Complex validation before write | Command |
| Aggregation of multiple reads | Query or Use Case |
When to use Command/Query/UseCase vs direct Adapter?
| Situation | Approach |
|---|
| Simple fetch, basic CRUD | Direct adapter + React Query |
| Write with validation | Command |
| Read with transformation | Query |
| Multi-step workflow | Use Case |
const { itemRepository } = useDependencies();
const query = useQuery({
queryKey: ["item", id],
queryFn: () => itemRepository.getById(id),
});
const { authRepository } = useDependencies();
const result = await new LoginCommand(authRepository).execute({
email,
password,
});
const result = await new CompleteOnboardingUseCase(
userRepository,
emailService,
settingsRepository,
).execute(params);
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 UI layer.
modules/[context]/infrastructure/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:
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
import { useQuery } from "@tanstack/react-query";
import { useDependencies } from "@shared/DI/ui/hooks/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
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDependencies } from "@shared/DI/ui/hooks/useDependencies";
import { CreateItemCommand } from "../../../application/commands/CreateItem.command";
import { itemsKeys } from "./items.queryKeys";
export const useCreateItemMutation = () => {
const { itemRepository } = useDependencies();
const queryClient = useQueryClient();
const createItemCommand = new CreateItemCommand(itemRepository);
return useMutation({
mutationFn: (params: CreateItemParams) => createItemCommand.execute(params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: itemsKeys.lists() });
},
});
};
Usage in a ViewModel
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() |
| Command in mutation if business logic | new CreateItemCommand(...).execute() |
| Direct adapter in query if simple fetch | repository.getById(id) |
Dependency Injection
Dependency injection system based on React Context, with environment-based configuration.
DI is centralized in modules/shared/ and used by all bounded contexts.
Structure
modules/shared/DI/
├── config/
│ ├── dependencies/
│ │ ├── Dependencies.type.ts # Dependencies interface
│ │ ├── dependencies.dev.ts # Development implementation
│ │ ├── dependencies.prod.ts # Production implementation
│ │ └── dependencies.test-env.ts # Test implementation
│ └── main.ts # Application bootstrap
└── ui/
├── hooks/
│ └── useDependencies.tsx # Dependencies access hook + provider
└── test-utils/
├── render.tsx # Custom render with providers
└── renderHook.tsx # Custom renderHook with providers
Dependencies.type.ts
Defines the contract of available dependencies in the app:
import { AuthRepository } from "@modules/authentication/core/ports/AuthRepository.port";
import { ItemRepository } from "@modules/items/core/ports/ItemRepository.port";
import { StorageAdapter } from "@modules/shared/DI/adapters/Storage.adapter";
export interface Dependencies {
authRepository: AuthRepository;
itemRepository: ItemRepository;
storageAdapter: StorageAdapter;
}
Environment-based implementations
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 "../adapters/AsyncStorage.adapter";
export const prodDependencies: Dependencies = {
authRepository: new AuthApiAdapter(),
itemRepository: new ItemApiAdapter(),
storageAdapter: new AsyncStorageAdapter(),
};
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 "../adapters/InMemoryStorage.adapter";
export const testDependencies: Dependencies = {
authRepository: new AuthInMemoryAdapter(),
itemRepository: new ItemInMemoryAdapter(),
storageAdapter: new InMemoryStorageAdapter(),
};
main.ts
Application bootstrap with environment-based dependency selection:
import { Dependencies } from "./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("./dependencies/dependencies.prod");
dependencies = importPath.prodDependencies;
break;
case "test":
importPath = require("./dependencies/dependencies.test-env");
dependencies = importPath.testDependencies;
break;
default:
case "development":
importPath = require("./dependencies/dependencies.dev");
dependencies = importPath.devDependencies;
break;
}
return dependencies;
}
}
export const app = new Main();
useDependencies Hook
import { createContext, useContext, ReactNode } from "react";
import { Dependencies } from "../../config/dependencies/Dependencies.type";
import { app } from "../../config/main";
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>
);
export const useDependencies = (): Dependencies => {
const dependencies = useContext(DependenciesContext);
if (!dependencies) {
throw new Error("useDependencies must be used within DependenciesProvider");
}
return dependencies;
};
Usage
const { authRepository, storageAdapter } = useDependencies();
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.
export interface Event {
id: string;
title: string;
date: ISO8601;
organizerId: string;
}
export type EventError =
| { type: "VALIDATION_ERROR"; message: string }
| { type: "NETWORK_ERROR" }
| { type: "UNAUTHORIZED" };
Step 2: Core — Port
Define the repository contract.
import { Event } from "../entities/Event.entity";
export interface CreateEventParams {
title: string;
date: ISO8601;
}
export interface EventRepository {
create(params: CreateEventParams): Promise<Event>;
getById(id: string): Promise<Event | null>;
list(): Promise<Event[]>;
}
Step 3: Application — Command
Create the command with business validation.
import { Event } from "../../core/entities/Event.entity";
import { ValidationError } from "../../core/errors/ValidationError.error";
import {
EventRepository,
CreateEventParams,
} from "../../core/ports/EventRepository.port";
export class CreateEventCommand {
constructor(private eventRepository: EventRepository) {}
async execute(params: CreateEventParams): Promise<Event> {
if (params.title.length < 3) {
throw new ValidationError("Title too short");
}
if (new Date(params.date) < new Date()) {
throw new ValidationError("Date must be in future");
}
return this.eventRepository.create(params);
}
}
Step 4: Infrastructure — Adapter
Implement the port.
import { Event } from "../../core/entities/Event.entity";
import { NetworkError } from "../../core/errors/NetworkError.error";
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<Event> {
const response = await fetch(`${this.baseUrl}/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (!response.ok) {
throw new NetworkError("Failed to create event");
}
const data: EventApiResponse = await response.json();
return this.mapToEntity(data);
}
private mapToEntity(response: EventApiResponse): Event {
return {
id: response.id,
title: response.title,
date: response.date,
organizerId: response.organizer_id,
};
}
}
Step 5: Register the dependency
import { EventRepository } from "@modules/events/core/ports/EventRepository.port";
export interface Dependencies {
eventRepository: EventRepository;
}
import { EventApiAdapter } from "@modules/events/infrastructure/adapters/EventApi.adapter";
export const prodDependencies: Dependencies = {
eventRepository: new EventApiAdapter(),
};
Step 6: Infrastructure/UI — Query Keys
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: Infrastructure/UI — Mutation Hook
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDependencies } from "@shared/DI/ui/hooks/useDependencies";
import { CreateEventCommand } from "../../../application/commands/CreateEvent.command";
import { eventsKeys } from "./events.queryKeys";
export const useCreateEventMutation = () => {
const { eventRepository } = useDependencies();
const queryClient = useQueryClient();
const createEventCommand = new CreateEventCommand(eventRepository);
return useMutation({
mutationFn: createEventCommand.execute.bind(createEventCommand),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: eventsKeys.lists() });
},
});
};
Step 8: Infrastructure/UI — ViewModel
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.error,
};
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: Infrastructure/UI — Screen
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 Command/Query/UseCase in
application/
- Direct API call in a component → extract to Adapter in
infrastructure/adapters/
- Inline type or
any → create an Entity in core/entities/
- Hardcoded dependency → extract to Port in
core/ports/ + Adapter
Refactoring process
1. Identify the violation
2. Create the target file (command, query, use case, adapter, entity)
3. Extract the code
4. Update imports
5. Verify Core doesn't import from Application/Infrastructure
6. Verify Application doesn't import from Infrastructure
7. Register new dependencies if needed
Example: extracting an API call from a component
Before (violation):
const EventList = () => {
const [events, setEvents] = useState([]);
useEffect(() => {
fetch("https://api.example.com/events")
.then((r) => r.json())
.then(setEvents);
}, []);
};
After (clean):
const EventList = () => {
const { state } = useEventListViewModel();
return <FlatList data={state.events} />;
};
Code Review Checklist
See references/code-review-checklist.md for the complete checklist.
References