Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 072 / 100

React component patterns: a hands-on guide for engineers

Patterns I use in every React codebase.

Expona Admin10 min read

After 5+ years building production systems, I've learned that React component patterns are less about following dogma and more about solving real problems: sharing logic, managing complexity, and keeping components testable. In this article, I'll walk you through four patterns I use in every React codebase, from e-commerce platforms to SaaS dashboards.

Container and presentational

The container-presentational pattern (sometimes called smart-dumb or container-view) separates data-fetching and business logic from pure UI rendering. The container component handles the "how" — it fetches data, manages side effects, orchestrates logic. The presentational component handles the "what" — it receives props and renders UI, with zero knowledge of where data comes from.

I use this pattern constantly in e-commerce platforms. When I built a product listing page, the container owned the API call, filtering state, and pagination logic, while the presentational component just displayed products and responded to user clicks.

typescript

// Container: ProductListContainer.tsx
// Owns data fetching, state, and business logic
import { useEffect, useState } from 'react';
import ProductList from './ProductList';

export default function ProductListContainer() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [filter, setFilter] = useState('');

  useEffect(() => {
    const fetchProducts = async () => {
      setLoading(true);
      try {
        const res = await fetch(`/api/products?filter=${filter}`);
        if (!res.ok) throw new Error('Failed to fetch');
        const data = await res.json();
        setProducts(data);
        setError(null);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error');
        setProducts([]);
      } finally {
        setLoading(false);
      }
    };

    fetchProducts();
  }, [filter]);

  return (
    
  );
}
typescript

// Presentational: ProductList.tsx
// Pure UI, receives everything via props
interface ProductListProps {
  products: Array<{ id: string; name: string; price: number }>;
  loading: boolean;
  error: string | null;
  filter: string;
  onFilterChange: (filter: string) => void;
}

export default function ProductList({
  products,
  loading,
  error,
  filter,
  onFilterChange,
}: ProductListProps) {
  if (loading) return <div>Loading products...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <div>
       onFilterChange(e.target.value)}
      />
      <ul>
        {products.map((product) => (
          <li>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

This separation makes testing easier—you can test the presentational component in isolation with mock props, and test the container's logic independently. In a CRM system I worked on, this meant frontend engineers could develop UI while backend engineers finalized APIs.

Custom hooks

Custom hooks let you extract stateful logic into reusable functions. Instead of repeating the same fetch-and-error-handling pattern across ten components, you write it once in a hook.

On a travel platform, multiple features needed to fetch booking data with identical retry logic and error handling. I created a custom hook instead of duplicating code:

typescript

// useBookings.ts
import { useEffect, useState, useCallback } from 'react';

interface Booking {
  id: string;
  destination: string;
  date: string;
  status: 'pending' | 'confirmed' | 'cancelled';
}

interface UseBookingsResult {
  bookings: Booking[];
  loading: boolean;
  error: string | null;
  refetch: () => Promise;
}

export function useBookings(userId: string): UseBookingsResult {
  const [bookings, setBookings] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchBookings = useCallback(async () => {
    setLoading(true);
    try {
      const res = await fetch(`/api/bookings?userId=${userId}`);
      if (!res.ok) throw new Error('Failed to fetch bookings');
      const data = await res.json();
      setBookings(data);
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setLoading(false);
    }
  }, [userId]);

  useEffect(() => {
    fetchBookings();
  }, [fetchBookings]);

  return { bookings, loading, error, refetch: fetchBookings };
}

Now any component needing bookings simply calls the hook:

typescript

function BookingDashboard({ userId }: { userId: string }) {
  const { bookings, loading, error, refetch } = useBookings(userId);

  if (loading) return <div>Loading bookings...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <div>
      Refresh
      <ul>
        {bookings.map((booking) => (
          <li>
            {booking.destination} on {booking.date}
          </li>
        ))}
      </ul>
    </div>
  );
}

Custom hooks also work perfectly for form handling, authentication state, local storage syncing, and real-time subscriptions. On a trading platform, I created a hook to manage WebSocket subscriptions for live price updates, and reused it across eight different components without repeating connection logic once.

Controlled components

A controlled component is one where the parent component owns the state, and the component receives its value and change handler as props. The opposite is an uncontrolled component, where the component manages its own state internally.

Use controlled components when the parent needs to coordinate state across multiple fields or validate and transform data before updating. On a ride-hailing platform, the ride request form had interdependent fields—pickup location, dropoff location, and route calculation all depended on each other, so the parent owned all the state:

typescript

// Parent: RideRequestForm.tsx
import { useState } from 'react';
import LocationInput from './LocationInput';
import RoutePreview from './RoutePreview';

export default function RideRequestForm() {
  const [pickup, setPickup] = useState('');
  const [dropoff, setDropoff] = useState('');
  const [route, setRoute] = useState<{ distance: number; duration: string } | null>(null);

  const handlePickupChange = (value: string) => {
    setPickup(value);
    // Trigger route recalculation
    if (value && dropoff) {
      calculateRoute(value, dropoff);
    }
  };

  const handleDropoffChange = (value: string) => {
    setDropoff(value);
    // Trigger route recalculation
    if (pickup && value) {
      calculateRoute(pickup, value);
    }
  };

  const calculateRoute = async (from: string, to: string) => {
    const res = await fetch('/api/routes', {
      method: 'POST',
      body: JSON.stringify({ from, to }),
    });
    const data = await res.json();
    setRoute(data);
  };

  return (
    <div>
      
      
      {route && }
      
        Request Ride
      
    </div>
  );
}
typescript

// Child: LocationInput.tsx
interface LocationInputProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
}

export default function LocationInput({ label, value, onChange }: LocationInputProps) {
  return (
    <div>
      {label}
       onChange(e.target.value)}
        placeholder={`Enter ${label.toLowerCase()}`}
      />
    </div>
  );
}

The parent owns the state because it needs to coordinate: pickup affects route, dropoff affects route, and the submit button depends on all three. This makes the form predictable and testable. The child component is entirely presentational—it doesn't know about routes or business rules, just renders based on props.

Headless components

Headless components provide stateful logic and behavior without imposing any markup or styling. The consumer gets to decide how to render the UI. This is powerful when you need the same logic across different designs or platforms.

I've used this pattern in SaaS platforms where different teams needed a dropdown with the same keyboard navigation, focus management, and accessibility features, but completely different visual designs. Instead of forking the component or fighting over styles, I created a headless dropdown:

typescript

// useDropdown.ts - headless hook
import { useRef, useState, useCallback } from 'react';

interface UseDropdownResult {
  isOpen: boolean;
  open: () => void;
  close: () => void;
  toggle: () => void;
  selectedIndex: number;
  selectIndex: (index: number) => void;
  moveUp: () => void;
  moveDown: () => void;
  triggerRef: React.RefObject;
}

export function useDropdown(itemCount: number): UseDropdownResult {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(-1);
  const triggerRef = useRef(null);

  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);
  const toggle = useCallback(() => setIsOpen((prev) => !prev), []);

  const selectIndex = useCallback((index: number) => {
    if (index >= 0 && index < itemCount) {
      setSelectedIndex(index);
    }
  }, [itemCount]);

  const moveUp = useCallback(() => {
    setSelectedIndex((prev) =>
      prev <= 0 ? itemCount - 1 : prev - 1
    );
  }, [itemCount]);

  const moveDown = useCallback(() => {
    setSelectedIndex((prev) =>
      prev >= itemCount - 1 ? 0 : prev + 1
    );
  }, [itemCount]);

  return {
    isOpen,
    open,
    close,
    toggle,
    selectedIndex,
    selectIndex,
    moveUp,
    moveDown,
    triggerRef,
  };
}

Now a team can use this hook with their own UI:

typescript

// Team A's styled dropdown
function StyledDropdown({ options }: { options: string[] }) {
  const dropdown = useDropdown(options.length);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowUp') dropdown.moveUp();
    if (e.key === 'ArrowDown') dropdown.moveDown();
    if (e.key === 'Escape') dropdown.close();
  };

  return (
    <div>
      
        {dropdown.selectedIndex >= 0 ? options[dropdown.selectedIndex] : 'Select...'}
      
      {dropdown.isOpen && (
        <ul>
          {options.map((option, index) => (
            <li> {
                dropdown.selectIndex(index);
                dropdown.close();
              }}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Headless components shine when building component libraries, design systems, or supporting multiple platforms. On a project using both web and mobile (React Native), I built headless hooks that provided the logic layer, and each platform implemented its own UI on top.

Common mistakes

  • Over-using container-presentational. Not every component needs this split. Simple, self-contained components can manage their own state. Use the pattern when you actually need to share logic or test independently.
  • Custom hooks with too much responsibility. A hook doing fetching, caching, retries, error recovery, and transformation is hard to maintain. Start simple and split if it grows.
  • Controlled components everywhere. If state is truly local to a component and no parent needs access, let the component own it. Over-centralizing state makes components verbose and hard to reuse.
  • Headless components premature. Build a concrete component first. Extract to headless only when you have multiple teams or platforms with the same logic but different UI needs.
  • Forgetting dependency arrays in hooks. A useEffect with missing dependencies will cause stale closures and bugs. Use ESLint's exhaustive-deps rule.

Key takeaways

  • React component patterns solve real problems: container-presentational separates concerns and improves testability; custom hooks eliminate duplication; controlled components enable coordination; headless components maximize reusability.
  • None of these patterns are mandatory. Use them when they solve a specific pain point in your codebase, not because they're "best practices."
  • The best pattern for your codebase depends on your team size, platform complexity, and how logic is shared. A single developer building a blog needs different patterns than a 20-person team building a SaaS platform.
  • Combine patterns freely. You can have a container wrapping a controlled component that uses custom hooks—there's no rule against it.
  • Always measure: Is this pattern reducing duplication? Making testing easier? Improving readability? If not, simplify.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles