· 7 min read

React Component Patterns: Tricks to Enhance Reusability

Practical component patterns and tricks to design reusable, composable, and maintainable React components for real-world apps.

Why patterns matter

Reusability is more than copying and pasting components. It’s about designing small, focused APIs that adapt to different needs, scale with your app, and are predictable for other developers. This article walks through practical component patterns and little tricks that make React components more reusable, composable, and robust.

Note: examples use plain React (hooks) and aim for clarity rather than production-ready details. For more fundamentals, see the React docs on hooks and general patterns at React Patterns.


Guiding principles for reusable components

  • Composition over inheritance. Compose small units into larger ones.
  • Minimal, predictable API. Small surface area + sensible defaults.
  • Separation of concerns. Keep state, markup, and behavior decoupled when possible.
  • Backwards-compatible extension. Allow consumers to opt in to advanced features.
  • Accessibility and performance by default.

With those in mind, here are patterns and tricks you can apply.


Presentational vs Container components (conceptual)

Split components by responsibility:

  • Presentational component: focused on markup and styling, accepts props for data and callbacks.
  • Container component: handles data fetching and state, renders presentational components.

This keeps UI flexible and reusable. With hooks and custom hooks, the boundary is often functional rather than separate files, but the concept still helps design APIs.


Compound components

Use when multiple UI parts need to share internal state while keeping a nice declarative API for consumers.

Example: a simple Toggle compound API where consumers use nested subcomponents.

// Toggle.js
import React, { useState, createContext, useContext } from 'react';

const ToggleContext = createContext(null);

export function Toggle({ children, initial = false }) {
  const [on, setOn] = useState(initial);
  const value = { on, toggle: () => setOn(o => !o) };
  return (
    <ToggleContext.Provider value={value}>{children}</ToggleContext.Provider>
  );
}

export function ToggleOn({ children }) {
  const ctx = useContext(ToggleContext);
  return ctx.on ? children : null;
}

export function ToggleOff({ children }) {
  const ctx = useContext(ToggleContext);
  return ctx.on ? null : children;
}

export function ToggleButton() {
  const ctx = useContext(ToggleContext);
  return <button onClick={ctx.toggle}>{ctx.on ? 'On' : 'Off'}</button>;
}

Usage stays ergonomic:

<Toggle>
  <ToggleOn>It's on</ToggleOn>
  <ToggleOff>It's off</ToggleOff>
  <ToggleButton />
</Toggle>

Why this is reusable: consumers can compose only the pieces they need, and the internal state is centrally managed.

See Kent C. Dodds’ writeup on compound components for deeper patterns: https://kentcdodds.com/blog/compound-components-with-react-hooks


Render props

Render props let you pass a function prop that receives internal state/handlers and returns elements. Good for flexible rendering.

function MouseTracker({ children }) {
  const [pos, setPos] = React.useState({ x: 0, y: 0 });
  React.useEffect(() => {
    function onMove(e) {
      setPos({ x: e.clientX, y: e.clientY });
    }
    window.addEventListener('mousemove', onMove);
    return () => window.removeEventListener('mousemove', onMove);
  }, []);
  return children(pos);
}

// usage
<MouseTracker>
  {pos => (
    <div>
      Mouse at {pos.x}, {pos.y}
    </div>
  )}
</MouseTracker>;

Trade-offs: extremely flexible but can create nested functions in JSX. Hook patterns often replace simple use cases.


Higher-order components (HOC)

HOCs are functions that wrap a component to augment behavior. Useful for cross-cutting concerns.

function withLoading(Component) {
  return function WithLoading({ isLoading, ...props }) {
    if (isLoading) return <div>Loading...</div>;
    return <Component {...props} />;
  };
}

Be mindful of hoisting static methods, refs, and display names. Prefer hooks and composition for many new cases, but HOCs are still useful when you need to wrap components dynamically.

See the React docs on HOCs for patterns and caveats: https://reactjs.org/docs/higher-order-components.html


Custom hooks: logic reusability

Hooks are the primary way to reuse behaviour in modern React.

function useToggle(initial = false) {
  const [on, setOn] = React.useState(initial);
  const toggle = React.useCallback(() => setOn(o => !o), []);
  return { on, toggle, setOn };
}

// usage
function MyComponent() {
  const { on, toggle } = useToggle();
  return <button onClick={toggle}>{on ? 'On' : 'Off'}</button>;
}

Custom hooks pair nicely with compound components: hooks handle state, while compound subcomponents handle markup.


Controlled vs Uncontrolled components

Design components to support both controlled and uncontrolled usage when appropriate (like inputs). Offer a simple API that lets consumers decide:

  • Controlled: parent manages state and passes value + onChange.
  • Uncontrolled: component manages its own state and exposes callbacks and refs.

Pattern:

function TextInput({ value, defaultValue, onChange, ...props }) {
  const [internal, setInternal] = React.useState(defaultValue || '');
  const isControlled = value !== undefined;
  const current = isControlled ? value : internal;
  function handleChange(e) {
    const v = e.target.value;
    if (!isControlled) setInternal(v);
    onChange && onChange(v);
  }
  return <input value={current} onChange={handleChange} {...props} />;
}

This gives flexibility and predictable defaults.


Prop getters and the state reducer pattern (Downshift-style)

When a component needs to expose many props for consumers to spread onto DOM nodes (e.g., open/close, aria attributes, event handlers), use prop-getter functions. This centralizes behavior and avoids accidental override.

Example prop getter API:

function useDropdown() {
  const [open, setOpen] = React.useState(false);
  function getToggleProps({ onClick, ...rest } = {}) {
    return {
      'aria-expanded': open,
      onClick: e => {
        setOpen(o => !o);
        onClick && onClick(e);
      },
      ...rest,
    };
  }
  return { open, getToggleProps };
}

State reducer pattern: let consumers control or intercept state changes with a reducer callback. Downshift uses this to be extremely flexible. See Downshift repo for a real-world example: https://github.com/downshift-js/downshift


Forwarding refs and useImperativeHandle

To be reusable with parent refs (e.g., focusing an internal input), forward refs and optionally expose a controlled imperative API.

const FancyInput = React.forwardRef(function FancyInput(props, ref) {
  const inputRef = React.useRef(null)
  React.useImperativeHandle(ref, () => ({
    focus: () => inputRef.current && inputRef.current.focus()
  }))
  return <input ref={inputRef} {...props} />
})

// usage
const ref = React.createRef()
<FancyInput ref={ref} />
// ref.current.focus()

Docs: https://reactjs.org/docs/forwarding-refs.html


Polymorphic components (as prop)

Allow components to render different HTML elements via an “as” prop. Helpful for buttons that sometimes need to be a link or a div.

function Box({ as: Component = 'div', children, ...props }) {
  return <Component {...props}>{children}</Component>
}

// usage
<Box as='button' onClick={...}>Click me</Box>
<Box as='a' href='...'>Link</Box>

In TypeScript, polymorphic components require careful typing to keep IntelliSense correct.


Slots and children-as-a-function

Slots give precise control over where children render. You can combine compound components with slot props.

function Modal({ header, body, footer }) {
  return (
    <div className="modal">
      <div className="modal-header">{header}</div>
      <div className="modal-body">{body}</div>
      <div className="modal-footer">{footer}</div>
    </div>
  );
}

// usage
<Modal
  header={<h1>Title</h1>}
  body={<p>Content</p>}
  footer={<button>Close</button>}
/>;

This pattern is explicit and useful for controlled layout.


Composition tricks and small API decisions that matter

  • Prop getters vs spread props: use getter functions to merge event handlers safely.
  • className merging: accept className prop and merge (use utilities like clsx); avoid overwriting.
  • Provide sensible defaults and allow overrides via props or render functions.
  • Keep state local by default but expose hooks/props to opt into control.
  • Keep components small and single-purpose, then compose them.

Performance patterns

  • memoize presentational components with React.memo when props are stable.
  • Use useCallback/useMemo to avoid unnecessary re-renders for callbacks/derived values.
  • Avoid over-optimizing early; measure first. For lists, prefer virtualization.

Example:

const Item = React.memo(function Item({ data, onClick }) { ... })

Accessibility is part of reusability

Reusable components should have accessible defaults:

  • Manage focus appropriately for dialogs and menus.
  • Expose aria attributes through prop-getters so consumers can add/override attributes.
  • Provide keyboard interactions by default and allow consumers to extend handlers.

Downshift and Reach UI are great references for accessible patterns.


Testing and documentation

  • Document the component API with prop examples and use-cases.
  • Write unit tests for behavior (state changes, prop-getter merging, callbacks).
  • Test as both controlled and uncontrolled where applicable.

TypeScript tips

  • Prefer narrow, explicit prop types for public APIs.
  • For polymorphic components, consider established typing patterns (generic component with extends).
  • Type prop-getters and state reducer callbacks so consumers can override safely.

Example: Bringing patterns together

A small Select component skeleton that uses hooks, prop getters, controlled/uncontrolled support, and forwardRef.

function useSelect({ value: controlledValue, defaultValue, onChange }) {
  const isControlled = controlledValue !== undefined;
  const [value, setValue] = React.useState(defaultValue || null);
  const current = isControlled ? controlledValue : value;
  const openRef = React.useRef(false);

  function select(v) {
    if (!isControlled) setValue(v);
    onChange && onChange(v);
  }

  function getToggleProps({ onClick, ...rest } = {}) {
    return {
      onClick: e => {
        openRef.current = !openRef.current;
        onClick && onClick(e);
      },
      'aria-expanded': openRef.current,
      ...rest,
    };
  }

  return { value: current, select, getToggleProps };
}

This skeleton demonstrates how multiple tricks interoperate to produce a flexible API.


When to prefer which pattern

  • Small behavior extraction: custom hooks.
  • Shared UI parts that must coordinate: compound components or context.
  • Flexible rendering: render props or children-as-function.
  • Cross-cutting behavior: HOC or wrapper component.
  • DOM ref and imperative actions: forwardRef + useImperativeHandle.
  • Many DOM props and merging handlers: prop-getters.

Final checklist for reusable components

  • Is the API minimal and predictable?
  • Can consumers opt into or out of internal state?
  • Are behaviors composable, not monolithic?
  • Is accessibility considered by default?
  • Is performance reasonable for common use-cases?

Reusable components are not a single pattern. They are a combination of small design choices that make components composable, extensible, and safe to use in many contexts.

Recommended reading

Back to Blog

Related Posts

View All Posts »

Using React with TypeScript: Tips and Tricks

Practical, example-driven guide to using TypeScript with React. Covers component typing, hooks, refs, generics, polymorphic components, utility types, and tooling tips to make your React code safer and more maintainable.

Unconventional React Tricks: Beyond the Basics

A practical guide to unconventional but practical React techniques - from useRef hacks and portals to transitions, Suspense patterns and off-main-thread work - with when-to-use guidance, pitfalls and examples.

The Hidden Costs of AI Tools for JavaScript: What No One Tells You

Integrating AI into JavaScript workflows can boost productivity - but it also introduces hard-to-see costs: compute, data, maintenance, security, and organizational overhead. This article uncovers those hidden costs and gives practical mitigation steps, checklists, and a realistic cost model to help you plan.

Bash Shell Commands for Data Science: An Essential Toolkit

A practical, example-driven guide to the Bash and Unix command-line tools that every data scientist should know for fast, repeatable dataset inspection, cleaning, transformation and merging - including tips for handling large files and messy real-world data.