Building Scalable React Native Apps: A Guide to Clean Architecture

October 17, 2025

A React Native app’s longevity depends on its architecture. As complexity grows, clear boundaries, testability, and consistent conventions become essential. This guide distills a layered approach, dependency injection, state management choices, component structure, error handling, testing, and performance practices that scale.

Layered Architecture

Separate concerns; each layer owns a single responsibility and communicates via stable interfaces.

The Four Core Layers

1) Presentation (UI)
Screens, navigation, and view components. Organize as:

  • Screens: top-level route components.
  • UI Modules: domain-oriented, reusable view “sections”.
  • UI Kit: primitive, styling-consistent components.

2) Business Logic
Pure app rules and orchestration (custom hooks/services/selectors). No rendering. Stateless where possible; stateful only when necessary.

3) Data Access
APIs, local storage, and adapters. Hides data source details behind interfaces (e.g., UserRepo, EventRepo).

4) Infrastructure
Cross-cutting services (logging, analytics, error tracking, DI container, configuration).

Folder Structure

Keep it domain-driven, predictable, and IDE-friendly.


src/
├─ app/ # App composition (screens, navigators, app-level components)
 ├─ screens/
 ├─ navigators/
 └─ components/
├─ ui-modules/ # Domain-oriented UI building blocks
 └─ [domain]/
 ├─ components/
 ├─ hooks/
 └─ assets/
├─ ui-kit/ # Primitive, reusable components (Button, Input, etc.)
 └─ [Component]/
 ├─ index.ts
 ├─ Component.tsx
 ├─ Component.story.tsx
 └─ Component.spec.ts
├─ common/ # Cross-domain utilities
 ├─ hooks/
 ├─ utils/
 └─ testing/
└─ services/ # Infrastructure & data-access
├─ abstracts/ # Interfaces & base classes
├─ implementations/ # Concrete services
└─ setup/ # DI setup & configuration

Dependency Injection (DI)

DI decouples implementation from usage, improving testability and flexibility.

// services/setup/ServicesContainer.ts
export class ServicesContainer {
  bottle = new Bottle();

  constructor(serviceMap = services) {
    for (const name in serviceMap) {
      const S = serviceMap[name];
      this.bottle.service(name, S, ...(S.inject ?? []));
    }
  }

  resolve<T extends keyof typeof services>(name: T) {
    return this.bottle.container[name] as InstanceType<(typeof services)[T]>;
  }
}
// services/implementations/ApiService.ts
export class ApiService {
  static inject = ["AxiosService", "AppConfigService"] as const;
  constructor(private readonly axios: IAxiosService) {
    this.event = new EventApiService(this.axios);
    this.post = new PostApiService(this.axios);
    this.user = new UserApiService(this.axios);
  }

  readonly event: EventApiService;
  readonly post: PostApiService;
  readonly user: UserApiService;
}

References: Dependency InjectionInversion of Control

State Management

Pick the right tool for the right kind of state.

Global state (Redux Toolkit): app-wide, cross-screen, serializable data (auth, feature flags, settings). Server state (TanStack Query / SWR): remote data that needs caching, background refetch, pagination, retries. Local state (React hooks): component-specific, transient UI.

// State management setup
export const store = configureStore({ reducer: { auth, settings, ui } });
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 60_000, refetchOnWindowFocus: false },
  },
});

References: Redux ToolkitTanStack QuerySWR

Component Architecture

UI Kit supplies primitives; UI Modules compose primitives with domain logic; Screens compose modules and handle navigation.

// ui-kit/Button/Button.tsx
export interface ButtonProps {
  title: string;
  onPress: () => void;
  variant?: "small" | "medium" | "large";
  kind?: "primary" | "secondary" | "outline";
  disabled?: boolean;
}

export const Button = ({
  title,
  onPress,
  variant = "medium",
  kind = "primary",
  disabled,
}: ButtonProps) => (
  <TouchableOpacity onPress={onPress} disabled={disabled}>
    <Text>{title}</Text>
  </TouchableOpacity>
);
// ui-modules/events/CreateBasicInfoSection.tsx
export const CreateBasicInfoSection = ({ form }: { form: any }) => (
  <View>
    <Input
      label="Title"
      value={form.values.title}
      onChangeText={form.handleChange("title")}
    />
    <TextArea
      label="Description"
      value={form.values.description}
      onChangeText={form.handleChange("description")}
    />
  </View>
);

Custom Hooks for Business Logic

Encapsulate reusable logic; keep side effects contained.

// hooks/useGetEvent.ts
export const useGetEvent = (id?: string) =>
  useQuery({
    queryKey: ["event", id],
    queryFn: () => api.events.get(id!),
    enabled: !!id,
  });

Path Aliases

Aliases make imports stable and refactors safe.

// tsconfig.json (paths)
{
  "compilerOptions": {
    "paths": {
      "@ui-kit/*": ["src/ui-kit/*"],
      "@ui-modules/*": ["src/ui-modules/*"],
      "@services/*": ["src/services/*"],
      "@common/*": ["src/common/*"],
      "@app/*": ["src/app/*"]
    }
  }
}
// clean imports
import { Button } from "@ui-kit";
import { useGetEvent } from "@ui-modules/events/hooks/useGetEvent";

Reference: TypeScript Path Mapping

Error Handling

Centralize error display and telemetry; avoid leaking infrastructure into UI.

// common/hooks/useNotification.ts
export const useNotification = () => {
  const toast = useService("ToastService");
  const showUnknownError = useCallback(
    (error: unknown) => {
      const message =
        error instanceof Error ? error.message : "Something went wrong.";
      toast.showError({ title: "Error", subtitle: message });
    },
    [toast]
  );
  return { showUnknownError };
};

Testing Strategy

Layered testing:

  • Unit: reducers, selectors, hooks/services.
  • Integration: screen + module interactions, navigation, query cache.
  • E2E: critical flows on device/simulator.
// Example: component assertions (testing-library)
it("renders required fields", () => {
  render(<CreateEventScreen />);
  expect(screen.getByLabelText("Title")).toBeTruthy();
  expect(screen.getByLabelText("Description")).toBeTruthy();
});

References: React Testing LibraryJest

Performance Guidelines

  • Place data correctly: server data stays in React Query; avoid duplicating into Redux.
  • Memoize expensive derivations (selectors, useMemo).
  • Stabilize props and callbacks (React.memo, useCallback).
  • Lazy-load screens and heavy modules.
  • Split Redux slices to limit re-render scope.
// Reselect example
export const selectDisplayName = createSelector(
  [(s: RootState) => s.auth.user],
  (u) => (u ? `${u.firstName} ${u.lastName}` : "")
);

References: ReselectReact Performance

Decision Checklist

  • Local state: transient, single component/subtree.
  • Server state: remote, cacheable, needs refetch/retry/pagination.
  • Global state: cross-screen app behavior; keep small and serializable.
  • DI: abstract infrastructure; swap implementations in tests.
  • Boundaries: UI ↔ logic ↔ data access are separate and testable.

Conclusion

Clean architecture is about clarity and boundaries. Use layers to isolate concerns, DI to decouple implementations, fit each kind of state to its natural home, and institutionalize testing and performance practices. Keep iterating: architecture is a continuous process, not a one-time decision.


Further Reading