FrontendDec 12, 202513 min read

React Design Patterns Every Developer Should Know in 2025

Build scalable and maintainable React applications

ReactTypeScriptPatternsArchitecture
React Design Patterns Every Developer Should Know in 2025

Key Takeaways

  • Compound Components pattern
  • Custom hooks best practices
  • Render props vs hooks
  • State machines for complex UI
  • Error boundary patterns

React's flexibility is both a strength and a challenge. Without established patterns, codebases can become difficult to maintain. In this guide, we'll explore battle-tested patterns that help you build scalable, maintainable React applications that your team will love working with.

Code architecture patterns
Good patterns lead to clean, maintainable code

Compound Components Pattern

Compound components let you create expressive, flexible APIs. Think of HTML's select and option elements - they work together as a unit. This pattern is perfect for complex UI components like tabs, accordions, menus, and form fields.

typescript
// Compound Components Pattern
import { createContext, useContext, useState, ReactNode } from 'react';

interface TabsContextType {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabsContext = createContext<TabsContextType | null>(null);

function useTabs() {
  const context = useContext(TabsContext);
  if (!context) throw new Error('useTabs must be used within Tabs');
  return context;
}

interface TabsProps {
  defaultTab: string;
  children: ReactNode;
  onChange?: (tab: string) => void;
}

function Tabs({ defaultTab, children, onChange }: TabsProps) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  
  const handleTabChange = (tab: string) => {
    setActiveTab(tab);
    onChange?.(tab);
  };
  
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab: handleTabChange }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }: { children: ReactNode }) {
  return <div className="tab-list" role="tablist">{children}</div>;
}

function Tab({ id, children }: { id: string; children: ReactNode }) {
  const { activeTab, setActiveTab } = useTabs();
  return (
    <button
      role="tab"
      aria-selected={activeTab === id}
      className={`tab ${activeTab === id ? 'active' : ''}`}
      onClick={() => setActiveTab(id)}
    >
      {children}
    </button>
  );
}

function TabPanels({ children }: { children: ReactNode }) {
  return <div className="tab-panels">{children}</div>;
}

function TabPanel({ id, children }: { id: string; children: ReactNode }) {
  const { activeTab } = useTabs();
  if (activeTab !== id) return null;
  return <div role="tabpanel" className="tab-panel">{children}</div>;
}

// Attach sub-components
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;

// Usage - Clean, readable API
<Tabs defaultTab="profile" onChange={console.log}>
  <Tabs.List>
    <Tabs.Tab id="profile">Profile</Tabs.Tab>
    <Tabs.Tab id="settings">Settings</Tabs.Tab>
    <Tabs.Tab id="billing">Billing</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panels>
    <Tabs.Panel id="profile"><ProfileContent /></Tabs.Panel>
    <Tabs.Panel id="settings"><SettingsContent /></Tabs.Panel>
    <Tabs.Panel id="billing"><BillingContent /></Tabs.Panel>
  </Tabs.Panels>
</Tabs>

Custom Hooks Best Practices

Custom hooks are the primary way to share logic in React. A well-designed custom hook encapsulates complexity, provides a clean API, and is thoroughly tested. Follow the 'use' prefix convention and ensure hooks are composable.

typescript
// Custom Hook - useAsync with proper patterns
import { useState, useCallback, useRef, useEffect } from 'react';

interface AsyncState<T> {
  data: T | null;
  error: Error | null;
  status: 'idle' | 'pending' | 'success' | 'error';
}

interface UseAsyncReturn<T> extends AsyncState<T> {
  execute: (...args: unknown[]) => Promise<T>;
  reset: () => void;
  isLoading: boolean;
  isError: boolean;
  isSuccess: boolean;
}

function useAsync<T>(
  asyncFunction: (...args: unknown[]) => Promise<T>,
  immediate = false
): UseAsyncReturn<T> {
  const [state, setState] = useState<AsyncState<T>>({
    data: null,
    error: null,
    status: 'idle',
  });
  
  // Use ref to track mounted state
  const mountedRef = useRef(true);
  
  useEffect(() => {
    return () => { mountedRef.current = false; };
  }, []);
  
  const execute = useCallback(async (...args: unknown[]) => {
    setState({ data: null, error: null, status: 'pending' });
    
    try {
      const data = await asyncFunction(...args);
      if (mountedRef.current) {
        setState({ data, error: null, status: 'success' });
      }
      return data;
    } catch (error) {
      if (mountedRef.current) {
        setState({ data: null, error: error as Error, status: 'error' });
      }
      throw error;
    }
  }, [asyncFunction]);
  
  const reset = useCallback(() => {
    setState({ data: null, error: null, status: 'idle' });
  }, []);
  
  useEffect(() => {
    if (immediate) execute();
  }, [immediate, execute]);
  
  return {
    ...state,
    execute,
    reset,
    isLoading: state.status === 'pending',
    isError: state.status === 'error',
    isSuccess: state.status === 'success',
  };
}

// Usage
const { data, isLoading, isError, execute } = useAsync(fetchUsers);
5+Patterns covered
50%Less prop drilling
Type-safeAll examples
ReusableCopy-paste ready
HR

Written by Hammas Rashid

Full-Stack Developer passionate about building scalable web applications and sharing knowledge with the developer community.

Chat on WhatsApp