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

Patterns in React: a Design Patterns guide for developers

Classic patterns in modern frontend code.

Expona Admin10 min read

React doesn't invent design patterns—it adapts classical ones to the component model. Over five and a half years building production platforms, I've watched developers rediscover Gang of Four patterns in hooks, render functions, and reducer logic. Today I'm breaking down how Patterns in React manifest in real code, and how understanding the underlying structure helps you write cleaner, more maintainable applications.

Observer — State subscriptions and event listeners

The Observer pattern is everywhere in React, even if you don't call it that. Every time you subscribe to state changes, you're implementing Observer. Hooks like useState and useEffect are observer machinery in disguise. The component observes state; when state changes, React notifies subscribers (other components, effects, event handlers) and re-renders accordingly.

I built a real-time trading dashboard where prices update via WebSocket. Without proper Observer structure, you'd manually wire listeners everywhere. Instead, I created a custom hook that encapsulates the subscription pattern:

typescript

// Custom hook implementing Observer pattern
function usePriceSubscription(symbolId: string) {
  const [price, setPrice] = useState(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/prices/${symbolId}`);

    const handleMessage = (event: MessageEvent) => {
      const newPrice = JSON.parse(event.data).price;
      setPrice(newPrice); // Notify all observers
    };

    const handleOpen = () => setIsConnected(true);
    const handleClose = () => setIsConnected(false);

    ws.addEventListener('message', handleMessage);
    ws.addEventListener('open', handleOpen);
    ws.addEventListener('close', handleClose);

    return () => {
      ws.removeEventListener('message', handleMessage);
      ws.removeEventListener('open', handleOpen);
      ws.removeEventListener('close', handleClose);
      ws.close();
    };
  }, [symbolId]);

  return { price, isConnected };
}

// Components observing the subscription
function PriceDisplay({ symbolId }: { symbolId: string }) {
  const { price, isConnected } = usePriceSubscription(symbolId);

  return (
    <div>
      <p>Price: {price ? `$${price.toFixed(2)}` : 'Loading...'}</p>
      <p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
    </div>
  );
}

The hook is the Subject; components are Observers. When price changes, all components using that hook re-render automatically. The cleanup function unsubscribes when the component unmounts—that's cleanup logic, not cleanup UI.

Real-world benefit: if you need to add logging, analytics, or notifications whenever price updates, you change it in one place. All observers inherit the new behavior.

Composite — Component trees

React's component hierarchy is the Composite pattern made visual. You build tree structures where leaf components (buttons, inputs) and branch components (forms, dialogs) follow the same interface. A Composite lets clients treat individual objects and compositions uniformly.

I worked on a CRM where custom forms had nested field groups, repeating sections, and conditional blocks. Treating every node as a component meant the tree could compose without special cases:

typescript

// Base component interface
interface FormNodeProps {
  id: string;
  label?: string;
  required?: boolean;
  children?: React.ReactNode;
}

// Leaf component
function TextInput({ id, label, required }: FormNodeProps) {
  return (
    <div>
      
        {label}
        {required && <span>*</span>}
      
      
    </div>
  );
}

// Leaf component
function EmailInput({ id, label, required }: FormNodeProps) {
  return (
    <div>
      
        {label}
        {required && <span>*</span>}
      
      
    </div>
  );
}

// Branch component (Composite)
function FieldGroup({ id, label, children }: FormNodeProps) {
  return (
    
      {label && {label}}
      <div>{children}</div>
    
  );
}

// Branch component for repeating sections
function RepeatingSection({
  id,
  label,
  children,
}: FormNodeProps & { children: React.ReactNode }) {
  const [items, setItems] = useState(['0']);

  const handleAdd = () => {
    setItems([...items, String(items.length)]);
  };

  const handleRemove = (index: string) => {
    setItems(items.filter((i) => i !== index));
  };

  return (
    <div>
      {label && <h4>{label}</h4>}
      {items.map((itemId) => (
        <div>
          {children}
          {items.length > 1 && (
             handleRemove(itemId)}
            >
              Remove
            
          )}
        </div>
      ))}
      
        Add {label}
      
    </div>
  );
}

// Tree composition—clients don't care if it's leaf or branch
function CompanyForm() {
  return (
    
      
        
        
      

      
        
          
          
        
      

      Save
    
  );
}

The beauty here: whether you're rendering a single input or a repeated section containing grouped inputs, the tree structure is uniform. Adding validation, styling, or conditional rendering happens at each level independently.

Strategy — Render different components by type

Strategy pattern lets you encapsulate a family of algorithms and swap them at runtime. In React, this often means rendering different components based on a type prop, or selecting different logic branches for calculations.

I built a ride-hailing platform with different payment methods: credit card, digital wallet, cash, corporate billing. Each had its own validation, UI, and API integration. Instead of conditional logic scattered everywhere, I used Strategy:

typescript

// Define strategy interface
interface PaymentStrategy {
  render(): React.ReactNode;
  validate(): boolean;
  submit(): Promise;
}

// Concrete strategies
class CreditCardPayment implements PaymentStrategy {
  constructor(
    private cardNumber: string,
    private cvv: string,
    private onSuccess: () => void
  ) {}

  render() {
    return (
      <div>
        
        
      </div>
    );
  }

  validate() {
    return this.cardNumber.length === 16 && this.cvv.length === 3;
  }

  async submit() {
    // Call payment processor for cards
    await fetch('/api/payments/card', {
      method: 'POST',
      body: JSON.stringify({
        cardNumber: this.cardNumber,
        cvv: this.cvv,
      }),
    });
    this.onSuccess();
  }
}

class DigitalWalletPayment implements PaymentStrategy {
  constructor(
    private walletId: string,
    private onSuccess: () => void
  ) {}

  render() {
    return (
      <div>
        <p>Pay with wallet: {this.walletId}</p>
        Authorize
      </div>
    );
  }

  validate() {
    return this.walletId.length > 0;
  }

  async submit() {
    // Call wallet API
    await fetch('/api/payments/wallet', {
      method: 'POST',
      body: JSON.stringify({ walletId: this.walletId }),
    });
    this.onSuccess();
  }
}

class CashPayment implements PaymentStrategy {
  constructor(private onSuccess: () => void) {}

  render() {
    return <div><p>Payment will be collected in cash.</p></div>;
  }

  validate() {
    return true;
  }

  async submit() {
    // No API call needed; driver handles it
    this.onSuccess();
  }
}

// React component using strategies
function PaymentForm({
  paymentType,
  onSuccess,
}: {
  paymentType: 'card' | 'wallet' | 'cash';
  onSuccess: () => void;
}) {
  const [strategy, setStrategy] = useState(null);

  useEffect(() => {
    if (paymentType === 'card') {
      setStrategy(new CreditCardPayment('', '', onSuccess));
    } else if (paymentType === 'wallet') {
      setStrategy(new DigitalWalletPayment('wallet123', onSuccess));
    } else {
      setStrategy(new CashPayment(onSuccess));
    }
  }, [paymentType, onSuccess]);

  const handleSubmit = async () => {
    if (!strategy) return;
    if (!strategy.validate()) {
      alert('Please fill in all required fields');
      return;
    }
    await strategy.submit();
  };

  if (!strategy) return null;

  return (
    <div>
      {strategy.render()}
      
        Confirm Payment
      
    </div>
  );
}

Each strategy is self-contained. Adding a new payment method means implementing the interface and plugging it in. The component doesn't care about the details—it just calls the same methods on whatever strategy is active.

Command — Reducers and dispatched actions

The Command pattern encapsulates requests as objects, deferring execution and enabling undo/redo, logging, and queuing. Redux reducers are Commands in disguise: actions are command objects; dispatch executes them; reducers apply transformations.

I built a SaaS content editor where users could undo/redo edits, replay actions, and persist them to the backend. Using Redux with a command-like structure made this straightforward:

typescript

// Action = Command
type EditorAction =
  | { type: 'INSERT_TEXT'; payload: { position: number; text: string } }
  | { type: 'DELETE_TEXT'; payload: { position: number; length: number } }
  | { type: 'APPLY_STYLE'; payload: { range: [number, number]; style: string } }
  | { type: 'UNDO' }
  | { type: 'REDO' };

interface EditorState {
  content: string;
  history: EditorAction[]; // Past commands
  historyIndex: number; // Current position in history
  styles: Map; // Position -> styles
}

// Reducer applies commands
function editorReducer(
  state: EditorState,
  action: EditorAction
): EditorState {
  // Discard redo history when new action is issued
  const history = state.history.slice(0, state.historyIndex + 1);

  switch (action.type) {
    case 'INSERT_TEXT': {
      const newContent =
        state.content.slice(0, action.payload.position) +
        action.payload.text +
        state.content.slice(action.payload.position);
      return {
        ...state,
        content: newContent,
        history: [...history, action],
        historyIndex: history.length,
      };
    }

    case 'DELETE_TEXT': {
      const newContent =
        state.content.slice(0, action.payload.position) +
        state.content.slice(
          action.payload.position + action.payload.length
        );
      return {
        ...state,
        content: newContent,
        history: [...history, action],
        historyIndex: history.length,
      };
    }

    case 'UNDO': {
      if (state.historyIndex <= 0) return state;
      // Replay all commands up to historyIndex - 1
      const previousIndex = state.historyIndex - 1;
      const commandsToReplay = history.slice(0, previousIndex);
      const replayedState = commandsToReplay.reduce(
        (acc, cmd) => editorReducer(acc, cmd),
        {
          content: '',
          history: [],
          historyIndex: 0,
          styles: new Map(),
        }
      );
      return {
        ...replayedState,
        history,
        historyIndex: previousIndex,
      };
    }

    case 'REDO': {
      if (state.historyIndex >= history.length - 1) return state;
      const nextIndex = state.historyIndex + 1;
      const nextCommand = history[nextIndex];
      return editorReducer(
        { ...state, historyIndex: nextIndex, history },
        nextCommand
      );
    }

    default:
      return state;
  }
}

// Component dispatching commands
function TextEditor() {
  const [state, dispatch] = useReducer(editorReducer, {
    content: '',
    history: [],
    historyIndex: -1,
    styles: new Map(),
  });

  const handleInsertText = (text: string) => {
    dispatch({
      type: 'INSERT_TEXT',
      payload: { position: state.content.length, text },
    });
  };

  const handleUndo = () => {
    dispatch({ type: 'UNDO' });
  };

  const handleRedo = () => {
    dispatch({ type: 'REDO' });
  };

  return (
    <div>
      </div>
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