Frontend

Building Reusable React Components

Mayur Dabhi
Mayur Dabhi
April 10, 2026
14 min read

One of React's greatest strengths is its component-based architecture. But the gap between writing components that work and writing components that are truly reusable is enormous. A reusable component is one that can be dropped into any part of your application — or even a completely different project — and behave correctly with minimal configuration. Getting there requires deliberate design choices, not just functional code.

In this guide, we'll explore the principles, patterns, and practical techniques that experienced React developers use to build components that stand the test of scale. Whether you're building a design system or simply trying to avoid copy-pasting code across your app, these strategies will fundamentally improve how you structure your UI.

The Reusability Rule of Three

Don't abstract too early. A good heuristic: if you've copied a component three times, it's time to generalize it. Premature abstraction leads to over-engineered components with too many props and unclear responsibilities. Build once, duplicate once, then abstract on the third use.

What Makes a Component Truly Reusable?

Reusability isn't just about avoiding duplicate code — it's about creating components with the right boundaries. A reusable component has a single, well-defined responsibility, accepts external configuration through props, and makes no assumptions about where it lives in the component tree.

The key characteristics of a well-designed reusable component:

Consumer <Button ... /> props Button Component Props Interface Render Logic Event Handlers JSX Output renders DOM Element <button>...</button>

Anatomy of a well-structured React component

Designing a Flexible Props API

The props API is your component's public contract. A well-designed API makes the component easy to use correctly and hard to use incorrectly. Start by studying how native HTML elements work — they're the best examples of flexible, well-designed APIs.

Use Sensible Defaults

Default props reduce boilerplate for consumers. The most common use case should require the fewest props:

Button.jsx
// Bad — every prop required
function Button({ label, onClick, variant, size, disabled, type }) {
  return (
    <button type={type} disabled={disabled} onClick={onClick}
      className={`btn btn-${variant} btn-${size}`}>
      {label}
    </button>
  );
}

// Good — sensible defaults, extensible
function Button({
  children,
  onClick,
  variant = 'primary',
  size = 'md',
  disabled = false,
  type = 'button',
  className = '',
  ...rest
}) {
  return (
    <button
      type={type}
      disabled={disabled}
      onClick={onClick}
      className={`btn btn-${variant} btn-${size} ${className}`.trim()}
      {...rest}
    >
      {children}
    </button>
  );
}

Notice the use of ...rest — this spread of remaining props onto the underlying element is a critical pattern. It means consumers can pass aria-label, data-testid, id, or any other native attribute without the component needing explicit support for each one.

Prefer children Over Label Props

Using children instead of a label or text prop makes the component dramatically more flexible. Consumers can pass plain text, icons, or complex JSX without any changes to the component itself.

Usage Comparison
// Rigid — label prop limits flexibility
<Button label="Save Changes" />

// Flexible — children works for everything
<Button>Save Changes</Button>
<Button><i className="fas fa-save" /> Save Changes</Button>
<Button><Spinner /> Saving...</Button>

Composition Over Configuration

The most powerful pattern for reusability in React is composition. Instead of building one mega-component with 30 props to cover every use case, you build several small components that work together. This is how React's own APIs are designed — think <select> and <option>.

The Compound Component Pattern

Compound components share state implicitly via React Context, letting the consumer assemble pieces in any order without prop drilling:

Tabs.jsx — Compound Component
import React, { createContext, useContext, useState } from 'react';

const TabsContext = createContext(null);

function Tabs({ children, defaultTab = 0 }) {
  const [activeTab, setActiveTab] = useState(defaultTab);

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

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

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

function TabPanel({ children, index }) {
  const { activeTab } = useContext(TabsContext);
  if (activeTab !== index) return null;
  return <div role="tabpanel">{children}</div>;
}

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

export default Tabs;

Consumers can now build tabs with full control over layout and content:

App.jsx — Consumer Usage
import Tabs from './Tabs';

function App() {
  return (
    <Tabs defaultTab={0}>
      <Tabs.List>
        <Tabs.Tab index={0}>Overview</Tabs.Tab>
        <Tabs.Tab index={1}>Details</Tabs.Tab>
        <Tabs.Tab index={2}>Settings</Tabs.Tab>
      </Tabs.List>

      <Tabs.Panel index={0}><p>Overview content here</p></Tabs.Panel>
      <Tabs.Panel index={1}><p>Details content here</p></Tabs.Panel>
      <Tabs.Panel index={2}><p>Settings content here</p></Tabs.Panel>
    </Tabs>
  );
}
Context Coupling Warning

Compound components using Context are tightly coupled — a Tabs.Tab only works inside a Tabs parent. Always add a guard: if (!context) throw new Error('Tab must be used inside Tabs') to give consumers a clear error instead of a cryptic undefined crash.

The Render Props Pattern

Render props let you share stateful logic while giving consumers total control over what gets rendered. They've largely been superseded by hooks, but remain useful for components that render to a portal or need to pass render context down to JSX:

Toggle.jsx — Render Props
function Toggle({ children }) {
  const [on, setOn] = React.useState(false);
  const toggle = () => setOn(prev => !prev);
  return children({ on, toggle });
}

// Usage — consumer decides what to render
<Toggle>
  {({ on, toggle }) => (
    <div>
      <button onClick={toggle}>{on ? 'Hide' : 'Show'}</button>
      {on && <div className="panel">Hidden content revealed!</div>}
    </div>
  )}
</Toggle>

Extracting Logic with Custom Hooks

Custom hooks are the most modern and idiomatic way to make logic reusable in React. They let you extract stateful logic from components entirely, so the same behavior can power completely different UIs.

Building a useToggle Hook

useToggle.js
import { useState, useCallback } from 'react';

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => setValue(v => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return { value, toggle, setTrue, setFalse };
}

export default useToggle;

A Practical useFetch Hook

Instead of duplicating loading/error/data logic across every component that fetches data, extract it into a hook:

useFetch.js
import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!url) return;
    let cancelled = false;

    setLoading(true);
    setError(null);

    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP error ${res.status}`);
        return res.json();
      })
      .then(json => { if (!cancelled) setData(json); })
      .catch(err => { if (!cancelled) setError(err.message); })
      .finally(() => { if (!cancelled) setLoading(false); });

    return () => { cancelled = true; };
  }, [url]);

  return { data, loading, error };
}

export default useFetch;

// Usage in any component
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;
  return <div>{user.name}</div>;
}

The cancelled flag is critical — it prevents state updates on unmounted components, avoiding memory leaks and React warnings when the URL changes before the previous fetch completes.

Choosing the Right Pattern

Each pattern has a specific strength. Choosing the wrong one creates unnecessary complexity. Here's a reference table:

Pattern Best For Drawback Example Use Case
Props + children Simple, presentational components Not scalable for complex state sharing Button, Badge, Card
Compound Components Related components sharing implicit state Sub-components coupled to parent Tabs, Accordion, Select
Render Props Sharing render control with logic Callback hell with nesting Mouse tracker, Portal wrappers
Custom Hooks Sharing stateful logic without UI opinion Can't directly share JSX useFetch, useForm, useDebounce
HOC Cross-cutting concerns (auth, logging) Prop collision, hard to debug withAuth, withLogger

Building a Complete, Reusable Form Input

Let's put everything together in a practical example — a form input component that covers the real-world requirements of a design system: labels, error states, helper text, icons, and full accessibility.

1

Define the Props Interface

Map out every variant before writing code. For an input: label, value, onChange, error, helperText, leadingIcon, trailingIcon, disabled, required.

2

Write the Core Component

Implement the happy path first — label + input + error message. Add variants and extras only once the base works correctly.

3

Add Accessibility

Wire up aria-invalid, aria-describedby for helper/error text, and ensure the label's htmlFor matches the input's id.

4

Forward the Ref

Wrap in React.forwardRef so consumers can imperatively focus or measure the input when needed.

TextInput.jsx — Production-Ready
import React, { useId } from 'react';

const TextInput = React.forwardRef(function TextInput(
  {
    label,
    error,
    helperText,
    leadingIcon,
    trailingIcon,
    className = '',
    required = false,
    disabled = false,
    ...rest
  },
  ref
) {
  const id = useId();
  const descId = useId();
  const hasDesc = !!(error || helperText);

  return (
    <div className={`field ${disabled ? 'field--disabled' : ''}`}>
      {label && (
        <label htmlFor={id} className="field__label">
          {label}
          {required && <span aria-hidden="true"> *</span>}
        </label>
      )}

      <div className={`field__input-wrapper ${error ? 'field__input-wrapper--error' : ''}`}>
        {leadingIcon && (
          <span className="field__icon field__icon--leading" aria-hidden="true">
            {leadingIcon}
          </span>
        )}
        <input
          ref={ref}
          id={id}
          disabled={disabled}
          required={required}
          aria-invalid={!!error}
          aria-describedby={hasDesc ? descId : undefined}
          className={`field__input ${leadingIcon ? 'field__input--leading' : ''} ${trailingIcon ? 'field__input--trailing' : ''} ${className}`}
          {...rest}
        />
        {trailingIcon && (
          <span className="field__icon field__icon--trailing" aria-hidden="true">
            {trailingIcon}
          </span>
        )}
      </div>

      {hasDesc && (
        <p id={descId} className={`field__hint ${error ? 'field__hint--error' : ''}`}>
          {error || helperText}
        </p>
      )}
    </div>
  );
});

export default TextInput;

Key design decisions in this component: useId() generates stable, unique IDs without manual management; aria-describedby only sets when there's actually text to describe; the spread ...rest ensures every native input attribute works; and forwardRef maintains ref transparency.

Testing Reusable Components

A component isn't truly reusable until it's tested. Good tests verify behavior from the consumer's perspective, not implementation details.

TextInput.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TextInput from './TextInput';

describe('TextInput', () => {
  it('renders label and associates it with input', () => {
    render(<TextInput label="Email" />);
    expect(screen.getByLabelText('Email')).toBeInTheDocument();
  });

  it('shows error message and marks input invalid', () => {
    render(<TextInput label="Email" error="Required" />);
    const input = screen.getByLabelText('Email');
    expect(input).toHaveAttribute('aria-invalid', 'true');
    expect(screen.getByText('Required')).toBeInTheDocument();
  });

  it('calls onChange with new value on typing', async () => {
    const user = userEvent.setup();
    const handleChange = jest.fn();
    render(<TextInput label="Name" onChange={handleChange} />);
    await user.type(screen.getByLabelText('Name'), 'Mayur');
    expect(handleChange).toHaveBeenCalled();
  });

  it('disables the input when disabled prop is set', () => {
    render(<TextInput label="Name" disabled />);
    expect(screen.getByLabelText('Name')).toBeDisabled();
  });
});

Documentation and the Developer Experience

A reusable component that's difficult to discover or understand won't be reused. Documentation is part of the component itself, not an afterthought.

Best practices for documenting reusable components:

Button.types.ts — TypeScript Interface
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Visual style of the button */
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';

  /** Size of the button */
  size?: 'sm' | 'md' | 'lg';

  /** Shows a loading spinner and disables interaction */
  loading?: boolean;

  /** Icon rendered before the button label */
  leadingIcon?: React.ReactNode;

  /** Icon rendered after the button label */
  trailingIcon?: React.ReactNode;
}

Key Takeaways

  • Design the API before writing the implementation — the props interface is the component's public contract
  • Use children over label props for maximum flexibility
  • Always spread ...rest onto the underlying DOM element
  • Use React.forwardRef on any component that wraps a form element
  • Compound components with Context eliminate prop drilling without sacrificing flexibility
  • Custom hooks are the cleanest way to make stateful logic reusable
  • Accessibility is not optional — bake in ARIA attributes from day one
  • Test from the consumer's perspective using React Testing Library

Conclusion

Building reusable React components is a skill that compounds over time. Each well-designed component you add to your library saves hours of future work and reduces the surface area for bugs. The patterns covered here — flexible props APIs, compound components, render props, and custom hooks — aren't academic; they're the tools that production design systems like Radix UI, Headless UI, and React Aria are built on.

Start small: take one component you've duplicated in your codebase and apply these principles to consolidate it. Review the props you've exposed, check whether forwardRef is needed, and write at least three tests. That one component, done right, will teach you more than any tutorial.

As your component library grows, invest in tooling: TypeScript for prop safety, Storybook for discovery, and Chromatic or Percy for visual regression testing. The upfront investment pays dividends every time a teammate reaches for your component and everything just works.

React Components Reusability
Mayur Dabhi

Mayur Dabhi

Full Stack Developer passionate about building clean, scalable web applications. Writing about React, Laravel, and modern web development.