Skip to main content

πŸ—οΈ Lesson 8.5: Architecture Best Practices

Welcome to the final lesson of Module 8! Great code isn't just about writing featuresβ€”it's about building applications that scale, adapt to change, and remain maintainable as your team and codebase grow. In this comprehensive lesson, you'll learn professional architecture patterns that separate great React applications from good ones. Whether you're building a small side project or an enterprise application, these architectural principles will serve you throughout your career as a React developer.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Understand the principles of scalable React architecture
  • Implement feature-based folder structures
  • Organize components by responsibility and reusability
  • Apply separation of concerns to React applications
  • Extract business logic into custom hooks
  • Design service layers for API communication
  • Structure TypeScript types and interfaces effectively
  • Implement proper error handling strategies
  • Design for testability from the start
  • Make architectural decisions that support growth

Estimated Time: 75-90 minutes

Prerequisites: Lessons 8.1-8.4 (State Management, Zustand, Redux Toolkit, React Query), All previous modules

In This Lesson

🎯 Principles of Good Architecture

Think of architecture like the framing of a house. 🏠 Nobody walks into a finished home and admires the studs behind the drywall β€” but get the framing wrong and every room you add later leans a little more, until one day the whole thing is too fragile to change. Software architecture is the framing behind your features: invisible when it's right, painfully obvious when it's wrong. Before we reach for specific patterns, let's ground ourselves in the principles that hold everything up. These apply whether you're building a weekend side project or an app that a hundred engineers touch every day.

The Core Principles

πŸ“– What "Good" Actually Means

Good architecture is about making your codebase easy to understand, modify, and extend. It prioritizes maintainability over cleverness, clarity over brevity, and consistency over individual preferences. The real metric is cognitive load: how much you have to hold in your head to safely make a change. Every principle below is really just a different way of shrinking that mental burden β€” for the teammate reviewing your PR, and for the future version of you who has completely forgotten how this code works.

1. Separation of Concerns

Each part of your application should have a single, well-defined responsibility. Picture a restaurant kitchen: the person plating dessert isn't also taking orders and washing dishes. Each station does one job well, and the meal comes together because responsibilities are cleanly divided. React code works the same way β€” when components, hooks, and services each focus on one thing, they become dramatically easier to understand, test, and reuse. The example below shows the same feature written two ways: one where a single component tries to be the whole kitchen, and one where the work is split into stations.

// ❌ BAD - Component doing too much
function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, []);
  
  const saveUser = (data) => {
    fetch('/api/user', {
      method: 'PUT',
      body: JSON.stringify(data)
    }).then(/* ... */);
  };
  
  return (
    <div>
      {loading ? <Spinner /> : (
        <form onSubmit={/* validation logic */}>
          {/* lots of form fields and UI logic */}
        </form>
      )}
    </div>
  );
}

// βœ… GOOD - Separated concerns
function UserProfile() {
  const { user, isLoading } = useUser();
  
  return (
    <div>
      {isLoading ? <Spinner /> : <UserForm user={user} />}
    </div>
  );
}

// Custom hook handles data fetching
function useUser() {
  return useQuery({
    queryKey: ['user'],
    queryFn: userService.getProfile
  });
}

// Service handles API calls
const userService = {
  getProfile: () => fetch('/api/user').then(r => r.json()),
  updateProfile: (data) => fetch('/api/user', { 
    method: 'PUT', 
    body: JSON.stringify(data) 
  })
};

// Component handles only UI and user interactions
function UserForm({ user }: { user: User }) {
  const updateMutation = useUpdateUser();
  // Form logic...
}
graph TB A[UserProfile Component] --> B[Presentation Layer] A --> C[Data Layer useUser] A --> D[Business Logic Layer] C --> E[Service Layer] E --> F[API Calls] B --> G[UserForm Component] B --> H[Spinner Component] D --> I[Custom Hooks] I --> J[useUpdateUser] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style B fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style C fill:#764ba2,stroke:#333,stroke-width:2px,color:#fff style D fill:#e91e63,stroke:#333,stroke-width:2px,color:#fff style E fill:#0288d1,stroke:#333,stroke-width:2px,color:#fff

2. DRY (Don't Repeat Yourself)

Duplicated code multiplies maintenance work and increases the chance of bugs. Extract common logic into reusable functions, hooks, and components.

// ❌ BAD - Repeated logic
function UsersList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    setLoading(true);
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, []);
  
  // ... component JSX
}

function PostsList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    setLoading(true);
    fetch('/api/posts')
      .then(res => res.json())
      .then(data => {
        setPosts(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, []);
  
  // ... component JSX
}

// βœ… GOOD - Reusable hook
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    let cancelled = false;
    
    setLoading(true);
    fetch(url)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setData(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });
    
    return () => { cancelled = true; };
  }, [url]);
  
  return { data, loading, error };
}

// Now both components are simple
function UsersList() {
  const { data: users, loading, error } = useFetch<User[]>('/api/users');
  // ... component JSX
}

function PostsList() {
  const { data: posts, loading, error } = useFetch<Post[]>('/api/posts');
  // ... component JSX
}

3. KISS (Keep It Simple, Stupid)

Simple solutions are easier to understand, test, and maintain. Avoid over-engineering and premature optimization.

βœ… Signs of Good Simplicity

  • A new team member can understand a component in under 5 minutes
  • Testing feels natural, not like fighting the code
  • Naming clearly describes what something does
  • You don't need comments to explain "why" (but you might need them for "what")
  • Changes in one area rarely require changes in unrelated areas

4. Single Responsibility Principle

Each component, function, or module should have one reason to change. This makes code predictable and reduces the ripple effect of modifications.

// ❌ BAD - Component has multiple responsibilities
function UserDashboard() {
  return (
    <div>
      {/* Analytics tracking logic */}
      {/* User data fetching */}
      {/* Navigation rendering */}
      {/* User profile display */}
      {/* Settings management */}
      {/* Notifications handling */}
    </div>
  );
}

// βœ… GOOD - Each component has one responsibility
function UserDashboard() {
  useAnalytics('dashboard-view');
  const { user } = useUser();
  
  return (
    <DashboardLayout>
      <UserHeader user={user} />
      <UserStats userId={user.id} />
      <RecentActivity userId={user.id} />
      <QuickActions />
    </DashboardLayout>
  );
}

5. Composition Over Inheritance

React's component model encourages composition. Build complex UIs by combining simple, focused components rather than creating deep inheritance hierarchies.

// βœ… GOOD - Composition pattern
function Card({ children }: { children: React.ReactNode }) {
  return <div className="card">{children}</div>;
}

function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="card-header">{children}</div>;
}

function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="card-body">{children}</div>;
}

// Compose them together
function UserCard({ user }: { user: User }) {
  return (
    <Card>
      <CardHeader>
        <h2>{user.name}</h2>
      </CardHeader>
      <CardBody>
        <p>{user.bio}</p>
      </CardBody>
    </Card>
  );
}

6. Separate Server State from Client State

This is the single most important architectural shift in modern React, and it's worth pausing on. For years we jammed everything into useState and Redux β€” form inputs, modal toggles, and the list of users fetched from the API β€” as if it were all the same kind of data. It isn't.

  • πŸ–₯️ Client state is data your app owns and fully controls: is the sidebar open? what's typed in this input? which tab is active? It's synchronous, it lives only in the browser, and you are the source of truth.
  • 🌐 Server state is a borrowed copy of data that actually lives on a server: the user list, a blog post, the shopping cart. It's asynchronous, it can go stale the moment someone else changes it, and the server β€” not your app β€” is the source of truth.

Treating borrowed server data like owned client state is why so many older codebases drown in isLoading flags, manual cache invalidation, and "why is this list out of date?" bugs. The modern answer is to stop hand-rolling that machinery and let a dedicated server-state library handle caching, refetching, and staleness for you.

graph TB A[Application State] --> B[Client State] A --> C[Server State] B --> B1["UI toggles, form inputs, filters"] B --> B2["Tools: useState, useReducer, Zustand, Redux Toolkit"] C --> C1["API data: users, posts, cart"] C --> C2["Tools: TanStack Query, RTK Query"] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style B fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style C fill:#0288d1,stroke:#333,stroke-width:2px,color:#fff

πŸ’‘ The 2026 Default

Reach for TanStack Query (formerly React Query) or RTK Query for anything that comes from an API, and keep a lightweight client-state tool like Zustand (or plain useState/useReducer) for the genuinely local stuff. You covered these tools in Lessons 8.1–8.4 β€” this lesson is about wiring them together cleanly. The rule of thumb: if the data could change on the server without your app doing anything, it's server state.

7. Design for Testability

A good architecture test: if a component is hard to test, it's probably doing too much. This isn't just a nice-to-have β€” testability is a diagnostic. When you find yourself mocking six things just to render one component, that pain is the code telling you its concerns are tangled. Pull the data fetching into a hook you can test in isolation, keep the presentational component a pure function of its props, and suddenly both halves become trivial to test. We call these clean boundaries testing seams: the natural places where you can slot in a fake and verify one layer without dragging in the whole app.

πŸ’‘ The Testing Principle

Difficulty testing is a signal, not an inconvenience. Before you write an elaborate mock, ask whether the component should be split so that the tricky part lives behind a seam β€” a hook, a service, or a prop β€” that you can swap out cleanly.

Architecture Anti-Patterns to Avoid

Anti-Pattern Problem Solution
God Components Components that do everything Break into smaller, focused components
Prop Drilling Passing props through many layers Use Context, state management, or composition
Mixed Concerns Business logic in components Extract to custom hooks or services
Tight Coupling Components depend on implementation details Use interfaces and dependency injection
Premature Abstraction Creating abstractions before needed Wait for the third use case

πŸ“ Feature-Based Folder Structure

Here's a question that reveals a lot about a codebase: when you sit down to change the "users" feature, how many folders do you have to open? In a small app, organizing files by type β€” all components in one folder, all hooks in another, all services in a third β€” feels tidy. But as the app grows, that tidiness turns on you. A single feature ends up smeared across four or five folders, and every change becomes a scavenger hunt.

Think of it like organizing a kitchen. 🍳 You could put all the handles in one drawer, all the blades in another, all the electrical cords in a third β€” sorted perfectly by type. But when you want to make an omelette, you'd be running all over the kitchen. Instead, you keep the things you use together, together. A feature-based structure does exactly this: it colocates everything a feature needs β€” its components, hooks, service calls, and types β€” so you can understand and change a feature as one complete unit.

Traditional vs Feature-Based Structure

❌ Type-Based (Doesn't Scale)

src/
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ UserList.tsx
β”‚   β”œβ”€β”€ UserCard.tsx
β”‚   β”œβ”€β”€ UserForm.tsx
β”‚   β”œβ”€β”€ PostList.tsx
β”‚   β”œβ”€β”€ PostCard.tsx
β”‚   β”œβ”€β”€ PostForm.tsx
β”‚   β”œβ”€β”€ CommentList.tsx
β”‚   └── ...50+ more
β”œβ”€β”€ hooks/
β”‚   β”œβ”€β”€ useUsers.ts
β”‚   β”œβ”€β”€ usePosts.ts
β”‚   β”œβ”€β”€ useComments.ts
β”‚   └── ...30+ more
β”œβ”€β”€ services/
β”‚   β”œβ”€β”€ userService.ts
β”‚   β”œβ”€β”€ postService.ts
β”‚   └── ...20+ more
└── types/
    β”œβ”€β”€ user.ts
    β”œβ”€β”€ post.ts
    └── ...20+ more

βœ… Feature-Based (Scales Well)

src/
β”œβ”€β”€ features/
β”‚   β”œβ”€β”€ users/
β”‚   β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”‚   β”œβ”€β”€ UserList.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ UserCard.tsx
β”‚   β”‚   β”‚   └── UserForm.tsx
β”‚   β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   β”‚   └── useUsers.ts
β”‚   β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”‚   └── userService.ts
β”‚   β”‚   β”œβ”€β”€ types/
β”‚   β”‚   β”‚   └── user.ts
β”‚   β”‚   └── index.ts
β”‚   β”œβ”€β”€ posts/
β”‚   β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   β”œβ”€β”€ api/
β”‚   β”‚   └── types/
β”‚   └── comments/
β”‚       └── ...
└── shared/
    β”œβ”€β”€ components/
    β”œβ”€β”€ hooks/
    └── utils/

Complete Feature-Based Architecture

src/
β”œβ”€β”€ features/                    # Feature modules
β”‚   β”œβ”€β”€ auth/                   # Authentication feature
β”‚   β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”‚   β”œβ”€β”€ LoginForm.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ RegisterForm.tsx
β”‚   β”‚   β”‚   └── ProtectedRoute.tsx
β”‚   β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   β”‚   β”œβ”€β”€ useAuth.ts
β”‚   β”‚   β”‚   └── useLogin.ts
β”‚   β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”‚   └── authService.ts
β”‚   β”‚   β”œβ”€β”€ store/             # Feature-specific state
β”‚   β”‚   β”‚   └── authStore.ts
β”‚   β”‚   β”œβ”€β”€ types/
β”‚   β”‚   β”‚   └── auth.types.ts
β”‚   β”‚   β”œβ”€β”€ utils/
β”‚   β”‚   β”‚   └── tokenManager.ts
β”‚   β”‚   └── index.ts           # Public API
β”‚   β”‚
β”‚   β”œβ”€β”€ users/                 # Users feature
β”‚   β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”‚   β”œβ”€β”€ UserList/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ UserList.tsx
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ UserList.test.tsx
β”‚   β”‚   β”‚   β”‚   └── UserList.module.css
β”‚   β”‚   β”‚   β”œβ”€β”€ UserCard/
β”‚   β”‚   β”‚   └── UserProfile/
β”‚   β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   β”‚   β”œβ”€β”€ useUsers.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ useUser.ts
β”‚   β”‚   β”‚   └── useUpdateUser.ts
β”‚   β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”‚   └── userService.ts
β”‚   β”‚   β”œβ”€β”€ types/
β”‚   β”‚   β”‚   └── user.types.ts
β”‚   β”‚   └── index.ts
β”‚   β”‚
β”‚   β”œβ”€β”€ posts/
β”‚   β”œβ”€β”€ comments/
β”‚   └── dashboard/
β”‚
β”œβ”€β”€ shared/                     # Shared across features
β”‚   β”œβ”€β”€ components/            # Reusable UI components
β”‚   β”‚   β”œβ”€β”€ Button/
β”‚   β”‚   β”œβ”€β”€ Input/
β”‚   β”‚   β”œβ”€β”€ Card/
β”‚   β”‚   └── Modal/
β”‚   β”œβ”€β”€ hooks/                 # Reusable hooks
β”‚   β”‚   β”œβ”€β”€ useLocalStorage.ts
β”‚   β”‚   β”œβ”€β”€ useDebounce.ts
β”‚   β”‚   └── useMediaQuery.ts
β”‚   β”œβ”€β”€ utils/                 # Utility functions
β”‚   β”‚   β”œβ”€β”€ formatters.ts
β”‚   β”‚   β”œβ”€β”€ validators.ts
β”‚   β”‚   └── helpers.ts
β”‚   β”œβ”€β”€ types/                 # Shared types
β”‚   β”‚   └── common.types.ts
β”‚   └── constants/
β”‚       └── config.ts
β”‚
β”œβ”€β”€ lib/                       # Third-party setup
β”‚   β”œβ”€β”€ react-query.ts        # React Query config
β”‚   β”œβ”€β”€ axios.ts              # Axios instance
β”‚   └── i18n.ts               # Internationalization
β”‚
β”œβ”€β”€ pages/                     # Route components (or routes/)
β”‚   β”œβ”€β”€ HomePage.tsx
β”‚   β”œβ”€β”€ UsersPage.tsx
β”‚   β”œβ”€β”€ UserDetailPage.tsx
β”‚   └── NotFoundPage.tsx
β”‚
β”œβ”€β”€ layouts/                   # Layout components
β”‚   β”œβ”€β”€ MainLayout.tsx
β”‚   β”œβ”€β”€ AuthLayout.tsx
β”‚   └── DashboardLayout.tsx
β”‚
β”œβ”€β”€ styles/                    # Global styles
β”‚   β”œβ”€β”€ globals.css
β”‚   β”œβ”€β”€ variables.css
β”‚   └── theme.css
β”‚
β”œβ”€β”€ App.tsx                    # Root component
β”œβ”€β”€ main.tsx                   # Entry point
└── vite-env.d.ts             # Vite types

βœ… Benefits of Feature-Based Structure

  • Scalability: Add new features without cluttering existing folders
  • Maintainability: All related code lives together
  • Team collaboration: Multiple devs can work on different features
  • Code ownership: Clear boundaries for feature teams
  • Easier refactoring: Feature can be moved or removed as a unit
  • Better imports: Feature's public API through index.ts

Feature Module Example

// features/users/index.ts - Public API
export { UserList } from './components/UserList';
export { UserCard } from './components/UserCard';
export { useUsers, useUser } from './hooks';
export type { User, UserFilters } from './types/user.types';

// Other files import from the feature's public API
import { UserList, useUsers, type User } from '@/features/users';

// NOT from internal paths
// ❌ DON'T: import { UserList } from '@/features/users/components/UserList/UserList';

That index.ts file is called a barrel β€” it re-exports a feature's public surface from one convenient place. Barrels are genuinely useful at the boundary of a feature: they let the rest of the app import from @/features/users without knowing or caring how the folder is laid out inside. That's real encapsulation, and it means you can rename or reorganize a feature's internals without breaking a single import elsewhere.

⚠️ Use Barrels Deliberately, Not Everywhere

Barrel files have a well-known dark side. A few caveats worth internalizing:

  • Circular imports: if two files in the same feature both import through the barrel, they can form an import cycle that throws a confusing "cannot access before initialization" error at runtime. Inside a feature, import from the sibling file directly; only outside code should go through the barrel.
  • Tree-shaking & build cost: a giant barrel that re-exports everything can pull far more into a bundle than you intended and slow down cold builds and test startup. Keep barrels thin β€” export the feature's public API, not every internal helper.
  • Don't nest barrels deeply: one barrel per feature boundary is plenty. Barrels that re-export other barrels that re-export other barrels are how you get mysterious circular dependencies.

Path Aliases: Killing the ../../../ Problem

You've probably noticed the @/ in those imports. That's a path alias, and it's the difference between a clean import { userService } from '@/features/users' and the dreaded import { userService } from '../../../features/users' that breaks the instant you move a file. You configure it in two places β€” TypeScript (for type-checking and editor navigation) and your bundler (for the actual build):

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

πŸ’‘ Why both files?

TypeScript's paths only teaches the compiler and your editor what @/ means β€” it does not rewrite imports in the output. The bundler (Vite here, or the equivalent in Next.js / your tooling) needs its own matching alias so the actual build can resolve the path. Keep the two in sync and "go to definition" works while your production bundle still finds the file.

Shared vs Feature-Specific

Deciding what goes in shared/ vs features/:

Put in shared/ Put in feature/
Used by 3+ features Used by only 1-2 features
Generic UI components (Button, Input) Domain-specific components (UserCard)
Generic utilities (formatDate, debounce) Domain logic (calculateUserScore)
No business logic Contains business logic
Could be open-sourced Specific to this app

⚠️ Avoid Premature Sharing

When creating a new component, start in the feature folder. Move it to shared/ only when you actually need it in a second place. The "Rule of Three" applies: extract to shared when you need it the third time.

🧩 Component Organization Patterns

Not all components are created equal. A component that owns a route and coordinates a whole page plays a very different role from a tiny reusable button β€” and treating them the same way is how folders turn to mush. Understanding these roles is like understanding the ranks in a kitchen brigade: the head chef (page) plans the meal, station chefs (feature containers) run their sections, and line cooks (presentational components) execute one dish well. When everyone knows their job, the kitchen runs smoothly.

The Component Hierarchy

graph TB A[Pages/Routes] --> B[Features] A --> C[Layouts] B --> D[Container Components] D --> E[Presentational Components] E --> F[UI Components] F --> G[Shared Components] C --> F style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style B fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style D fill:#764ba2,stroke:#333,stroke-width:2px,color:#fff style E fill:#e91e63,stroke:#333,stroke-width:2px,color:#fff style F fill:#0288d1,stroke:#333,stroke-width:2px,color:#fff style G fill:#f57c00,stroke:#333,stroke-width:2px,color:#fff

πŸ’‘ A Note on "Container vs Presentational" in 2026

The strict container/presentational split you'll see below was coined back when class components and Redux connect() ruled React. With Hooks, the modern version is subtler: instead of a wrapper component whose only job is to fetch data, you extract that logic into a custom hook and call it right inside the component that needs it. The principle β€” separate "how we get the data" from "how we show it" β€” is as vital as ever; the mechanism is now a hook, not a wrapper. We show the explicit container form here because it makes the roles crystal clear, but in real code a single component plus a well-named hook usually does the job.

1. Page Components (Route Components)

Top-level components that correspond to routes. They orchestrate feature components and manage page-level concerns.

// pages/UsersPage.tsx
import { UserList } from '@/features/users';
import { PageHeader } from '@/shared/components';
import { MainLayout } from '@/layouts';

export function UsersPage() {
  return (
    <MainLayout>
      <PageHeader 
        title="Users" 
        description="Manage all users in your application"
      />
      <UserList />
    </MainLayout>
  );
}

// Characteristics:
// - Maps to a route
// - Minimal logic (mostly composition)
// - Uses layouts and features
// - Handles page-level data fetching
// - Sets page metadata (title, etc.)

2. Container Components (Smart Components)

Manage state, data fetching, and business logic. They connect to stores, hooks, and APIs, then pass data down to presentational components.

// features/users/components/UserList/UserListContainer.tsx
import { useUsers } from '../../hooks/useUsers';
import { UserListView } from './UserListView';

export function UserListContainer() {
  const { 
    users, 
    isLoading, 
    error, 
    refetch 
  } = useUsers();
  
  const [filters, setFilters] = useState<UserFilters>({});
  
  const handleFilterChange = (newFilters: UserFilters) => {
    setFilters(newFilters);
  };
  
  const filteredUsers = useMemo(() => {
    return users?.filter(user => {
      // Filtering logic
      return true;
    });
  }, [users, filters]);
  
  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} onRetry={refetch} />;
  
  return (
    <UserListView
      users={filteredUsers}
      filters={filters}
      onFilterChange={handleFilterChange}
    />
  );
}

// Characteristics:
// - Manages state and side effects
// - Connects to data sources
// - Contains business logic
// - Handles loading and error states
// - Passes data to presentational components

3. Presentational Components (Dumb Components)

Focus purely on rendering UI. They receive data through props and notify parents of user interactions through callback props.

// features/users/components/UserList/UserListView.tsx
interface UserListViewProps {
  users: User[];
  filters: UserFilters;
  onFilterChange: (filters: UserFilters) => void;
}

export function UserListView({ 
  users, 
  filters, 
  onFilterChange 
}: UserListViewProps) {
  return (
    <div className="user-list">
      <UserFilters 
        filters={filters} 
        onChange={onFilterChange} 
      />
      
      <div className="user-grid">
        {users.map(user => (
          <UserCard key={user.id} user={user} />
        ))}
      </div>
    </div>
  );
}

// Characteristics:
// - Pure presentation logic
// - No state or side effects
// - Easy to test
// - Reusable with different data
// - Can be used in Storybook

4. UI Components (Shared Components)

Generic, reusable components that have no knowledge of business logic. These live in the shared/components/ folder and can be used across features.

// shared/components/Button/Button.tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
  loading?: boolean;
  children: React.ReactNode;
}

export function Button({
  variant = 'primary',
  size = 'medium',
  loading = false,
  disabled,
  children,
  className = '',
  ...props
}: ButtonProps) {
  const baseClasses = 'btn';
  const variantClass = `btn-${variant}`;
  const sizeClass = `btn-${size}`;
  
  return (
    <button
      className={`${baseClasses} ${variantClass} ${sizeClass} ${className}`}
      disabled={disabled || loading}
      {...props}
    >
      {loading ? <Spinner size="small" /> : children}
    </button>
  );
}

// Characteristics:
// - Generic and reusable
// - No business logic
// - Highly configurable through props
// - Can be documented in Storybook
// - Could be published as a library

πŸ’‘ Component Organization Summary

Component Type Responsibility Location
Page Route orchestration pages/
Layout Shared page structure layouts/
Feature Domain-specific features features/{feature}/components/
Container State & data management features/{feature}/components/
Presentational Pure UI rendering features/{feature}/components/
Shared UI Generic reusable UI shared/components/

πŸ”€ Separation of Concerns

We touched on separation of concerns as a principle earlier; now let's make it concrete. The idea is to organize code into layers, each with a clear job, stacked so that each one only talks to the layer directly below it. Think of a restaurant again: the dining room (presentation) never walks into the walk-in freezer (data access) to grab ingredients β€” it sends an order through the kitchen (business logic), which decides what's needed and requests it from the pantry. Each layer trusts the next to do its job and doesn't reach past it. That discipline is what lets you swap out your entire API client later without touching a single button.

The Three Layers

graph TB A[Presentation Layer] --> B[Business Logic Layer] B --> C[Data Access Layer] A1[Components] --> A A2[JSX/UI] --> A B1[Custom Hooks] --> B B2[Utility Functions] --> B B3[Validation] --> B C1[API Services] --> C C2[TanStack Query] --> C C3[State Management] --> C style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style B fill:#764ba2,stroke:#333,stroke-width:2px,color:#fff style C fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff

1. Presentation Layer (Components)

Handles only rendering and user interactions. No business logic or data fetching.

// βœ… GOOD - Pure presentation
function ProductCard({ product, onAddToCart }: ProductCardProps) {
  return (
    <Card>
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price.toFixed(2)}</p>
      <Button onClick={() => onAddToCart(product.id)}>
        Add to Cart
      </Button>
    </Card>
  );
}

// ❌ BAD - Mixed concerns
function ProductCard({ productId }: { productId: string }) {
  const [product, setProduct] = useState(null);
  const [cart, setCart] = useState([]);
  
  useEffect(() => {
    // Data fetching in component
    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(setProduct);
  }, [productId]);
  
  const addToCart = () => {
    // Business logic in component
    if (cart.length >= 10) {
      alert('Cart is full!');
      return;
    }
    
    const tax = product.price * 0.08;
    const total = product.price + tax;
    
    setCart([...cart, { ...product, total }]);
    
    // Data mutation in component
    fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId })
    });
  };
  
  return (/* ... */);
}

2. Business Logic Layer (Custom Hooks)

Encapsulates business rules, calculations, and state management logic.

// hooks/useCart.ts
export function useCart() {
  const [cart, setCart] = useState<CartItem[]>([]);
  
  const addToCart = useCallback((product: Product) => {
    // Business rule: Max 10 items
    if (cart.length >= 10) {
      throw new Error('Cart is full. Maximum 10 items allowed.');
    }
    
    // Business logic: Calculate totals
    const tax = calculateTax(product.price);
    const total = product.price + tax;
    
    const cartItem: CartItem = {
      ...product,
      tax,
      total,
      addedAt: new Date()
    };
    
    setCart(prev => [...prev, cartItem]);
    return cartItem;
  }, [cart.length]);
  
  const removeFromCart = useCallback((itemId: string) => {
    setCart(prev => prev.filter(item => item.id !== itemId));
  }, []);
  
  const cartTotal = useMemo(() => {
    return cart.reduce((sum, item) => sum + item.total, 0);
  }, [cart]);
  
  const itemCount = cart.length;
  const isFull = itemCount >= 10;
  
  return {
    cart,
    cartTotal,
    itemCount,
    isFull,
    addToCart,
    removeFromCart
  };
}

// Utility function for business logic
function calculateTax(price: number): number {
  const TAX_RATE = 0.08;
  return price * TAX_RATE;
}

3. Data Access Layer (Services)

Handles all communication with external systems (APIs, localStorage, etc.).

// services/cartService.ts
import { apiClient } from '@/lib/axios';

export const cartService = {
  async getCart(userId: string): Promise<CartItem[]> {
    const response = await apiClient.get(`/users/${userId}/cart`);
    return response.data;
  },
  
  async addToCart(userId: string, productId: string): Promise<CartItem> {
    const response = await apiClient.post(`/users/${userId}/cart`, {
      productId
    });
    return response.data;
  },
  
  async removeFromCart(userId: string, itemId: string): Promise<void> {
    await apiClient.delete(`/users/${userId}/cart/${itemId}`);
  },
  
  async clearCart(userId: string): Promise<void> {
    await apiClient.delete(`/users/${userId}/cart`);
  }
};

Putting It All Together

// Component uses hook (business logic)
function ProductPage({ productId }: { productId: string }) {
  const { product } = useProduct(productId);
  const { addToCart, isFull } = useCart();
  
  const handleAddToCart = async () => {
    try {
      await addToCart(product);
      toast.success('Added to cart!');
    } catch (error) {
      // Under strict TS, a caught error is `unknown` β€” narrow before use
      toast.error(error instanceof Error ? error.message : 'Could not add to cart');
    }
  };
  
  return (
    <ProductCard
      product={product}
      onAddToCart={handleAddToCart}
      disabled={isFull}
    />
  );
}

// Hook uses service (data access)
function useCart() {
  const { user } = useAuth();
  const queryClient = useQueryClient();
  
  const { data: cart = [] } = useQuery({
    queryKey: ['cart', user?.id],
    queryFn: () => cartService.getCart(user!.id),
    enabled: !!user
  });
  
  const addMutation = useMutation({
    mutationFn: (product: Product) => {
      // Business logic first
      if (cart.length >= 10) {
        throw new Error('Cart is full');
      }
      
      // Then data access
      return cartService.addToCart(user!.id, product.id);
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['cart'] });
    }
  });
  
  const addToCart = async (product: Product) => {
    return addMutation.mutateAsync(product);
  };
  
  return {
    cart,
    addToCart,
    isFull: cart.length >= 10
  };
}

// Service handles API calls
const cartService = {
  async addToCart(userId: string, productId: string) {
    const response = await apiClient.post(`/users/${userId}/cart`, {
      productId
    });
    return response.data;
  }
};

βœ… Benefits of Separation

  • Testability: Each layer can be tested independently
  • Reusability: Business logic can be used in multiple components
  • Maintainability: Changes are localized to one layer
  • Readability: Each file has a clear, focused purpose
  • Flexibility: Easy to swap implementations (e.g., change API)

πŸͺ Custom Hooks for Business Logic

If separation of concerns is the goal, custom hooks are the workhorse that gets you there in React. A custom hook is just a function whose name starts with use and that calls other hooks β€” but that simple idea is quietly powerful. It lets you lift stateful logic out of a component and into a named, reusable, independently testable unit, leaving the component free to do what it's best at: describe what the UI looks like. Think of a hook as a self-contained appliance: your component plugs into useCart() the way you plug a toaster into the wall, without needing to know a thing about the wiring inside.

When to Create a Custom Hook

πŸ’‘ Create a Custom Hook When...

  • Logic is used in multiple components
  • Component has complex state management
  • You want to test business logic independently
  • Logic involves multiple React hooks
  • You need to share stateful logic between components

Hook Patterns and Examples

1. Data Fetching Hooks

// hooks/useUser.ts
export function useUser(userId: string) {
  return useQuery({
    queryKey: ['users', userId],
    queryFn: () => userService.getById(userId),
    staleTime: 1000 * 60 * 5
  });
}

// Usage
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useUser(userId);
  
  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  
  return <div>{user.name}</div>;
}

2. Form Handling Hooks

// hooks/useForm.ts
interface UseFormOptions<T> {
  initialValues: T;
  onSubmit: (values: T) => void | Promise<void>;
  validate?: (values: T) => Record<string, string>;
}

export function useForm<T extends Record<string, any>>({
  initialValues,
  onSubmit,
  validate
}: UseFormOptions<T>) {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const handleChange = (name: keyof T, value: any) => {
    setValues(prev => ({ ...prev, [name]: value }));
    // Clear error when user starts typing
    if (errors[name as string]) {
      setErrors(prev => {
        const newErrors = { ...prev };
        delete newErrors[name as string];
        return newErrors;
      });
    }
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    // Validate
    if (validate) {
      const validationErrors = validate(values);
      if (Object.keys(validationErrors).length > 0) {
        setErrors(validationErrors);
        return;
      }
    }
    
    // Submit
    setIsSubmitting(true);
    try {
      await onSubmit(values);
      setValues(initialValues); // Reset form
    } catch (error) {
      // `error` is `unknown` in strict mode β€” narrow before reading .message
      const message = error instanceof Error ? error.message : 'Submission failed';
      setErrors({ submit: message });
    } finally {
      setIsSubmitting(false);
    }
  };
  
  const reset = () => {
    setValues(initialValues);
    setErrors({});
  };
  
  return {
    values,
    errors,
    isSubmitting,
    handleChange,
    handleSubmit,
    reset
  };
}

// Usage
function LoginForm() {
  const { values, errors, handleChange, handleSubmit, isSubmitting } = useForm({
    initialValues: { email: '', password: '' },
    onSubmit: async (values) => {
      await authService.login(values);
    },
    validate: (values) => {
      const errors: Record<string, string> = {};
      if (!values.email) errors.email = 'Email is required';
      if (!values.password) errors.password = 'Password is required';
      return errors;
    }
  });
  
  return (
    <form onSubmit={handleSubmit}>
      <Input
        value={values.email}
        onChange={(e) => handleChange('email', e.target.value)}
        error={errors.email}
      />
      <Button type="submit" loading={isSubmitting}>
        Login
      </Button>
    </form>
  );
}

3. Business Logic Hooks

// hooks/useShoppingCart.ts
export function useShoppingCart() {
  const [items, setItems] = useState<CartItem[]>([]);
  
  const addItem = useCallback((product: Product, quantity = 1) => {
    setItems(prev => {
      const existing = prev.find(item => item.product.id === product.id);
      
      if (existing) {
        // Update quantity if item exists
        return prev.map(item =>
          item.product.id === product.id
            ? { ...item, quantity: item.quantity + quantity }
            : item
        );
      }
      
      // Add new item
      return [...prev, { product, quantity }];
    });
  }, []);
  
  const removeItem = useCallback((productId: string) => {
    setItems(prev => prev.filter(item => item.product.id !== productId));
  }, []);
  
  const updateQuantity = useCallback((productId: string, quantity: number) => {
    if (quantity <= 0) {
      removeItem(productId);
      return;
    }
    
    setItems(prev =>
      prev.map(item =>
        item.product.id === productId
          ? { ...item, quantity }
          : item
      )
    );
  }, [removeItem]);
  
  const clearCart = useCallback(() => {
    setItems([]);
  }, []);
  
  // Computed values
  const subtotal = useMemo(() => {
    return items.reduce((sum, item) => {
      return sum + (item.product.price * item.quantity);
    }, 0);
  }, [items]);
  
  const tax = useMemo(() => subtotal * 0.08, [subtotal]);
  const total = subtotal + tax;
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
  
  return {
    items,
    itemCount,
    subtotal,
    tax,
    total,
    addItem,
    removeItem,
    updateQuantity,
    clearCart
  };
}

4. Utility Hooks

// hooks/useDebounce.ts
export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => clearTimeout(handler);
  }, [value, delay]);
  
  return debouncedValue;
}

// hooks/useLocalStorage.ts
export function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });
  
  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };
  
  return [storedValue, setValue] as const;
}

// hooks/useMediaQuery.ts
export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => {
    return window.matchMedia(query).matches;
  });
  
  useEffect(() => {
    const mediaQuery = window.matchMedia(query);
    
    const handleChange = (e: MediaQueryListEvent) => {
      setMatches(e.matches);
    };
    
    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, [query]);
  
  return matches;
}

// Usage
function SearchBar() {
  const [search, setSearch] = useState('');
  const debouncedSearch = useDebounce(search, 500);
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const isMobile = useMediaQuery('(max-width: 768px)');
  
  // Use debouncedSearch for API calls
  const { data } = useQuery({
    queryKey: ['search', debouncedSearch],
    queryFn: () => searchApi(debouncedSearch),
    enabled: debouncedSearch.length > 0
  });
  
  return (/* ... */);
}

βœ… Custom Hook Best Practices

  • Naming: Always start with "use" (useCart, useAuth, useDebounce)
  • Single Responsibility: Each hook should do one thing well
  • Return Object: Return objects for flexibility, not arrays (unless 2 items max)
  • Memoization: Use useCallback and useMemo for expensive operations
  • Dependencies: Always list all dependencies correctly
  • Type Safety: Use TypeScript generics for reusable hooks

πŸ”Œ Service Layer Pattern

The service layer is a dedicated abstraction for all external communication β€” the one place in your app that actually knows the shape of your backend. Instead of scattering fetch('/api/users') calls across twenty components (each with its own slightly different error handling and its own chance to typo the URL), you funnel everything through a typed service. It's the embassy of your application: all diplomacy with the outside world happens here, in one language, with one set of rules.

πŸ’‘ Typed inputs, typed outputs

The real payoff in TypeScript is that a service turns a raw HTTP call into a typed contract. userService.getById(id) returns a Promise<User>, not a Promise<any> β€” so the moment the backend's User shape and your type drift apart, the compiler flags it. Whether you use axios (shown here) or the native fetch API underneath is a detail; what matters architecturally is that callers never see it. They see typed methods, and the transport stays swappable.

Why Use a Service Layer?

Benefit Description
Centralization All API logic in one place
Reusability Same endpoints used across features
Testability Easy to mock for tests
Type Safety Typed inputs and outputs
Error Handling Consistent error handling

Service Structure

// lib/axios.ts - Configure axios instance
import axios from 'axios';

export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json'
  }
});

// Request interceptor - Add auth token
apiClient.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('authToken');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// Response interceptor - Handle errors
apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      // Redirect to login
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

Creating Services

// services/userService.ts
import { apiClient } from '@/lib/axios';
import type { User, CreateUserDto, UpdateUserDto } from '@/types';

export const userService = {
  /**
   * Get all users
   */
  async getAll(): Promise<User[]> {
    const response = await apiClient.get<User[]>('/users');
    return response.data;
  },

  /**
   * Get user by ID
   */
  async getById(id: string): Promise<User> {
    const response = await apiClient.get<User>(`/users/${id}`);
    return response.data;
  },

  /**
   * Create new user
   */
  async create(data: CreateUserDto): Promise<User> {
    const response = await apiClient.post<User>('/users', data);
    return response.data;
  },

  /**
   * Update existing user
   */
  async update(id: string, data: UpdateUserDto): Promise<User> {
    const response = await apiClient.patch<User>(`/users/${id}`, data);
    return response.data;
  },

  /**
   * Delete user
   */
  async delete(id: string): Promise<void> {
    await apiClient.delete(`/users/${id}`);
  },

  /**
   * Search users by query
   */
  async search(query: string): Promise<User[]> {
    const response = await apiClient.get<User[]>('/users/search', {
      params: { q: query }
    });
    return response.data;
  }
};

Advanced Service Patterns

// services/baseService.ts - Generic base service
export class BaseService<T> {
  constructor(private endpoint: string) {}

  async getAll(): Promise<T[]> {
    const response = await apiClient.get<T[]>(this.endpoint);
    return response.data;
  }

  async getById(id: string): Promise<T> {
    const response = await apiClient.get<T>(`${this.endpoint}/${id}`);
    return response.data;
  }

  async create(data: Partial<T>): Promise<T> {
    const response = await apiClient.post<T>(this.endpoint, data);
    return response.data;
  }

  async update(id: string, data: Partial<T>): Promise<T> {
    const response = await apiClient.patch<T>(`${this.endpoint}/${id}`, data);
    return response.data;
  }

  async delete(id: string): Promise<void> {
    await apiClient.delete(`${this.endpoint}/${id}`);
  }
}

// services/userService.ts - Extend base service
class UserService extends BaseService<User> {
  constructor() {
    super('/users');
  }

  // Add user-specific methods
  async getProfile(): Promise<User> {
    const response = await apiClient.get<User>('/users/profile');
    return response.data;
  }

  async updateProfile(data: UpdateProfileDto): Promise<User> {
    const response = await apiClient.patch<User>('/users/profile', data);
    return response.data;
  }

  async changePassword(data: ChangePasswordDto): Promise<void> {
    await apiClient.post('/users/change-password', data);
  }
}

export const userService = new UserService();

Service with React Query

// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userService } from '@/services/userService';

export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: string) => [...userKeys.lists(), { filters }] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};

export function useUsers() {
  return useQuery({
    queryKey: userKeys.lists(),
    queryFn: userService.getAll
  });
}

export function useUser(id: string) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => userService.getById(id)
  });
}

export function useCreateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: userService.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    }
  });
}

export function useUpdateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateUserDto }) =>
      userService.update(id, data),
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) });
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    }
  });
}

⚠️ Service Layer Anti-Patterns

  • Business Logic in Services: Services should only handle data access, not business rules
  • Direct State Updates: Services shouldn't update React state directly
  • Component Dependencies: Services shouldn't import components or hooks
  • Mixed Responsibilities: Keep services focused on API communication

πŸ“ Type Organization

Types are the vocabulary of your codebase. When they're well organized, a new developer can read features/users/types/user.types.ts and understand the entire "users" domain in two minutes β€” the data shapes, the request payloads, the allowed roles. When they're scattered and inconsistently named, every file becomes a guessing game. The guiding rule is simple and mirrors the folder structure you already learned: put types as close to where they're used as possible. A type only the "users" feature cares about lives with the users feature; a type half the app shares moves to shared/.

Type Organization Strategies

1. Feature-Based Types

// features/users/types/user.types.ts
export interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  createdAt: string;
  updatedAt: string;
}

export interface CreateUserDto {
  email: string;
  name: string;
  password: string;
}

export interface UpdateUserDto {
  name?: string;
  avatar?: string;
}

export interface UserFilters {
  search?: string;
  role?: UserRole;
  status?: UserStatus;
}

export type UserRole = 'admin' | 'user' | 'moderator';
export type UserStatus = 'active' | 'inactive' | 'suspended';

2. Shared/Common Types

// shared/types/common.types.ts
export interface ApiResponse<T> {
  data: T;
  message?: string;
  success: boolean;
}

export interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  hasMore: boolean;
}

export interface ApiError {
  message: string;
  code: string;
  statusCode: number;
  details?: Record<string, unknown>; // prefer unknown over any for untrusted shapes
}

export type Nullable<T> = T | null;
export type Optional<T> = T | undefined;
export type ID = string | number;

3. Utility Types

// shared/types/utils.types.ts

// Make all properties optional recursively
export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// Make all properties required recursively
export type DeepRequired<T> = {
  [P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P];
};

// Extract keys of type
export type KeysOfType<T, U> = {
  [K in keyof T]: T[K] extends U ? K : never;
}[keyof T];

// Async function return type
export type AsyncReturnType<T extends (...args: any) => Promise<any>> =
  T extends (...args: any) => Promise<infer R> ? R : any;

βœ… Type Organization Best Practices

  • Naming: Use descriptive names (UserDto, not UD)
  • DTOs: Create separate types for API requests/responses
  • Consistency: Use same naming patterns across types
  • Documentation: Add JSDoc comments for complex types
  • Exports: Export through index.ts files
  • Avoid Any: Never use 'any', use 'unknown' instead

🚨 Error Handling Architecture

Errors aren't edge cases β€” they're a first-class part of your app's behavior, and users judge you by how gracefully you handle them. The difference between "Something went wrong, please try again" with a working retry button and a blank white screen of death is entirely architectural. The trick is to decide, deliberately, which layer handles which kind of failure. A network hiccup is caught differently from a validation error, which is caught differently from a bug that crashes rendering. Think of it as a set of nets stacked under a trapeze artist: most falls are caught by the first net (the API layer), and the last net (an error boundary) exists only to make sure nobody ever hits the floor.

Error Handling Layers

graph TB A[API Layer] --> B[Service Layer] B --> C[Hook Layer] C --> D[Component Layer] A --> E[Axios Interceptor] B --> F[Custom Error Classes] C --> G[TanStack Query onError] D --> H[Error Boundaries] D --> I[Toast Notifications] style A fill:#e91e63,stroke:#333,stroke-width:2px,color:#fff style B fill:#764ba2,stroke:#333,stroke-width:2px,color:#fff style C fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style D fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff

1. Custom Error Classes

// shared/errors/AppError.ts
export class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number,
    public details?: unknown
  ) {
    super(message);
    this.name = 'AppError';
  }
}

export class ValidationError extends AppError {
  constructor(message: string, details?: Record<string, string>) {
    super(message, 'VALIDATION_ERROR', 400, details);
    this.name = 'ValidationError';
  }
}

export class AuthenticationError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 'AUTH_ERROR', 401);
    this.name = 'AuthenticationError';
  }
}

export class AuthorizationError extends AppError {
  constructor(message = 'Insufficient permissions') {
    super(message, 'AUTHORIZATION_ERROR', 403);
    this.name = 'AuthorizationError';
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 'NOT_FOUND', 404);
    this.name = 'NotFoundError';
  }
}

2. Error Handling in Services

// lib/axios.ts
import { AppError, ValidationError, AuthenticationError } from '@/shared/errors';

apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (axios.isAxiosError(error)) {
      const status = error.response?.status;
      const data = error.response?.data;

      switch (status) {
        case 400:
          throw new ValidationError(
            data?.message || 'Validation failed',
            data?.errors
          );
        case 401:
          throw new AuthenticationError(data?.message);
        case 403:
          throw new AuthorizationError(data?.message);
        case 404:
          throw new NotFoundError(data?.resource || 'Resource');
        default:
          throw new AppError(
            data?.message || 'An error occurred',
            'UNKNOWN_ERROR',
            status || 500,
            data
          );
      }
    }

    throw error;
  }
);

3. Error Boundaries

// shared/components/ErrorBoundary.tsx
interface ErrorBoundaryProps {
  children: React.ReactNode;
  fallback?: React.ComponentType<{ error: Error; reset: () => void }>;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

export class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('Error caught by boundary:', error, errorInfo);
    // Log to error reporting service
    // errorReportingService.log(error, errorInfo);
  }

  reset = () => {
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError) {
      const FallbackComponent = this.props.fallback || DefaultErrorFallback;
      return <FallbackComponent error={this.state.error!} reset={this.reset} />;
    }

    return this.props.children;
  }
}

// Default error fallback
function DefaultErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div className="error-fallback">
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

⚠️ Error boundaries are still class components

This is the one place the "function components only" rule bends: React β€” including React 19 β€” has no Hook equivalent of getDerivedStateFromError or componentDidCatch, so a boundary must be a class. In practice, almost nobody hand-writes one anymore. Install the tiny react-error-boundary package and you get a battle-tested <ErrorBoundary> plus a useErrorBoundary() Hook, so your own code stays function components end to end:

import { ErrorBoundary } from 'react-error-boundary';

function App() {
  return (
    <ErrorBoundary
      FallbackComponent={DefaultErrorFallback}
      onReset={() => queryClient.clear()}
    >
      <Routes />
    </ErrorBoundary>
  );
}

React 19 also improved error reporting: uncaught errors now route through onUncaughtError / onCaughtError options you can pass when creating the root, giving you a single place to log to your error-tracking service.

4. Error Handling in Components

// Component with error handling
function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading } = useUser(userId);
  const updateMutation = useUpdateUser();

  const handleUpdate = async (data: UpdateUserDto) => {
    try {
      await updateMutation.mutateAsync({ id: userId, data });
      toast.success('Profile updated successfully!');
    } catch (error) {
      if (error instanceof ValidationError) {
        // Show field-specific errors
        Object.entries(error.details || {}).forEach(([field, message]) => {
          toast.error(`${field}: ${message}`);
        });
      } else if (error instanceof AppError) {
        toast.error(error.message);
      } else {
        toast.error('An unexpected error occurred');
      }
    }
  };

  if (isLoading) return <Spinner />;
  
  if (error) {
    if (error instanceof NotFoundError) {
      return <NotFound message="User not found" />;
    }
    if (error instanceof AuthorizationError) {
      return <Unauthorized />;
    }
    return <ErrorMessage error={error} />;
  }

  return <UserForm user={data} onSubmit={handleUpdate} />;
}

πŸ’‘ Error Handling Best Practices

  • Fail Fast: Catch errors as early as possible
  • Be Specific: Use custom error classes for different scenarios
  • User-Friendly: Show helpful messages, not technical jargon
  • Logging: Log errors to a service for debugging
  • Recovery: Provide ways to recover (retry button, reset)
  • Boundaries: Use Error Boundaries for unexpected errors

πŸ‹οΈ Hands-On Exercises

Exercise 1: Refactor a Monolithic Component

Goal: Take a component that's doing too much and refactor it using proper architectural patterns.

Starting Point:

// ❌ This component has multiple problems
function ProductDashboard() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [cart, setCart] = useState([]);
  const [search, setSearch] = useState('');
  
  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(data => {
        setProducts(data);
        setLoading(false);
      });
  }, []);
  
  const addToCart = (product) => {
    const tax = product.price * 0.08;
    const total = product.price + tax;
    setCart([...cart, { ...product, tax, total }]);
    
    fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId: product.id })
    });
  };
  
  const filteredProducts = products.filter(p => 
    p.name.toLowerCase().includes(search.toLowerCase())
  );
  
  return (
    <div>
      <input 
        value={search} 
        onChange={(e) => setSearch(e.target.value)} 
      />
      {loading ? 'Loading...' : (
        filteredProducts.map(product => (
          <div key={product.id}>
            <h3>{product.name}</h3>
            <p>${product.price}</p>
            <button onClick={() => addToCart(product)}>
              Add to Cart
            </button>
          </div>
        ))
      )}
    </div>
  );
}

Your Task:

  1. Create a service layer for API calls (productService.ts, cartService.ts)
  2. Extract business logic into custom hooks (useProducts, useCart)
  3. Break into smaller components (ProductList, ProductCard, SearchBar)
  4. Add proper TypeScript types
  5. Implement error handling
  6. Use React Query for data fetching
πŸ’‘ Hint #1: Architecture Structure
// Structure to aim for:
// services/productService.ts
// services/cartService.ts
// hooks/useProducts.ts
// hooks/useCart.ts
// components/ProductDashboard.tsx (orchestrator)
// components/ProductList.tsx (presentational)
// components/ProductCard.tsx (presentational)
// components/SearchBar.tsx (presentational)
πŸ’‘ Hint #2: Service Layer
// services/productService.ts
export const productService = {
  async getAll(): Promise<Product[]> {
    const response = await apiClient.get('/api/products');
    return response.data;
  }
};

// services/cartService.ts
export const cartService = {
  async addItem(productId: string): Promise<CartItem> {
    const response = await apiClient.post('/api/cart', { productId });
    return response.data;
  }
};
βœ… Solution Outline

Your refactored solution should have:

  • Services: Clean API abstractions
  • Hooks: useProducts (with React Query), useCart (with business logic)
  • Components: ProductDashboard (orchestrator), ProductList (receives data), ProductCard (pure UI), SearchBar (controlled input)
  • Types: Product, CartItem, AddToCartDto interfaces
  • Error Handling: Try-catch in handlers, Error Boundary around dashboard

Exercise 2: Design a Feature Module

Goal: Create a complete feature module following best practices for folder structure and organization.

Scenario: You're building a "Comments" feature for a blog application. Users should be able to:

  • View comments on a post
  • Add new comments
  • Edit their own comments
  • Delete their own comments
  • Reply to comments (nested)

Your Task:

  1. Design the folder structure for features/comments/
  2. Define TypeScript types and interfaces
  3. Create the service layer
  4. Design custom hooks
  5. Plan component hierarchy
  6. Create the public API (index.ts)
πŸ’‘ Hint: Folder Structure
features/comments/
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ CommentList/
β”‚   β”‚   β”œβ”€β”€ CommentList.tsx
β”‚   β”‚   β”œβ”€β”€ CommentList.test.tsx
β”‚   β”‚   └── CommentList.module.css
β”‚   β”œβ”€β”€ CommentItem/
β”‚   β”œβ”€β”€ CommentForm/
β”‚   └── CommentReply/
β”œβ”€β”€ hooks/
β”‚   β”œβ”€β”€ useComments.ts
β”‚   β”œβ”€β”€ useAddComment.ts
β”‚   β”œβ”€β”€ useUpdateComment.ts
β”‚   └── useDeleteComment.ts
β”œβ”€β”€ api/
β”‚   └── commentService.ts
β”œβ”€β”€ types/
β”‚   └── comment.types.ts
β”œβ”€β”€ utils/
β”‚   └── commentHelpers.ts
└── index.ts
βœ… Solution Checklist
  • βœ… Types defined (Comment, CreateCommentDto, UpdateCommentDto)
  • βœ… Service with all CRUD operations
  • βœ… Custom hooks using React Query
  • βœ… Component hierarchy planned (List > Item > Form/Reply)
  • βœ… Error handling strategy defined
  • βœ… Public API exports only what's needed
  • βœ… Optimistic updates for better UX

Exercise 3: Implement Error Handling Strategy

Goal: Add comprehensive error handling to an existing feature.

Scenario: You have a user authentication feature that currently has no error handling. Add proper error handling at all layers.

Requirements:

  1. Create custom error classes (AuthError, ValidationError, NetworkError)
  2. Add error interceptor to axios
  3. Implement Error Boundary for the auth pages
  4. Add try-catch blocks in components
  5. Show user-friendly error messages
  6. Log errors to console (simulate error tracking service)
πŸ’‘ Hint: Error Class Hierarchy
class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number
  ) {
    super(message);
  }
}

class AuthError extends AppError {
  constructor(message: string) {
    super(message, 'AUTH_ERROR', 401);
  }
}

// Handle in component:
try {
  await login(credentials);
} catch (error) {
  if (error instanceof AuthError) {
    setError('Invalid email or password');
  } else if (error instanceof NetworkError) {
    setError('Connection failed. Please try again.');
  } else {
    setError('An unexpected error occurred');
  }
}

🧠 Knowledge Check Quiz

Question 1: Separation of Concerns

What are the three main layers in a well-architected React application, and what is each responsible for?

Show Answer

Answer:

  1. Presentation Layer (Components): Handles rendering UI and user interactions. Components should be focused on display logic only, not business rules or data fetching.
  2. Business Logic Layer (Custom Hooks): Contains application logic, calculations, validations, and state management. This is where you implement business rules and domain logic.
  3. Data Access Layer (Services): Handles all external communication like API calls, localStorage, or other data sources. Services provide a clean abstraction for data operations.

Question 2: Component Types

What's the difference between Container Components and Presentational Components? When should you use each?

Show Answer

Answer:

Container Components (Smart Components):

  • Manage state and side effects
  • Connect to data sources (hooks, context, stores)
  • Contain business logic
  • Pass data down to presentational components
  • Use when: You need to fetch data, manage state, or implement business logic

Presentational Components (Dumb Components):

  • Receive data only through props
  • Focus purely on UI rendering
  • No state or side effects
  • Highly reusable and testable
  • Use when: You need a reusable UI component that just displays data

Question 3: Folder Structure

Why is a feature-based folder structure better than organizing by file type (components/, hooks/, services/)? Give at least three reasons.

Show Answer

Answer:

  1. Scalability: As the app grows, feature folders don't become cluttered. Each feature is self-contained.
  2. Maintainability: All related code lives together, making it easier to understand and modify a feature.
  3. Team Collaboration: Different teams can work on different features without conflicts.
  4. Code Ownership: Clear boundaries make it obvious who owns what code.
  5. Easier Refactoring: Features can be moved, removed, or extracted as complete units.
  6. Better Imports: Features export a public API through index.ts, hiding implementation details.

Question 4: Service Layer

What should go in a service layer, and what should NOT go there?

Show Answer

Should go in services:

  • API calls (GET, POST, PUT, DELETE)
  • Request/response transformation (if needed)
  • HTTP configuration (headers, auth tokens)
  • Basic error throwing

Should NOT go in services:

  • Business logic (calculations, validations)
  • React state updates
  • Component dependencies
  • UI logic or rendering
  • Direct access to React hooks

Principle: Services should be pure data access - they communicate with external systems and return/send data, nothing more.

Question 5: Custom Hooks

When should you create a custom hook? Give an example of logic that belongs in a custom hook vs logic that should stay in a component.

Show Answer

Create a custom hook when:

  • Logic is used in multiple components
  • Logic involves multiple React hooks
  • Logic contains business rules
  • You want to test logic independently
  • Logic manages complex state

Example - Belongs in Custom Hook:

// useShoppingCart.ts
- Calculate cart totals
- Add/remove items logic
- Cart state management
- Business rules (max items, etc.)

Example - Stay in Component:

// Component
- Button click handlers that just call hooks
- Simple UI state (modal open/closed)
- Navigation after actions
- Direct prop transformations for rendering

Question 6: Error Handling

What are the different layers where you should handle errors in a React application? What type of errors should be handled at each layer?

Show Answer

Answer:

  1. API Layer (Axios Interceptors):
    • HTTP status code errors
    • Network errors
    • Transform to custom error classes
    • Handle auth token refresh
  2. Service Layer:
    • Throw custom errors
    • Add context to errors
    • No catching (let them bubble up)
  3. Hook Layer:
    • React Query onError callbacks
    • Error state management
    • Some errors may be caught here
  4. Component Layer:
    • Show error UI to users
    • Try-catch for async actions
    • User-friendly error messages
    • Error Boundaries for unexpected errors

Question 7: Type Organization

Where should you put type definitions in a feature-based architecture? Give examples of feature-specific vs shared types.

Show Answer

Type Locations:

  • Feature-specific types: features/{feature}/types/
  • Shared types: shared/types/

Feature-Specific Types (features/users/types/):

- User
- CreateUserDto
- UpdateUserDto
- UserFilters
- UserRole (enum)

Shared Types (shared/types/):

- ApiResponse<T>
- PaginatedResponse<T>
- ApiError
- ID (type alias)
- Nullable<T>
- Common utility types

Question 8: Architecture Principles

Explain the SOLID principle that's most important in React: Single Responsibility Principle. How does it apply to React components?

Show Answer

Single Responsibility Principle:

Each component, function, or module should have one reason to change - one well-defined responsibility.

In React Components:

  • A component should do ONE thing (display user data, OR fetch it, OR validate it - not all three)
  • If you can't describe what a component does in one sentence, it's doing too much
  • Changes to business logic shouldn't require changes to UI components
  • Changes to API structure shouldn't require changes to components

Example:

  • ❌ BAD: UserProfile fetches data, validates form, handles submission, and renders UI
  • βœ… GOOD: UserProfile just orchestrates. Data fetching is in useUser hook, form logic in useForm, validation in validators, and UI in presentational components

πŸ“ Lesson Summary

πŸŽ‰ Congratulations on Completing Lesson 8.5!

You've just completed the final lesson of Module 8! You now have a comprehensive understanding of React architecture patterns that will serve you throughout your career as a professional React developer.

What You've Learned

  • βœ… Architecture Principles: Separation of Concerns, DRY, KISS, Single Responsibility, and Composition Over Inheritance
  • βœ… Feature-Based Structure: How to organize large codebases by feature instead of file type
  • βœ… Component Organization: Page, Layout, Container, Presentational, and Shared component patterns
  • βœ… Separation of Concerns: Dividing code into Presentation, Business Logic, and Data Access layers
  • βœ… Custom Hooks: Extracting business logic into reusable, testable hooks
  • βœ… Service Layer: Creating a clean abstraction for API communication
  • βœ… Type Organization: Structuring TypeScript types for maintainability
  • βœ… Error Handling: Implementing comprehensive error handling at every layer

🎯 Key Architectural Takeaways

  1. Components should be simple: If a component is hard to understand or test, it's doing too much. Break it down.
  2. Business logic belongs in hooks: Not in components, not in services - custom hooks are the perfect place.
  3. Services only handle data access: No business logic, no state management - just API calls.
  4. Feature-based structure scales: Organize by feature, not by file type, especially as your app grows.
  5. Types should be close to usage: Feature-specific types in feature folders, shared types in shared folder.
  6. Error handling is multi-layered: Handle errors at the appropriate level - API, service, hook, or component.
  7. Test your architecture: If something is hard to test, it needs better separation of concerns.
  8. Start simple, refactor as needed: Don't over-engineer early. The "Rule of Three" applies - abstract when you need it the third time.

πŸ† Module 8 Complete!

You've now completed all five lessons in Module 8: State Management and Architecture:

  • βœ… Lesson 8.1: State Management Overview
  • βœ… Lesson 8.2: Zustand Basics
  • βœ… Lesson 8.3: Redux Toolkit
  • βœ… Lesson 8.4: React Query (TanStack Query)
  • βœ… Lesson 8.5: Architecture Best Practices

You've learned how to build production-ready, scalable React applications!

πŸš€ What's Next?

You're ready to apply everything you've learned in Module 8!

Immediate Next Step:

Module 8 Project: Social Media Feed

Build a complete social media feed application that demonstrates:

  • Feature-based folder structure
  • React Query for data fetching
  • Zustand or Redux Toolkit for client state
  • Proper component organization
  • Custom hooks for business logic
  • Service layer for API calls
  • Comprehensive error handling
  • TypeScript throughout

Coming Up Next: Module 9 - Testing React Applications

After completing your Module 8 project, you'll learn:

  • Testing fundamentals and the testing pyramid
  • React Testing Library
  • Testing user interactions and async code
  • Integration and E2E testing
  • Testing best practices

πŸ’‘ Applying What You've Learned

In Your Next Project:

  1. Start with structure: Set up feature folders before writing code
  2. Identify layers: Decide what goes in services, hooks, and components
  3. Type everything: Define TypeScript interfaces before implementing
  4. Separate concerns: Keep components focused on UI
  5. Plan error handling: Set up error classes and boundaries early
  6. Refactor regularly: As patterns emerge, extract and organize