ReactComponent DesignTypeScriptFrontend Architecture

React Component Design: Composition, Abstraction, and Reuse

Design React components with clear APIs, composition, controlled inputs, local state boundaries, custom Hooks, and fewer boolean props.

·Updated ·11 min read·Counting...
React Component Design: Composition, Abstraction, and Reuse

The goal of React component abstraction is not to reduce the number of files. It is to give a stable UI concept a clear API. Allow a little duplication first, then abstract when the common structure and variation points are real.

Function components are the default

New React code usually uses function components and Hooks:

type ButtonProps = {
  children: React.ReactNode
  variant?: "primary" | "secondary"
  onClick?: () => void
}

export function Button({ children, variant = "primary", onClick }: ButtonProps) {
  return (
    <button className={`button button-${variant}`} onClick={onClick}>
      {children}
    </button>
  )
}

Class components still exist in older projects and some Error Boundary patterns, but function components fit the current React API and ecosystem better.

When to abstract

Consider abstraction when at least two of these are true:

  • The same recognizable UI concept appears in multiple places.
  • Structure is stable, while content, state, or small styles vary.
  • Interaction rules need one shared fix or test.
  • The component has a clear domain name, not CommonWrapper2.
  • Callers do not need to know internal DOM details.

Two copies of three lines of JSX do not always need a component. Repeated accessibility behavior, loading states, validation, or keyboard interactions should be centralized earlier.

Prefer composition over many boolean props

This API will become hard to reason about:

<Card compact bordered showHeader showFooter imageLeft loading />

Composition gives callers clearer regions:

function Card({ children }: { children: React.ReactNode }) {
  return <article className="card">{children}</article>
}

Card.Header = function CardHeader({ children }: { children: React.ReactNode }) {
  return <header className="card-header">{children}</header>
}

Card.Body = function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="card-body">{children}</div>
}

You can also use ordinary children, named props, or smaller components. Do not use a compound component pattern just to look advanced.

Where state should live

Place state at the closest common owner:

  • Used by one component: local useState.
  • Shared by siblings: lift to the nearest common parent.
  • Shared deeply: Context.
  • Complex transitions: useReducer.
  • Reusable stateful logic: custom Hook.

Do not move every piece of state into a global store. The farther state is from where it is used, the harder updates are to understand.

For Hook choices, read React Hooks Guide: State, Effects, Context, and Reducers.

Controlled and uncontrolled components

A reusable input should be clear about who owns the value. A controlled component receives value and change handler:

type SearchInputProps = {
  value: string
  onChange: (value: string) => void
}

function SearchInput({ value, onChange }: SearchInputProps) {
  return <input value={value} onChange={(event) => onChange(event.target.value)} />
}

Uncontrolled components keep value in the DOM or internally, which can be fine for simple forms or native API integration. Avoid switching a component between controlled and uncontrolled modes.

Common abstraction mistakes

  • Creating many one-line "shared components" for one page.
  • Adding a dozen boolean props to one component.
  • Mixing data fetching, permissions, visual styling, and modal state in one file.
  • Introducing global Context to avoid passing props two levels.
  • Using useEffect to synchronize values that could be derived from one source.
  • Reusing visuals while ignoring semantic HTML, focus, and keyboard behavior.

Server and Client Component boundary

In the Next.js App Router, display components can often stay on the server. Only the smallest interactive region needs "use client" for state, events, Effects, or browser APIs.

Do not turn a whole page into a Client Component because one button is interactive. The boundary is explained in Next.js Server vs Client Components.

Pre-abstraction checklist

  1. Is the concept truly repeated?
  2. Which parts are stable and which parts vary?
  3. Do prop names express domain meaning?
  4. Is the call site clearer than raw JSX?
  5. Does it preserve semantic HTML and accessibility?
  6. Would deleting the abstraction make the code simpler?

Good component APIs are small and composable. Bad abstractions collect special switches.

Subscribe to FreeMac

Weekly picks: free Mac software reviews, trusted source updates, alternatives, and low-friction guides.