Frontend

Building Reusable React Components

Mayur Dabhi
Mayur Dabhi
April 10, 2026
14 min read

You build a Button for the checkout page. A month later someone needs it with an icon, so they add an icon prop. Then a loading prop, then iconPosition, then a copy of the whole file called ButtonV2 because the original got too scary to touch. Most React codebases have a component like this somewhere, and it usually didn't fail because of bad code. It failed because nobody thought about its API up front.

This post covers the patterns I reach for to avoid that: a props API built on defaults and ...rest, composition with compound components, custom hooks for shared logic, a complete accessible input, how to test it, and how to document it so people actually use it.

The Reusability Rule of Three

Don't abstract too early. If you've copied a component three times, that's usually the point to generalize it. Abstract after the first use and you end up guessing at requirements, which is how you get a component with 25 props and no clear job. Build once, duplicate once, then abstract on the third use.

What Makes a Component Reusable?

Avoiding duplicate code is the easy part. The hard part is drawing the right boundaries. A component that gets reused has one clear job, takes its configuration through props, and doesn't assume anything about where it sits in the tree (no reaching into a specific store, no hardcoded page margins).

What I look for in a component that's meant to be shared:

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

Once other people use your component, its props are a public contract, and changing them means touching every call site. You want an API that's easy to use correctly and hard to misuse. Native HTML elements are a good model here: <button> works with zero attributes and still accepts dozens of optional ones.

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>
  );
}

The ...rest spread does a lot of work here. Any prop the component doesn't name goes straight onto the <button>, so consumers can pass aria-label, data-testid, id, or any other native attribute without you adding support for each one.

Two gotchas with it. First, order matters: {...rest} comes last, so anything in it overrides the attributes above. That's why className is destructured and merged by hand; leave it in rest and a consumer's class would silently wipe out your btn classes. Second, only spread props that are valid DOM attributes. If a custom prop like isActive leaks into rest, React logs Warning: React does not recognize the `isActive` prop on a DOM element. Destructure your custom props explicitly so they never reach the spread.

Prefer children Over Label Props

A label or text prop only takes a string, in practice. Use children instead and consumers can pass plain text, an icon plus text, or a spinner, all without changing the component.

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

When a component starts collecting props like showHeader, headerIcon and renderFooter, that's a sign it wants to be several components. Instead of one component with 30 props for every case, build small pieces that work together. HTML already does this with <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

A Tabs.Tab only works inside a Tabs parent. Render one outside and the code above crashes with TypeError: Cannot destructure property 'activeTab' of ... as it is null, which tells the consumer nothing. Add a guard in each sub-component: if (!context) throw new Error('Tab must be used inside Tabs').

The Render Props Pattern

With render props, the component owns the state and the consumer decides what to render with it. Hooks have replaced most uses, but the pattern still shows up in components that render to a portal or need to hand render context down into 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

Sometimes two components look nothing alike but share the same behavior: a dropdown and a modal both need open/close state, a profile card and a settings page both fetch data. Pull that logic into a custom hook and each component keeps its own markup.

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>;
}

Don't skip the cancelled flag. Without it, if userId changes quickly, a slow response for the old user can land after the new one and overwrite it. That's a race condition you'll see on flaky mobile connections long before you see it on localhost. For real apps I'd still look at TanStack Query or SWR before growing this hook, since caching and retries get complicated fast.

Choosing the Right Pattern

Picking the wrong pattern adds complexity you don't need. Here's how they compare:

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

Here's a form input that pulls these ideas together. It handles what a design system input usually needs: a label, error state, helper text, icons, and proper accessibility wiring.

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.

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
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;

A few decisions worth pointing out. useId() gives stable IDs that match between server and client render, so you don't get hydration mismatches like you would with Math.random(). aria-describedby is only set when there's text to point at. The ...rest spread means every native input attribute works. And forwardRef lets form libraries like React Hook Form attach their ref to the real input. (In React 19, ref is a regular prop for function components, so you can drop forwardRef there.)

Testing Reusable Components

Shared components get used in places you never thought of, so tests matter more here than in page-level code. Test what the consumer sees and does, not internal state.

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

If nobody can find your component or figure out its props, they'll write their own. That's how you end up with three date pickers in one repo. Here's what helps:

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;
}

Mistakes I see most often

  • Boolean prop pile-up: isPrimary, isDanger, isGhost on the same button. Two of them can be true at once and nobody knows what wins. Use one variant prop instead.
  • Forgetting ...rest: the first time someone needs a data-testid or aria-label, they fork the component.
  • No ref forwarding on inputs: focus management and form libraries quietly break.
  • Abstracting after one use: you guess the API, guess wrong, and every later use fights it.

When to build reusable components (and when not to)

It's worth the effort for UI primitives that show up everywhere (buttons, inputs, modals, tabs) and for logic that keeps getting copied, like fetching or form state. For those, a clean API pays off every time someone reaches for the component instead of writing their own. Libraries like Radix UI, Headless UI and React Aria are built on these same patterns, so if you need complex widgets such as comboboxes or date pickers, I'd start from one of them rather than writing the accessibility logic yourself.

I wouldn't bother for one-off page sections, marketing layouts, or anything still changing every week. Making those "generic" mostly adds props nobody uses and makes the code harder to read. A bit of duplication is cheaper than the wrong abstraction, and you can always extract later once the third copy shows up.

If you want a place to start, pick one component that's been copied around your codebase, consolidate it with a props API like the one above, and write a few tests for it. TypeScript, Storybook and visual regression tools like Chromatic or Percy are worth adding once more than a couple of people use the library, but not before.

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.