Skip to content

Architecture

Implementing Clean Architecture in React Applications

A practical guide to structuring React applications using the principles of Clean Architecture for better maintainability.

9 min read

Clean Architecture in React is a way to structure your project so that your business logic is independent from the UI and external systems. This makes your app easier to scale, test, and maintain.


The Problem It Solves

Most React components start small. Then they grow.

Here is a component you have probably written:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((response) => response.json())
      .then((data) => {
        // Business rule, buried in a component
        const isPremium = data.plan === "pro" && data.status === "active";
        setUser({ ...data, isPremium });
      });
  }, [userId]);

  return <div>{user?.name}</div>;
}

This component does three jobs at once. It fetches data, it applies a business rule, and it renders.

That creates three real problems:

  • You cannot test the premium rule without rendering a component.
  • You cannot reuse that rule anywhere else.
  • The day the API changes its shape, you edit UI files.

Clean Architecture gives each job its own place.


Core Idea

Instead of mixing everything together (UI, API calls, logic), we separate the app into layers.

Each layer has a clear responsibility and only communicates in a controlled way.


The Dependency Rule

This is the one rule that holds everything together.

Dependencies always point inward. The outer layers know about the inner layers. The inner layers know nothing about the outer ones.

presentation  →  domain  ←  data

In practice it means:

  • The domain layer imports nothing from React, from your API client, or from your database.
  • The presentation layer and the data layer both depend on the domain.
  • The domain defines what it needs, and the data layer provides it.

If you can delete your UI folder and your domain code still compiles, you got it right.


Layers in React Clean Architecture

1. Presentation Layer

This is everything related to the user interface:

  • React components
  • Pages
  • UI state (loading, errors, input handling)

Its job is only to display data and handle user interactions.

It should NOT contain business logic or direct API calls.

function UserProfile({ userId }: { userId: string }) {
  const { user, isLoading } = useUserProfile(userId);

  if (isLoading) return <Spinner />;
  if (!user) return <EmptyState />;

  return <ProfileCard name={user.name} premium={user.isPremium} />;
}

The component now reads like a description of the screen. Nothing else.

2. Domain Layer

This is the core of your application.

It contains:

  • Business logic
  • Use cases (application actions)
  • Entities (core models)

Examples of use cases:

  • getUserProfile
  • createOrder
  • calculateTotalPrice

This layer must NOT depend on React, APIs, or frameworks.

An entity holds the rules that belong to the data itself:

export type User = {
  id: string;
  name: string;
  plan: "free" | "pro";
  status: "active" | "cancelled";
};

export function isPremium(user: User): boolean {
  return user.plan === "pro" && user.status === "active";
}

A use case describes one action of your application:

export function getUserProfile(users: UserRepository) {
  return async (userId: string) => {
    const user = await users.findById(userId);
    if (!user) return null;

    return { ...user, isPremium: isPremium(user) };
  };
}

Notice what the use case does not know. It does not know if the user comes from an API, from a cache, or from a test fixture. It only knows the contract:

export type UserRepository = {
  findById: (id: string) => Promise<User | null>;
};

That contract lives in the domain. The data layer has to follow it.

3. Data Layer

This layer handles communication with external systems:

  • API requests
  • Database access
  • Local storage

It implements repositories that the domain layer uses.

Example:

  • fetchUserFromApi
  • userRepository implementation
export const httpUserRepository: UserRepository = {
  async findById(id) {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) return null;

    const data = await response.json();

    // The API shape stops here. It never leaks into the domain.
    return {
      id: data.user_id,
      name: data.full_name,
      plan: data.subscription_plan,
      status: data.subscription_status,
    };
  },
};

This is where the mapping happens. When the backend renames a field, you change this file and nothing else.


Wiring It Together

The layers meet in a thin adapter. In React, a hook is a good place for it:

export function useUserProfile(userId: string) {
  const loadProfile = useMemo(
    () => getUserProfile(httpUserRepository),
    [],
  );

  const [user, setUser] = useState<UserProfile | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;

    loadProfile(userId).then((profile) => {
      if (!cancelled) {
        setUser(profile);
        setIsLoading(false);
      }
    });

    return () => {
      cancelled = true;
    };
  }, [loadProfile, userId]);

  return { user, isLoading };
}

The hook is the only place that knows both worlds. Everything above it is UI. Everything below it is business logic.


Folder Structure Example

A typical clean architecture structure in React looks like this:

src/
  domain/
    entities/
    usecases/
    repositories/

  data/
    api/
    repositories/

  presentation/
    pages/
    components/
    hooks/
    state/

Why Testing Becomes Easy

This is where the structure pays for itself.

To test the premium rule, you no longer need a rendering library, a fake DOM, or a mocked fetch. You call a function:

test("a cancelled pro user is not premium", async () => {
  const users: UserRepository = {
    findById: async () => ({
      id: "1",
      name: "Ada",
      plan: "pro",
      status: "cancelled",
    }),
  };

  const profile = await getUserProfile(users)("1");

  expect(profile?.isPremium).toBe(false);
});

The whole repository is replaced by four lines. The test runs in milliseconds, and it breaks only when the rule breaks.


When Not To Use It

Clean Architecture has a cost. You write more files, and you jump between them.

It is usually not worth it for:

  • A landing page or a portfolio site.
  • A prototype you plan to throw away.
  • A CRUD screen with no real business rule.

It starts paying off when the rules outlive the UI: several screens sharing the same logic, a backend that keeps changing, a team where more than one person touches the code.

You also do not need all three layers on day one. Extracting your business rules into plain functions already gives you most of the benefit.


Key Takeaways

  • Keep business rules in plain functions, away from components.
  • Let dependencies point inward, toward the domain.
  • Define repository contracts in the domain, implement them in the data layer.
  • Map external shapes at the boundary, so an API change stays in one file.
  • Adopt the layers progressively, when the complexity asks for them.
#React#Clean Architecture