Learning Guides
Menu

Component Patterns

10 min readFrontend Patterns & Concepts

Component Patterns

Well-designed components are the building blocks of maintainable applications. These patterns help you create flexible, reusable, and testable components.

Compound Components

Components that work together to form a complete UI, sharing implicit state.

JAVASCRIPT
// Usage - notice the declarative, flexible API
<Tabs>
  <Tabs.List>
    <Tabs.Tab>Account</Tabs.Tab>
    <Tabs.Tab>Security</Tabs.Tab>
    <Tabs.Tab>Notifications</Tabs.Tab>
  </Tabs.List>
 
  <Tabs.Panels>
    <Tabs.Panel>Account settings...</Tabs.Panel>
    <Tabs.Panel>Security settings...</Tabs.Panel>
    <Tabs.Panel>Notification preferences...</Tabs.Panel>
  </Tabs.Panels>
</Tabs>

Implementation

JAVASCRIPT
import {
  createContext,
  useContext,
  useState,
  Children,
  cloneElement,
} from "react";
 
const TabsContext = createContext();
 
function Tabs({ children, defaultIndex = 0, onChange }) {
  const [activeIndex, setActiveIndex] = useState(defaultIndex);
 
  const handleChange = (index) => {
    setActiveIndex(index);
    onChange?.(index);
  };
 
  return (
    <TabsContext.Provider value={{ activeIndex, setActiveIndex: handleChange }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}
 
function TabList({ children }) {
  const { activeIndex, setActiveIndex } = useContext(TabsContext);
 
  return (
    <div className="tab-list" role="tablist">
      {Children.map(children, (child, index) =>
        cloneElement(child, {
          isActive: index === activeIndex,
          onClick: () => setActiveIndex(index),
          index,
        }),
      )}
    </div>
  );
}
 
function Tab({ children, isActive, onClick, index }) {
  return (
    <button
      role="tab"
      aria-selected={isActive}
      className={`tab ${isActive ? "active" : ""}`}
      onClick={onClick}
      id={`tab-${index}`}
      aria-controls={`panel-${index}`}
    >
      {children}
    </button>
  );
}
 
function TabPanels({ children }) {
  const { activeIndex } = useContext(TabsContext);
 
  return (
    <div className="tab-panels">
      {Children.map(children, (child, index) =>
        cloneElement(child, {
          isActive: index === activeIndex,
          index,
        }),
      )}
    </div>
  );
}
 
function TabPanel({ children, isActive, index }) {
  return (
    <div
      role="tabpanel"
      id={`panel-${index}`}
      aria-labelledby={`tab-${index}`}
      hidden={!isActive}
      className="tab-panel"
    >
      {isActive && children}
    </div>
  );
}
 
// Attach sub-components
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;
 
export default Tabs;

More Complex Example: Accordion

JAVASCRIPT
const AccordionContext = createContext();
const ItemContext = createContext();
 
function Accordion({ children, allowMultiple = false, defaultOpen = [] }) {
  const [openItems, setOpenItems] = useState(new Set(defaultOpen));
 
  const toggle = (id) => {
    setOpenItems((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        if (!allowMultiple) next.clear();
        next.add(id);
      }
      return next;
    });
  };
 
  const isOpen = (id) => openItems.has(id);
 
  return (
    <AccordionContext.Provider value={{ toggle, isOpen }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
}
 
function Item({ children, id }) {
  const { isOpen } = useContext(AccordionContext);
 
  return (
    <ItemContext.Provider value={{ id, isOpen: isOpen(id) }}>
      <div className={`accordion-item ${isOpen(id) ? "open" : ""}`}>
        {children}
      </div>
    </ItemContext.Provider>
  );
}
 
function Header({ children }) {
  const { toggle } = useContext(AccordionContext);
  const { id, isOpen } = useContext(ItemContext);
 
  return (
    <button
      className="accordion-header"
      onClick={() => toggle(id)}
      aria-expanded={isOpen}
    >
      {children}
      <span className="icon">{isOpen ? "−" : "+"}</span>
    </button>
  );
}
 
function Content({ children }) {
  const { isOpen } = useContext(ItemContext);
 
  return (
    <div
      className="accordion-content"
      style={{ display: isOpen ? "block" : "none" }}
    >
      {children}
    </div>
  );
}
 
Accordion.Item = Item;
Accordion.Header = Header;
Accordion.Content = Content;

Render Props

Pass a function as a child or prop to share logic between components.

JAVASCRIPT
// The pattern
function DataFetcher({ url, children }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    fetch(url)
      .then((r) => r.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);
 
  // Call children as a function with state
  return children({ data, loading, error });
}
 
// Usage
<DataFetcher url="/api/users">
  {({ data, loading, error }) => {
    if (loading) return <Spinner />;
    if (error) return <Error message={error.message} />;
    return <UserList users={data} />;
  }}
</DataFetcher>;

Mouse Position Tracker

JAVASCRIPT
function MouseTracker({ children }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });
 
  useEffect(() => {
    const handleMove = (e) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };
 
    window.addEventListener("mousemove", handleMove);
    return () => window.removeEventListener("mousemove", handleMove);
  }, []);
 
  return children(position);
}
 
// Usage
<MouseTracker>
  {({ x, y }) => (
    <div style={{ position: "fixed", left: x + 10, top: y + 10 }}>
      Tooltip follows mouse
    </div>
  )}
</MouseTracker>;

Toggle with Render Props

JAVASCRIPT
function Toggle({ initial = false, children }) {
  const [on, setOn] = useState(initial);
 
  const toggle = useCallback(() => setOn((v) => !v), []);
  const setTrue = useCallback(() => setOn(true), []);
  const setFalse = useCallback(() => setOn(false), []);
 
  return children({
    on,
    off: !on,
    toggle,
    setTrue,
    setFalse,
  });
}
 
// Usage
<Toggle>
  {({ on, toggle }) => (
    <div>
      <button onClick={toggle}>{on ? "Hide" : "Show"}</button>
      {on && <Content />}
    </div>
  )}
</Toggle>;

Higher-Order Components (HOCs)

Functions that take a component and return an enhanced component.

JAVASCRIPT
// Basic HOC pattern
function withLogger(WrappedComponent) {
  return function LoggerComponent(props) {
    useEffect(() => {
      console.log(`${WrappedComponent.name} mounted with props:`, props);
      return () => console.log(`${WrappedComponent.name} unmounted`);
    }, []);
 
    return <WrappedComponent {...props} />;
  };
}
 
// Usage
const EnhancedButton = withLogger(Button);

Practical HOC: withAuth

JAVASCRIPT
function withAuth(WrappedComponent, options = {}) {
  const { redirectTo = "/login", requiredRole = null } = options;
 
  return function AuthenticatedComponent(props) {
    const { user, loading } = useAuth();
    const navigate = useNavigate();
 
    useEffect(() => {
      if (!loading && !user) {
        navigate(redirectTo, { replace: true });
      }
 
      if (requiredRole && user?.role !== requiredRole) {
        navigate("/unauthorized", { replace: true });
      }
    }, [user, loading, navigate]);
 
    if (loading) return <LoadingScreen />;
    if (!user) return null;
    if (requiredRole && user.role !== requiredRole) return null;
 
    return <WrappedComponent {...props} user={user} />;
  };
}
 
// Usage
const AdminDashboard = withAuth(Dashboard, { requiredRole: "admin" });
const UserProfile = withAuth(Profile);

Data Fetching HOC

JAVASCRIPT
function withData(WrappedComponent, fetchData) {
  return function DataComponent(props) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
 
    useEffect(
      () => {
        let cancelled = false;
 
        fetchData(props)
          .then((data) => {
            if (!cancelled) setData(data);
          })
          .catch((error) => {
            if (!cancelled) setError(error);
          })
          .finally(() => {
            if (!cancelled) setLoading(false);
          });
 
        return () => {
          cancelled = true;
        };
      },
      [
        /* relevant props */
      ],
    );
 
    if (loading) return <Loading />;
    if (error) return <Error error={error} />;
 
    return <WrappedComponent {...props} data={data} />;
  };
}
 
// Usage
const UserProfileWithData = withData(UserProfile, (props) =>
  fetch(`/api/users/${props.userId}`).then((r) => r.json()),
);

Custom Hooks: The Modern Alternative

Most HOC and render props patterns are better expressed as hooks.

JAVASCRIPT
// Instead of HOC
function useAuth() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    authService
      .getCurrentUser()
      .then(setUser)
      .finally(() => setLoading(false));
  }, []);
 
  const login = useCallback(async (credentials) => {
    const user = await authService.login(credentials);
    setUser(user);
    return user;
  }, []);
 
  const logout = useCallback(async () => {
    await authService.logout();
    setUser(null);
  }, []);
 
  return { user, loading, login, logout, isAuthenticated: !!user };
}
 
// Instead of render props mouse tracker
function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
 
  useEffect(() => {
    const handler = (e) => setPosition({ x: e.clientX, y: e.clientY });
    window.addEventListener("mousemove", handler);
    return () => window.removeEventListener("mousemove", handler);
  }, []);
 
  return position;
}
 
// Usage is cleaner
function Tooltip({ children }) {
  const { x, y } = useMousePosition();
  return <div style={{ left: x, top: y }}>{children}</div>;
}

Controlled vs Uncontrolled Components

Uncontrolled: Component manages its own state

JAVASCRIPT
function UncontrolledInput({ defaultValue, onSubmit }) {
  const inputRef = useRef();
 
  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit(inputRef.current.value);
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} defaultValue={defaultValue} />
      <button type="submit">Submit</button>
    </form>
  );
}
 
// Usage - no need to manage state
<UncontrolledInput
  defaultValue="Hello"
  onSubmit={(value) => console.log(value)}
/>;

Controlled: Parent manages state

JAVASCRIPT
function ControlledInput({ value, onChange }) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
 
// Usage - full control in parent
function Parent() {
  const [value, setValue] = useState("");
 
  return (
    <div>
      <ControlledInput value={value} onChange={setValue} />
      <p>You typed: {value}</p>
      <button onClick={() => setValue("")}>Clear</button>
    </div>
  );
}

Hybrid: Support Both Modes

JAVASCRIPT
function FlexibleInput({
  value: controlledValue,
  defaultValue = '',
  onChange
}) {
  // Determine if controlled
  const isControlled = controlledValue !== undefined;
 
  // Internal state for uncontrolled mode
  const [internalValue, setInternalValue] = useState(defaultValue);
 
  // Use controlled or internal value
  const value = isControlled ? controlledValue : internalValue;
 
  const handleChange = (e) => {
    const newValue = e.target.value;
 
    if (!isControlled) {
      setInternalValue(newValue);
    }
 
    onChange?.(newValue);
  };
 
  return <input value={value} onChange={handleChange} />;
}
 
// Can be used either way
<FlexibleInput defaultValue="uncontrolled" onChange={console.log} />
<FlexibleInput value={controlled} onChange={setControlled} />

Provider Pattern

Inject dependencies via context for testability and flexibility.

JAVASCRIPT
// Theme provider
const ThemeContext = createContext({
  theme: "light",
  toggle: () => {},
});
 
function ThemeProvider({ children, initialTheme = "light" }) {
  const [theme, setTheme] = useState(initialTheme);
 
  const toggle = useCallback(() => {
    setTheme((t) => (t === "light" ? "dark" : "light"));
  }, []);
 
  const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);
 
  return (
    <ThemeContext.Provider value={value}>
      <div className={`theme-${theme}`}>{children}</div>
    </ThemeContext.Provider>
  );
}
 
const useTheme = () => useContext(ThemeContext);
 
// API client provider
const ApiContext = createContext();
 
function ApiProvider({ children, baseUrl }) {
  const client = useMemo(
    () => ({
      get: (path) => fetch(`${baseUrl}${path}`).then((r) => r.json()),
      post: (path, data) =>
        fetch(`${baseUrl}${path}`, {
          method: "POST",
          body: JSON.stringify(data),
          headers: { "Content-Type": "application/json" },
        }).then((r) => r.json()),
    }),
    [baseUrl],
  );
 
  return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>;
}
 
const useApi = () => useContext(ApiContext);

Slot Pattern

Allow parent to inject content into specific locations.

JAVASCRIPT
function Card({ children }) {
  let header, body, footer;
 
  Children.forEach(children, (child) => {
    if (child?.type === CardHeader) header = child;
    else if (child?.type === CardFooter) footer = child;
    else body = child;
  });
 
  return (
    <div className="card">
      {header && <div className="card-header">{header}</div>}
      <div className="card-body">{body}</div>
      {footer && <div className="card-footer">{footer}</div>}
    </div>
  );
}
 
function CardHeader({ children }) {
  return children;
}
 
function CardFooter({ children }) {
  return children;
}
 
Card.Header = CardHeader;
Card.Footer = CardFooter;
 
// Usage
<Card>
  <Card.Header>
    <h2>Title</h2>
  </Card.Header>
 
  <p>Card content goes here</p>
 
  <Card.Footer>
    <button>Action</button>
  </Card.Footer>
</Card>;

Polymorphic Components

Components that can render as different elements.

JAVASCRIPT
function Box({ as: Component = 'div', children, ...props }) {
  return <Component {...props}>{children}</Component>;
}
 
// Usage
<Box>Default div</Box>
<Box as="section">Renders as section</Box>
<Box as="article">Renders as article</Box>
<Box as={Link} to="/about">Renders as Link component</Box>

With TypeScript

TYPESCRIPT
type BoxProps<T extends React.ElementType> = {
  as?: T;
  children?: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'children'>;
 
function Box<T extends React.ElementType = 'div'>({
  as,
  children,
  ...props
}: BoxProps<T>) {
  const Component = as || 'div';
  return <Component {...props}>{children}</Component>;
}
 
// Now TypeScript knows the correct props
<Box as="a" href="/link">Link</Box>           // ✓ href is valid
<Box as="button" onClick={handler}>Click</Box> // ✓ onClick is valid
<Box as="a" onClick={handler}>Error</Box>      // ✗ would warn

Container/Presentational Pattern

Separate logic from presentation.

JAVASCRIPT
// Container: handles logic and data
function UserListContainer() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [filter, setFilter] = useState("");
 
  useEffect(() => {
    fetchUsers()
      .then(setUsers)
      .finally(() => setLoading(false));
  }, []);
 
  const filteredUsers = useMemo(
    () =>
      users.filter((u) => u.name.toLowerCase().includes(filter.toLowerCase())),
    [users, filter],
  );
 
  const handleDelete = async (id) => {
    await deleteUser(id);
    setUsers(users.filter((u) => u.id !== id));
  };
 
  return (
    <UserList
      users={filteredUsers}
      loading={loading}
      filter={filter}
      onFilterChange={setFilter}
      onDelete={handleDelete}
    />
  );
}
 
// Presentational: purely renders UI
function UserList({ users, loading, filter, onFilterChange, onDelete }) {
  if (loading) return <Skeleton />;
 
  return (
    <div>
      <input
        value={filter}
        onChange={(e) => onFilterChange(e.target.value)}
        placeholder="Filter users..."
      />
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            {user.name}
            <button onClick={() => onDelete(user.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Summary

PatternUse CaseBenefits
Compound ComponentsComplex UI with shared stateFlexible, declarative API
Render PropsSharing behaviorExplicit data flow
HOCsCross-cutting concernsReusable enhancement
Custom HooksReusable logicClean, composable
Controlled/UncontrolledForm inputsFlexibility
ProviderDependency injectionTestable, decoupled
SlotCustomizable layoutsClear structure
PolymorphicFlexible renderingType-safe flexibility
Container/PresentationalSeparation of concernsTestable, reusable

Key Principles:

  1. Prefer composition over inheritance
  2. Make components do one thing well
  3. Design APIs from usage, not implementation
  4. Start simple, add patterns when needed
  5. Custom hooks are often the simplest solution