RizTech Academy logo
RizTech Academy
React FundamentalsLesson 7 of 725 min

Composition over configuration

A component that does one thing well gets reused. A component with fourteen boolean props gets copied and edited. This lesson is about which one you end up writing.

The prop explosion

It starts reasonably:

<ProductCard product={product} />

Then the search page needs a compact version. Then the admin needs an edit button. Then the offers page wants a badge:

type Props = {
  product: Product;
  compact?: boolean;
  showBadge?: boolean;
  badgeText?: string;
  showEditButton?: boolean;
  onEdit?: (id: string) => void;
  hideImage?: boolean;
  showStockCount?: boolean;
  variant?: "grid" | "list" | "admin";
};

Inside, conditionals everywhere. Nobody can tell which combinations are supported, half of them produce nonsense, and every new requirement adds another prop.

The signal to watch for: a boolean prop that only controls whether something renders. That is a job for children.

Slots

Let the caller supply the content:

type Props = {
  product: Product;
  actions?: React.ReactNode;
  badge?: React.ReactNode;
};

export function ProductCard({ product, actions, badge }: Props) {
  return (
    <article className="rounded-xl border p-4">
      {badge}
      <h2>{product.name}</h2>
      <p>₹{(product.pricePaise / 100).toFixed(2)}</p>
      {actions}
    </article>
  );
}
<ProductCard product={product} actions={<AddToCartButton product={product} />} />

<ProductCard
  product={product}
  badge={<Badge>20% off</Badge>}
  actions={<EditButton onClick={() => edit(product.id)} />}
/>

Three props instead of nine, and the card never learns what an edit button is. New requirements need no changes to it at all.

Props that are React.ReactNode are slots. They are the main tool for keeping a component from growing conditionals.

children for the main slot

export function Card({ children }: { children: React.ReactNode }) {
  return <div className="rounded-xl border bg-white p-4 shadow-sm">{children}</div>;
}

children is the default slot. Named props are for additional ones.

This is also the cure for prop drilling from the props lesson:

// Drilling: Layout must know about user to pass it on
<Layout user={user} />

// Composition: Layout knows nothing
<Layout>
  <Header>
    <UserMenu user={user} />
  </Header>
</Layout>

The data no longer travels through components that do not use it. Restructuring like this solves most drilling without Context.

Splitting by responsibility

A component doing three things is three components:

export function ProductPage({ product }: { product: Product }) {
  return (
    <div className="grid gap-8 lg:grid-cols-2">
      <ProductGallery images={product.images} />
      <div>
        <ProductSummary product={product} />
        <AddToCartForm product={product} />
      </div>
      <ProductReviews productId={product.id} />
    </div>
  );
}

ProductPage is now layout. Each piece can be understood, changed and tested on its own.

A useful rule: if a component's name needs "and", split it. Another: if you are scrolling to find things inside one component, it is too big.

Do not split pre-emptively. Extract when a piece is reused, or when the file has become hard to read — not because a file reached a line count.

Presentational and container components

A division worth keeping:

// Knows about data. Knows nothing about appearance.
export async function FeaturedProducts() {
  const products = await getFeaturedProducts();
  return <ProductGrid products={products} />;
}

// Knows about appearance. Knows nothing about where data came from.
export function ProductGrid({ products }: { products: Product[] }) {
  return <div className="grid gap-4">{products.map(...)}</div>;
}

ProductGrid can be used with data from an API, from a search, or from a literal array in a test. It cannot be broken by a change to how fetching works.

This split matters more in Next.js than in plain React, because the data-fetching component can be a server component while the interactive one is a client component. Module 3 builds on exactly this.

Custom hooks

Shared logic, rather than shared markup, goes in a hook:

"use client";

import { useEffect, useState } from "react";

export function useDebounced<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}
const [query, setQuery] = useState("");
const debouncedQuery = useDebounced(query);

A hook is a function starting with use that calls other hooks. That naming is not decoration — React's linting relies on it to check the rules of hooks.

The cleanup matters: without clearTimeout, every keystroke leaves a pending timer and the search fires for every intermediate value.

Extract a hook when the same stateful logic appears in two places. Not before — a hook used once is usually harder to follow than the code inlined.

The rules of hooks

Two, and both are absolute:

Only call hooks at the top level. Never inside a condition, loop or nested function.

if (isLoggedIn) {
  const [name, setName] = useState("");     // breaks everything
}

React identifies hooks by call order. A hook that sometimes runs shifts every hook after it, so state gets attached to the wrong one.

Only call hooks from components or other hooks. Not from ordinary functions.

The ESLint plugin catches both. Do not disable it.

When not to abstract

Two similar components are not necessarily one component.

<ProductCard product={p} variant="grid" />
<ProductCard product={p} variant="list" />

If those branches share almost nothing, two components are clearer than one with a switch inside. Duplication is cheaper than the wrong abstraction — a premature merge produces a component that fits neither case and resists every change.

Wait until you have three similar things before deciding what they share. The third one tells you what the abstraction actually is.

Check your work

The signal that a component needs slots: a boolean prop whose only job is to decide whether something renders.

What a slot is: a prop typed React.ReactNode, so the caller supplies the content and the component never learns what it is.

How composition removes prop drilling: passing rendered content as children means the data never travels through components that do not use it.

When to split a component: when its name needs "and", or when you scroll to find things inside it.

Why separate presentational from data-fetching components: the presentational one can be rendered with a literal array, in a test, or with data from anywhere.

What a custom hook is: a function starting with use that calls other hooks. The naming is what React's linting relies on.

Why hooks cannot be called conditionally: React identifies them by call order, so a hook that sometimes runs shifts every later one.

Why duplication can beat abstraction: a premature merge produces a component that fits neither case. Wait for the third example.

Practice

  1. Add four boolean props to ProductCard for different pages. Feel it get worse.
  2. Replace them with actions and badge slots. Render three variants with no changes to the card.
  3. Build a Card using children and put different content inside.
  4. Create three levels of prop drilling, then remove it with children.
  5. Split a large product page into gallery, summary, form and reviews.
  6. Separate a data-fetching component from a presentational one, and render the presentational one with a literal array.
  7. Write useDebounced and use it in a search box. Remove the cleanup and watch the search fire on every keystroke.
  8. Put a useState inside an if. Read the error and explain why the rule exists.
  9. Find two similar components you were about to merge. Write down what they actually share — and decide honestly whether it is enough.

That is module two. You can build interfaces from components, manage state, handle forms, render lists correctly, and keep components reusable.

Next module: Next.js — routing, and the server/client split this course is built around.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship