← back to section

When you write your first React code, a component seems like just a chunk of HTML with some logic inside. Over time you realize that what matters most is not how a component is written, but where its boundaries are drawn. A component that is too large does too much and quickly turns into an unreadable 500-line file. One that is too small fragments logic to the point where it becomes hard to follow. Let's figure out how to find the balance.

What a component is

In React, a component is a function that accepts data and returns markup. The entire application is a tree of such functions nested inside each other.

function Greeting({ name }: { name: string }) {
  return <p>Hello, {name}!</p>;
}

A component does one thing: it receives data and renders a piece of the interface. As soon as it starts doing several unrelated things at once, that's a signal to split it.

Where to draw the component boundary

In the past, components were split into two kinds: "smart" (container) and "dumb" (presentational). The smart one loads data and manages state; the dumb one just renders whatever it receives.

Today this split is implemented through hooks: logic is extracted into a separate hook function, and the component is responsible only for rendering.

function useProductList() {
  const { data, isLoading } = useProducts();
  return { products: data ?? [], isLoading };
}

export function ProductList() {
  const { products, isLoading } = useProductList();
  if (isLoading) return <Spinner />;
  return (
    <ul>
      {products.map((p) => (
        <ProductCard key={p.id} {...p} />
      ))}
    </ul>
  );
}

ProductList is responsible for the view. useProductList — for fetching data. Logic doesn't mix with markup.

A sign that it's time to split a component: it both loads data and computes something from it, renders complex markup, and manages a form — all in one file.

Composition over inheritance

In React, components do not inherit from each other. Flexibility is achieved not through class hierarchies, but by passing components as data.

The most common technique is the children prop:

type CardProps = {
  title: string;
  actions?: React.ReactNode;
  children: React.ReactNode;
};

export function Card({ title, actions, children }: CardProps) {
  return (
    <section className="card">
      <header>
        <h3>{title}</h3>
        {actions}
      </header>
      <div className="card-body">{children}</div>
    </section>
  );
}

Usage:

<Card title="Order #42" actions={<button>Cancel</button>}>
  <OrderDetails order={order} />
</Card>

Card doesn't know what will end up inside it. It defines the frame — a title, an actions area, a body — while the actual content is passed in from outside. This is exactly how component libraries are built: one skeleton, endless variations of content.

Render props and slots

When you need more flexibility than just children, you use multiple "slots":

type LayoutProps = {
  sidebar: React.ReactNode;
  content: React.ReactNode;
};

function Layout({ sidebar, content }: LayoutProps) {
  return (
    <div className="layout">
      <aside>{sidebar}</aside>
      <main>{content}</main>
    </div>
  );
}

This way different parts of the interface are substituted independently, without forcing you to create dozens of variants of the same component.

Props and state: who owns what

This distinction is the foundation of React:

  • Props — data that a component receives from outside. It does not change them.
  • State — data that the component manages itself.
type ToggleProps = {
  on: boolean;
  onChange: (on: boolean) => void;
};

export function Toggle({ on, onChange }: ToggleProps) {
  return (
    <button onClick={() => onChange(!on)}>
      {on ? "On" : "Off"}
    </button>
  );
}

Here Toggle holds no state of its own — it receives the value through props and reports changes through a callback. This kind of component is called controlled: it is managed by the parent.

When to put state directly inside a component:

function Dropdown({ options }: { options: string[] }) {
  const [open, setOpen] = React.useState(false);

  return (
    <div>
      <button onClick={() => setOpen(!open)}>Select</button>
      {open && (
        <ul>
          {options.map((o) => <li key={o}>{o}</li>)}
        </ul>
      )}
    </div>
  );
}

open is local state of Dropdown. Nothing above needs it; the parent doesn't need to know whether the dropdown is open. If state is needed by several components, it gets lifted to their nearest common parent.

Reuse without over-engineering

The urge to immediately write a universal component "for all cases" is a trap. A component with a dozen props for different modes becomes harder to read than two simple, separate components.

A sensible approach: first write a concrete solution right where you need it. Once the same component is needed in a second or third place and its shape has settled — move it to a shared shared/ui folder.

shared/
  ui/
    Button/
    Card/
    Modal/
feature/
  orders/
    OrderCard/   ← lives here until it's needed elsewhere

Premature abstraction is just as harmful as code duplication.

In short

  • A component is a function that accepts data and returns markup. One responsibility.
  • Logic is extracted into hooks; the component handles rendering.
  • React uses composition: children and prop slots instead of class inheritance.
  • Props — data from the parent; the component does not change them. State — data the component owns itself.
  • If state is needed by multiple components, lift it to their nearest common parent.
  • A shared component is extracted once duplication has appeared and its shape is clear — not before.
  • State and data management — where and how to lift state, and when you need a global store.
  • TypeScript in React — typing props, generics, discriminated unions.
  • Project structure — how to organize components into folders as the application grows.