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

Before diving into specific patterns, let's understand the fundamental principles that guide architectural decisions in React applications. These principles apply regardless of project size or specific technologies used.

The Core Principles

πŸ“– Architecture Principles

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 goal is to minimize the cognitive load required to work with your code, both for yourself in the future and for your teammates.

1. Separation of Concerns

Each part of your application should have a single, well-defined responsibility. When components, hooks, and utilities each focus on one thing, they become easier to understand, test, and reuse.

// ❌ 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:#e3f2fd style B fill:#c8e6c9 style C fill:#fff3cd style D fill:#f8bbd0 style E fill:#d1c4e9

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>
  );
}

πŸ’‘ The Testing Principle

A good architecture test: If a component is hard to test, it's probably doing too much. Difficulty testing is often a sign that you need to break things down or separate concerns better.

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

As applications grow, organizing files by type (all components together, all hooks together) becomes unwieldy. A feature-based structure groups related files together, making it easier to understand and modify features as complete units.

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
import { UserList, useUsers, User } from '@/features/users';

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

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. Understanding different component types and their roles helps you organize them effectively and make better architectural decisions.

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:#e3f2fd style B fill:#c8e6c9 style D fill:#fff3cd style E fill:#f8bbd0 style F fill:#d1c4e9 style G fill:#ffccbc

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

Separation of concerns is about organizing code so that each piece has a clear, focused responsibility. In React applications, this means separating presentation, business logic, and data fetching into distinct layers.

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[React Query] --> C C3[State Management] --> C style A fill:#e3f2fd style B fill:#fff3cd style C fill:#c8e6c9

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) {
      toast.error(error.message);
    }
  };
  
  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

Custom hooks are the perfect place to encapsulate business logic in React applications. They keep components clean and focused on rendering while making logic reusable and testable.

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) {
      setErrors({ submit: error.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. It centralizes API calls, error handling, and data transformation, making your application easier to maintain and test.

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

Proper TypeScript type organization makes your codebase more maintainable and helps developers understand data structures quickly.

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, any>;
}

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

Consistent, well-architected error handling improves user experience and makes debugging easier. A good error handling strategy catches errors at the right level and provides meaningful feedback.

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[React Query onError] D --> H[Error Boundaries] D --> I[Toast Notifications] style A fill:#ffcdd2 style B fill:#fff3cd style C fill:#e3f2fd style D fill:#c8e6c9

1. Custom Error Classes

// shared/errors/AppError.ts
export class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number,
    public details?: any
  ) {
    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>
  );
}

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