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
- Add four boolean props to
ProductCardfor different pages. Feel it get worse. - Replace them with
actionsandbadgeslots. Render three variants with no changes to the card. - Build a
Cardusingchildrenand put different content inside. - Create three levels of prop drilling, then remove it with
children. - Split a large product page into gallery, summary, form and reviews.
- Separate a data-fetching component from a presentational one, and render the presentational one with a literal array.
- Write
useDebouncedand use it in a search box. Remove the cleanup and watch the search fire on every keystroke. - Put a
useStateinside anif. Read the error and explain why the rule exists. - 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