A

React & UI Patterns

reactpatternshookscomponentsuitypescript

React & UI Patterns

React has developed its own vocabulary of patterns over the years. Higher-Order Components (HOCs) and Render Props dominated the class component era, but hooks have become the primary tool for logic reuse. This article covers the patterns that matter in modern React: custom hooks, compound components, container/presenter separation, and controlled components.

Custom Hooks

Custom hooks are the standard way to extract and reuse stateful logic in React. They're just functions that call other hooks—the use prefix signals to React (and ESLint) that hook rules apply.

Basic Structure

// src/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
  // Initialize from localStorage or use default
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') {
      return initialValue;
    }

    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  // Update localStorage when value changes
  useEffect(() => {
    if (typeof window === 'undefined') return;

    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}
// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);

  return (
    <div>
      <select value={theme} onChange={(e) => setTheme(e.target.value)}>
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>

      <input
        type="range"
        min={12}
        max={24}
        value={fontSize}
        onChange={(e) => setFontSize(Number(e.target.value))}
      />
    </div>
  );
}

Encapsulating Side Effects

Hooks excel at hiding complex async logic:

// src/hooks/useFetch.ts
import { useState, useEffect, useCallback, useRef } from 'react';

interface UseFetchResult<T> {
  data: T | null;
  error: Error | null;
  isLoading: boolean;
  refetch: () => void;
}

interface UseFetchOptions {
  enabled?: boolean;
  refetchInterval?: number;
}

export function useFetch<T>(
  url: string,
  options: UseFetchOptions = {}
): UseFetchResult<T> {
  const { enabled = true, refetchInterval } = options;

  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  // Track mounted state to prevent updates after unmount
  const mountedRef = useRef(true);

  const fetchData = useCallback(async () => {
    if (!enabled) return;

    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch(url);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const json = await response.json();

      if (mountedRef.current) {
        setData(json);
      }
    } catch (err) {
      if (mountedRef.current) {
        setError(err instanceof Error ? err : new Error(String(err)));
      }
    } finally {
      if (mountedRef.current) {
        setIsLoading(false);
      }
    }
  }, [url, enabled]);

  // Initial fetch and refetch on URL change
  useEffect(() => {
    fetchData();
  }, [fetchData]);

  // Optional polling
  useEffect(() => {
    if (!refetchInterval || !enabled) return;

    const intervalId = setInterval(fetchData, refetchInterval);
    return () => clearInterval(intervalId);
  }, [fetchData, refetchInterval, enabled]);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      mountedRef.current = false;
    };
  }, []);

  return { data, error, isLoading, refetch: fetchData };
}
// Usage
function UserProfile({ userId }: { userId: string }) {
  const { data: user, error, isLoading, refetch } = useFetch<User>(
    `/api/users/${userId}`,
    { refetchInterval: 30000 } // Poll every 30 seconds
  );

  if (isLoading && !user) return <Skeleton />;
  if (error) return <ErrorMessage error={error} onRetry={refetch} />;
  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <button onClick={refetch}>Refresh</button>
    </div>
  );
}

Composing Multiple Hooks

Build complex behavior from simple hooks:

// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}
// src/hooks/useSearch.ts
import { useState, useMemo } from 'react';
import { useFetch } from './useFetch';
import { useDebounce } from './useDebounce';

interface UseSearchOptions {
  debounceMs?: number;
  minLength?: number;
}

export function useSearch<T>(
  baseUrl: string,
  options: UseSearchOptions = {}
) {
  const { debounceMs = 300, minLength = 2 } = options;

  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, debounceMs);

  const shouldFetch = debouncedQuery.length >= minLength;
  const url = shouldFetch
    ? `${baseUrl}?q=${encodeURIComponent(debouncedQuery)}`
    : '';

  const { data, error, isLoading } = useFetch<T[]>(url, {
    enabled: shouldFetch,
  });

  return {
    query,
    setQuery,
    results: data ?? [],
    error,
    isLoading: isLoading && shouldFetch,
    hasSearched: shouldFetch,
  };
}
// Usage
function SearchPage() {
  const { query, setQuery, results, isLoading, hasSearched } = useSearch<Product>(
    '/api/products/search',
    { debounceMs: 400, minLength: 3 }
  );

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products..."
      />

      {isLoading && <Spinner />}

      {hasSearched && results.length === 0 && !isLoading && (
        <p>No results found for "{query}"</p>
      )}

      <ul>
        {results.map((product) => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
    </div>
  );
}

Hook Guidelines

Do:

  • Start with use prefix
  • Call hooks at the top level (not inside conditions, loops, or nested functions)
  • Return arrays [value, setValue] for single values (like useState)
  • Return objects { value, loading, error } for multiple values
  • Include cleanup functions in useEffect when needed

Don't:

  • Call hooks conditionally
  • Return too many values (destructuring becomes unwieldy)
  • Forget the dependency array in useEffect/useCallback/useMemo

Compound Components

Compound components are groups of components that work together implicitly, sharing state through React Context. This pattern creates flexible APIs where the parent controls state and children consume it.

Basic Example: Accordion

// src/components/Accordion/index.tsx
import {
  createContext,
  useContext,
  useState,
  ReactNode,
  useCallback,
} from 'react';

// Context for sharing state
interface AccordionContextValue {
  expandedItems: Set<string>;
  toggleItem: (id: string) => void;
  allowMultiple: boolean;
}

const AccordionContext = createContext<AccordionContextValue | null>(null);

function useAccordionContext() {
  const context = useContext(AccordionContext);
  if (!context) {
    throw new Error('Accordion components must be used within an Accordion');
  }
  return context;
}

// Root component
interface AccordionProps {
  children: ReactNode;
  allowMultiple?: boolean;
  defaultExpanded?: string[];
}

export function Accordion({
  children,
  allowMultiple = false,
  defaultExpanded = [],
}: AccordionProps) {
  const [expandedItems, setExpandedItems] = useState(
    () => new Set(defaultExpanded)
  );

  const toggleItem = useCallback(
    (id: string) => {
      setExpandedItems((current) => {
        const next = new Set(current);

        if (next.has(id)) {
          next.delete(id);
        } else {
          if (!allowMultiple) {
            next.clear();
          }
          next.add(id);
        }

        return next;
      });
    },
    [allowMultiple]
  );

  return (
    <AccordionContext.Provider value={{ expandedItems, toggleItem, allowMultiple }}>
      <div className="divide-y divide-gray-200">{children}</div>
    </AccordionContext.Provider>
  );
}

// Item component
interface AccordionItemProps {
  id: string;
  children: ReactNode;
}

export function AccordionItem({ id, children }: AccordionItemProps) {
  const { expandedItems } = useAccordionContext();
  const isExpanded = expandedItems.has(id);

  return (
    <div data-expanded={isExpanded} className="accordion-item">
      {children}
    </div>
  );
}

// Trigger component
interface AccordionTriggerProps {
  id: string;
  children: ReactNode;
}

export function AccordionTrigger({ id, children }: AccordionTriggerProps) {
  const { expandedItems, toggleItem } = useAccordionContext();
  const isExpanded = expandedItems.has(id);

  return (
    <button
      onClick={() => toggleItem(id)}
      aria-expanded={isExpanded}
      className="flex w-full items-center justify-between py-4 text-left"
    >
      {children}
      <ChevronIcon
        className={`transform transition-transform ${isExpanded ? 'rotate-180' : ''}`}
      />
    </button>
  );
}

// Content component
interface AccordionContentProps {
  id: string;
  children: ReactNode;
}

export function AccordionContent({ id, children }: AccordionContentProps) {
  const { expandedItems } = useAccordionContext();
  const isExpanded = expandedItems.has(id);

  if (!isExpanded) return null;

  return (
    <div className="pb-4">
      {children}
    </div>
  );
}

// Attach subcomponents for convenient dot notation
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;
// Usage
function FAQ() {
  return (
    <Accordion allowMultiple defaultExpanded={['faq-1']}>
      <Accordion.Item id="faq-1">
        <Accordion.Trigger id="faq-1">
          What payment methods do you accept?
        </Accordion.Trigger>
        <Accordion.Content id="faq-1">
          We accept Visa, MasterCard, American Express, and PayPal.
        </Accordion.Content>
      </Accordion.Item>

      <Accordion.Item id="faq-2">
        <Accordion.Trigger id="faq-2">
          How long does shipping take?
        </Accordion.Trigger>
        <Accordion.Content id="faq-2">
          Standard shipping takes 5-7 business days. Express shipping
          is available for 2-3 day delivery.
        </Accordion.Content>
      </Accordion.Item>
    </Accordion>
  );
}

More Complex Example: Select/Dropdown

// src/components/Select/index.tsx
import {
  createContext,
  useContext,
  useState,
  useRef,
  useEffect,
  ReactNode,
  KeyboardEvent,
} from 'react';

interface SelectContextValue {
  isOpen: boolean;
  setIsOpen: (open: boolean) => void;
  selectedValue: string | null;
  selectValue: (value: string) => void;
  highlightedIndex: number;
  setHighlightedIndex: (index: number) => void;
  options: string[];
  registerOption: (value: string) => void;
}

const SelectContext = createContext<SelectContextValue | null>(null);

function useSelectContext() {
  const context = useContext(SelectContext);
  if (!context) {
    throw new Error('Select components must be used within a Select');
  }
  return context;
}

// Root component
interface SelectProps {
  children: ReactNode;
  value?: string;
  onChange?: (value: string) => void;
  defaultValue?: string;
}

export function Select({
  children,
  value: controlledValue,
  onChange,
  defaultValue,
}: SelectProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [internalValue, setInternalValue] = useState(defaultValue ?? null);
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
  const [options, setOptions] = useState<string[]>([]);

  const isControlled = controlledValue !== undefined;
  const selectedValue = isControlled ? controlledValue : internalValue;

  const selectValue = (value: string) => {
    if (!isControlled) {
      setInternalValue(value);
    }
    onChange?.(value);
    setIsOpen(false);
  };

  const registerOption = (value: string) => {
    setOptions((prev) => {
      if (prev.includes(value)) return prev;
      return [...prev, value];
    });
  };

  return (
    <SelectContext.Provider
      value={{
        isOpen,
        setIsOpen,
        selectedValue,
        selectValue,
        highlightedIndex,
        setHighlightedIndex,
        options,
        registerOption,
      }}
    >
      <div className="relative inline-block">{children}</div>
    </SelectContext.Provider>
  );
}

// Trigger button
interface SelectTriggerProps {
  children?: ReactNode;
  placeholder?: string;
}

export function SelectTrigger({ children, placeholder = 'Select...' }: SelectTriggerProps) {
  const { isOpen, setIsOpen, selectedValue, options, setHighlightedIndex } =
    useSelectContext();
  const triggerRef = useRef<HTMLButtonElement>(null);

  const handleKeyDown = (e: KeyboardEvent) => {
    switch (e.key) {
      case 'Enter':
      case ' ':
        e.preventDefault();
        setIsOpen(!isOpen);
        if (!isOpen) {
          setHighlightedIndex(0);
        }
        break;
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          setHighlightedIndex(0);
        }
        break;
      case 'Escape':
        setIsOpen(false);
        break;
    }
  };

  return (
    <button
      ref={triggerRef}
      onClick={() => setIsOpen(!isOpen)}
      onKeyDown={handleKeyDown}
      aria-haspopup="listbox"
      aria-expanded={isOpen}
      className="flex items-center justify-between min-w-[200px] px-4 py-2 border rounded"
    >
      {children ?? selectedValue ?? placeholder}
      <ChevronIcon className={isOpen ? 'rotate-180' : ''} />
    </button>
  );
}

// Options container
interface SelectOptionsProps {
  children: ReactNode;
}

export function SelectOptions({ children }: SelectOptionsProps) {
  const { isOpen, highlightedIndex, setHighlightedIndex, options, selectValue } =
    useSelectContext();
  const listRef = useRef<HTMLUListElement>(null);

  useEffect(() => {
    if (!isOpen) return;

    const handleKeyDown = (e: globalThis.KeyboardEvent) => {
      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault();
          setHighlightedIndex(Math.min(highlightedIndex + 1, options.length - 1));
          break;
        case 'ArrowUp':
          e.preventDefault();
          setHighlightedIndex(Math.max(highlightedIndex - 1, 0));
          break;
        case 'Enter':
          e.preventDefault();
          if (highlightedIndex >= 0) {
            selectValue(options[highlightedIndex]);
          }
          break;
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, highlightedIndex, options, selectValue, setHighlightedIndex]);

  if (!isOpen) return null;

  return (
    <ul
      ref={listRef}
      role="listbox"
      className="absolute top-full left-0 w-full mt-1 bg-white border rounded shadow-lg z-50"
    >
      {children}
    </ul>
  );
}

// Individual option
interface SelectOptionProps {
  value: string;
  children: ReactNode;
}

export function SelectOption({ value, children }: SelectOptionProps) {
  const {
    selectValue,
    selectedValue,
    highlightedIndex,
    setHighlightedIndex,
    options,
    registerOption,
  } = useSelectContext();

  useEffect(() => {
    registerOption(value);
  }, [value, registerOption]);

  const index = options.indexOf(value);
  const isSelected = selectedValue === value;
  const isHighlighted = highlightedIndex === index;

  return (
    <li
      role="option"
      aria-selected={isSelected}
      onClick={() => selectValue(value)}
      onMouseEnter={() => setHighlightedIndex(index)}
      className={`
        px-4 py-2 cursor-pointer
        ${isHighlighted ? 'bg-blue-50' : ''}
        ${isSelected ? 'font-semibold' : ''}
      `}
    >
      {children}
    </li>
  );
}

// Dot notation exports
Select.Trigger = SelectTrigger;
Select.Options = SelectOptions;
Select.Option = SelectOption;
// Usage
function CountrySelector() {
  const [country, setCountry] = useState('');

  return (
    <Select value={country} onChange={setCountry}>
      <Select.Trigger placeholder="Choose a country" />
      <Select.Options>
        <Select.Option value="us">United States</Select.Option>
        <Select.Option value="uk">United Kingdom</Select.Option>
        <Select.Option value="ca">Canada</Select.Option>
        <Select.Option value="au">Australia</Select.Option>
      </Select.Options>
    </Select>
  );
}

When to Use Compound Components

Good fit:

  • Component groups that share implicit state (tabs, accordions, dropdowns)
  • APIs where composition matters (<Menu><Menu.Item/> is clearer than passing arrays)
  • Components with optional subcomponents

Not ideal:

  • Simple components with few props
  • When you need to pass data between distant children (consider different patterns)

Container/Presenter Pattern

This pattern separates data concerns from presentation concerns. Containers handle data fetching, state management, and business logic. Presenters (or "dumb" components) only render UI based on props.

Classic Implementation

// src/components/UserList/UserListPresenter.tsx
// Pure presentational component - no data fetching, no complex logic
interface User {
  id: string;
  name: string;
  email: string;
  avatar: string;
}

interface UserListPresenterProps {
  users: User[];
  isLoading: boolean;
  error: Error | null;
  onUserClick: (userId: string) => void;
  onRetry: () => void;
}

export function UserListPresenter({
  users,
  isLoading,
  error,
  onUserClick,
  onRetry,
}: UserListPresenterProps) {
  if (isLoading) {
    return (
      <div className="space-y-4">
        {[1, 2, 3].map((i) => (
          <div key={i} className="animate-pulse flex items-center space-x-4">
            <div className="w-12 h-12 bg-gray-200 rounded-full" />
            <div className="flex-1 space-y-2">
              <div className="h-4 bg-gray-200 rounded w-1/4" />
              <div className="h-3 bg-gray-200 rounded w-1/3" />
            </div>
          </div>
        ))}
      </div>
    );
  }

  if (error) {
    return (
      <div className="text-center py-8">
        <p className="text-red-600 mb-4">Failed to load users: {error.message}</p>
        <button
          onClick={onRetry}
          className="px-4 py-2 bg-blue-500 text-white rounded"
        >
          Try Again
        </button>
      </div>
    );
  }

  if (users.length === 0) {
    return (
      <div className="text-center py-8 text-gray-500">
        No users found
      </div>
    );
  }

  return (
    <ul className="divide-y divide-gray-200">
      {users.map((user) => (
        <li
          key={user.id}
          onClick={() => onUserClick(user.id)}
          className="flex items-center space-x-4 py-4 cursor-pointer hover:bg-gray-50"
        >
          <img
            src={user.avatar}
            alt=""
            className="w-12 h-12 rounded-full"
          />
          <div>
            <p className="font-medium">{user.name}</p>
            <p className="text-sm text-gray-500">{user.email}</p>
          </div>
        </li>
      ))}
    </ul>
  );
}
// src/components/UserList/UserListContainer.tsx
// Container handles all data logic
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useFetch } from '../../hooks/useFetch';
import { UserListPresenter } from './UserListPresenter';

export function UserListContainer() {
  const navigate = useNavigate();
  const { data: users, error, isLoading, refetch } = useFetch<User[]>('/api/users');

  const handleUserClick = useCallback((userId: string) => {
    navigate(`/users/${userId}`);
  }, [navigate]);

  return (
    <UserListPresenter
      users={users ?? []}
      isLoading={isLoading}
      error={error}
      onUserClick={handleUserClick}
      onRetry={refetch}
    />
  );
}
// src/pages/UsersPage.tsx
import { UserListContainer } from '../components/UserList/UserListContainer';

export function UsersPage() {
  return (
    <div className="max-w-2xl mx-auto py-8">
      <h1 className="text-2xl font-bold mb-6">Users</h1>
      <UserListContainer />
    </div>
  );
}

Modern Hooks Version

With hooks, the separation often happens inside a single component file:

// src/components/UserList.tsx
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useFetch } from '../hooks/useFetch';

// Custom hook encapsulates "container" logic
function useUserList() {
  const navigate = useNavigate();
  const { data, error, isLoading, refetch } = useFetch<User[]>('/api/users');

  const handleUserClick = useCallback((userId: string) => {
    navigate(`/users/${userId}`);
  }, [navigate]);

  return {
    users: data ?? [],
    error,
    isLoading,
    refetch,
    handleUserClick,
  };
}

// Component is primarily presentational
export function UserList() {
  const { users, error, isLoading, refetch, handleUserClick } = useUserList();

  if (isLoading) return <UserListSkeleton />;
  if (error) return <ErrorState error={error} onRetry={refetch} />;
  if (users.length === 0) return <EmptyState />;

  return (
    <ul className="divide-y divide-gray-200">
      {users.map((user) => (
        <UserListItem
          key={user.id}
          user={user}
          onClick={() => handleUserClick(user.id)}
        />
      ))}
    </ul>
  );
}

Testing Benefits

The main win is testability. Presenter components are trivial to test:

// src/components/UserList/UserListPresenter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { UserListPresenter } from './UserListPresenter';

const mockUsers = [
  { id: '1', name: 'Alice', email: '[email protected]', avatar: '/avatars/1.jpg' },
  { id: '2', name: 'Bob', email: '[email protected]', avatar: '/avatars/2.jpg' },
];

describe('UserListPresenter', () => {
  const defaultProps = {
    users: [],
    isLoading: false,
    error: null,
    onUserClick: vi.fn(),
    onRetry: vi.fn(),
  };

  it('renders loading state', () => {
    render(<UserListPresenter {...defaultProps} isLoading={true} />);
    expect(screen.getAllByRole('status')).toHaveLength(3); // Skeleton items
  });

  it('renders error state with retry button', () => {
    const error = new Error('Network error');
    render(<UserListPresenter {...defaultProps} error={error} />);

    expect(screen.getByText(/failed to load users/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
  });

  it('renders user list and handles clicks', () => {
    const onUserClick = vi.fn();
    render(
      <UserListPresenter {...defaultProps} users={mockUsers} onUserClick={onUserClick} />
    );

    expect(screen.getByText('Alice')).toBeInTheDocument();
    expect(screen.getByText('[email protected]')).toBeInTheDocument();

    fireEvent.click(screen.getByText('Alice'));
    expect(onUserClick).toHaveBeenCalledWith('1');
  });

  it('renders empty state', () => {
    render(<UserListPresenter {...defaultProps} users={[]} />);
    expect(screen.getByText(/no users found/i)).toBeInTheDocument();
  });
});

When the Lines Blur

In modern React, strict separation isn't always necessary:

  • Small components: A component fetching its own data is fine if it's simple
  • Server Components (Next.js/React 19): Data fetching moves to the server, blurring container/presenter
  • Co-location: Sometimes keeping data logic near the UI that uses it is clearer

The pattern is most valuable when:

  • You need multiple presentational variants (desktop vs. mobile, different themes)
  • You want to reuse presentation with different data sources
  • You're testing complex UI states

Controlled vs. Uncontrolled Components

This pattern determines who "owns" a component's state—the component itself (uncontrolled) or the parent (controlled).

Uncontrolled: Component Manages State

// Uncontrolled: input manages its own value
function UncontrolledInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = () => {
    // Read value imperatively when needed
    console.log(inputRef.current?.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} defaultValue="initial" />
      <button type="submit">Submit</button>
    </form>
  );
}

Controlled: Parent Manages State

// Controlled: parent owns the value
function ControlledInput() {
  const [value, setValue] = useState('initial');

  const handleSubmit = () => {
    console.log(value); // Always have current value
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

Building Flexible Components with Control Props

The best component APIs support both patterns:

// src/components/Toggle.tsx
import { useState, useCallback } from 'react';

interface ToggleProps {
  // Controlled props
  checked?: boolean;
  onChange?: (checked: boolean) => void;

  // Uncontrolled props
  defaultChecked?: boolean;

  // Common props
  disabled?: boolean;
  label?: string;
}

export function Toggle({
  checked: controlledChecked,
  onChange,
  defaultChecked = false,
  disabled = false,
  label,
}: ToggleProps) {
  // Track whether we're in controlled mode
  const isControlled = controlledChecked !== undefined;

  // Internal state for uncontrolled mode
  const [internalChecked, setInternalChecked] = useState(defaultChecked);

  // Use controlled value if provided, otherwise internal
  const checked = isControlled ? controlledChecked : internalChecked;

  const handleChange = useCallback(() => {
    if (disabled) return;

    const newValue = !checked;

    // Update internal state if uncontrolled
    if (!isControlled) {
      setInternalChecked(newValue);
    }

    // Always call onChange if provided
    onChange?.(newValue);
  }, [checked, disabled, isControlled, onChange]);

  return (
    <button
      role="switch"
      aria-checked={checked}
      aria-disabled={disabled}
      onClick={handleChange}
      className={`
        relative w-12 h-6 rounded-full transition-colors
        ${checked ? 'bg-blue-500' : 'bg-gray-300'}
        ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
      `}
    >
      <span
        className={`
          absolute top-1 w-4 h-4 bg-white rounded-full transition-transform
          ${checked ? 'translate-x-6' : 'translate-x-1'}
        `}
      />
      {label && <span className="sr-only">{label}</span>}
    </button>
  );
}
// Usage: Uncontrolled with default
function PreferencesForm() {
  return (
    <form>
      <Toggle defaultChecked={true} label="Email notifications" />
      {/* Read value with a ref or on submit */}
    </form>
  );
}

// Usage: Controlled with state
function SettingsPanel() {
  const [darkMode, setDarkMode] = useState(false);

  return (
    <div>
      <Toggle
        checked={darkMode}
        onChange={setDarkMode}
        label="Dark mode"
      />
      <p>Current mode: {darkMode ? 'Dark' : 'Light'}</p>
    </div>
  );
}

// Usage: Controlled with external state (form library)
function FormLibraryExample() {
  const { register, watch, setValue } = useForm();
  const notifications = watch('notifications');

  return (
    <Toggle
      checked={notifications}
      onChange={(v) => setValue('notifications', v)}
      label="Notifications"
    />
  );
}

Warning: Don't Switch Modes

A component should stay either controlled or uncontrolled throughout its lifecycle:

// ❌ BAD: switching from uncontrolled to controlled
function BadExample() {
  const [value, setValue] = useState<string | undefined>(undefined);

  return (
    <input
      value={value} // undefined → controlled later = warning
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

// ✅ GOOD: always controlled
function GoodExample() {
  const [value, setValue] = useState('');

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

React will warn you:

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen.

Choosing Between Controlled and Uncontrolled

Use uncontrolled when:

  • You only need the value at specific moments (form submission)
  • Performance is critical (fewer re-renders)
  • Integrating with non-React code

Use controlled when:

  • You need to validate/transform input in real-time
  • Multiple components need to stay in sync
  • You're implementing complex form logic
  • You need to programmatically reset or modify values

See Also

Last updated: March 23, 2026