Skip to content

Advanced React Behavior and Tests

Step 25 of 45

Reusable Components and Context

Introduction

This is lesson 1 of chapter 6, aligned with course 6 of the Meta Front-End Developer pathway ("Advanced React", domain advanced-react). In chapter 5 you built components that receive props and keep local state. As an interface grows, two problems appear: the same markup is copied in many places, and some data (the signed-in member, the language, the colour theme) is needed by components buried deep in the tree.

This lesson teaches how to design components that are reusable (configurable through props, children, and sensible defaults) and how to share data without passing it through every level with context. The scenario is original: Harbor Workshops needs a shared card, a modal-style dialog, a theme switch, and a member session that many components read. By the end you will be able to decide when props are enough, when context is the better tool, and how to avoid the performance and design traps of both.

Learning objectives

  1. Design a reusable component with a small, predictable props interface.
  2. Use children and named "slot" props to let callers supply content.
  3. Apply default values, prop spreading, and prop validation responsibly.
  4. Recognise prop drilling and decide whether context is justified.
  5. Create a context, provide a value, and consume it with useContext.
  6. Wrap a context in a provider component and a custom hook with a clear error.
  7. Explain why every consumer re-renders when the provided value changes, and limit the damage.
  8. Choose between lifting state, composition, and context for a given problem.

1. What makes a component reusable

A component is reusable when callers can adapt it without editing its source. In React the adaptation channels are props, children, and (in this lesson) context. A good reusable component has these properties:

  • A narrow purpose. WorkshopCard shows a workshop; it does not also fetch data and manage the cart.
  • Data in, events out. It receives values as props and reports user actions through callback props such as onReserve.
  • No hidden dependencies. It does not read a global variable or call an API on its own unless that is its stated job.
  • Sensible defaults. Optional props have defaults so the simplest usage is short.

A first reusable card

function Card({ title, footer, children, variant = 'plain' }) {
  return (
    <section className={`card card--${variant}`}>
      <h3 className="card__title">{title}</h3>
      <div className="card__body">{children}</div>
      {footer && <div className="card__footer">{footer}</div>}
    </section>
  );
}

The component does not know what it contains. Callers decide:

<Card title="Sourdough basics" variant="highlight" footer={<button>Reserve</button>}>
  <p>Saturday, 10:00. Four seats left.</p>
</Card>

<Card title="Studio rules">
  <ul>
    <li>Wear closed shoes.</li>
    <li>Arrive ten minutes early.</li>
  </ul>
</Card>

Notice three techniques in the definition:

  1. children is the content placed between the opening and closing tags. It can be text, elements, arrays, or nothing.
  2. A slot prop (footer) accepts JSX through a named prop. Use slots when a component has several distinct areas (header, footer, actions) and children alone would be ambiguous.
  3. A default (variant = 'plain') means the caller may omit it. The default is applied only when the prop is undefined, not when it is null or 0.

Props design rules

Rule Why it matters
Prefer few props with clear names Each prop is part of the component's public contract
Pass data, not pre-rendered strings, when the component formats it The component can change formatting without touching callers
Name callbacks onSomething and handlers handleSomething Callers immediately see what is an event
Avoid boolean prop explosions (isBig isRed isRounded) Use one variant or size prop with a few named values
Do not mutate props Props are read-only; copy before changing
Keep key and ref in mind key is not a prop; in React 19, ref can be passed to a function component as an ordinary prop

Spreading props with care

A wrapper around a native element often needs to forward extra attributes.

function TextField({ label, id, ...inputProps }) {
  return (
    <div className="field">
      <label htmlFor={id}>{label}</label>
      <input id={id} {...inputProps} />
    </div>
  );
}

<TextField id="email" label="Email" type="email" required autoComplete="email" />

The rest syntax collects every prop except label and id into inputProps, then spreads them onto the <input>. This keeps the wrapper small while supporting type, required, value, onChange, and any ARIA attribute. The trade-off is that callers can override anything, so place explicit props after the spread when the wrapper must win: <input {...inputProps} id={id} />.

Worked example: refactoring duplicated markup

Statement. Harbor Workshops has two nearly identical blocks, one for workshops and one for instructors. Both show a title, an image, a paragraph, and an optional action button. The two copies already differ by a typo in a class name. Refactor them into one reusable component.

Step 1: find what varies. The title, image source and alt text, the paragraph, and whether a button appears. Everything else (layout classes, heading level) is identical.

Step 2: define the interface.

function MediaCard({ title, imageSrc, imageAlt, children, action }) {
  return (
    <article className="media-card">
      <img src={imageSrc} alt={imageAlt} loading="lazy" />
      <div>
        <h3>{title}</h3>
        {children}
        {action}
      </div>
    </article>
  );
}

Step 3: replace both copies.

<MediaCard title="Wheel pottery" imageSrc="/img/pottery.jpg" imageAlt="Hands shaping clay on a wheel"
  action={<button onClick={() => reserve('w2')}>Reserve</button>}>
  <p>Learn to centre clay and pull walls.</p>
</MediaCard>

<MediaCard title="Maya Ortiz" imageSrc="/img/maya.jpg" imageAlt="Maya Ortiz in the pottery studio">
  <p>Ceramicist with twelve years of teaching experience.</p>
</MediaCard>

Step 4: why the alternatives are weaker. A single component with a type="workshop" | "instructor" prop and if branches inside would couple the component to every future card type. Passing the button as a boolean showButton plus buttonLabel plus onButtonClick would multiply props. Passing the action as a slot keeps the card unaware of what the action does.

2. Prop drilling and when it hurts

Prop drilling means passing a prop through several components that do not use it, only to reach one that does. Suppose the signed-in member's name must appear in a header badge deep inside the layout:

<App user={user}>
  <Layout user={user}>
    <Header user={user}>
      <UserBadge user={user} />
    </Header>
  </Layout>
</App>

Layout and Header do not care about user; they only forward it. Every new intermediate component must remember to pass it along, and renaming the prop touches four files.

Before reaching for context, try the lighter solutions:

  1. Composition. Instead of passing user down, create the <UserBadge user={user} /> element high up and pass it as children or a slot. Layout then receives finished JSX and never sees user.
  2. Moving state closer. If only one subtree needs the data, keep the state there.
  3. Context. Use it when many components at different depths need the same value and composition would make the structure awkward.

3. Context in practice

Context lets a parent make a value available to any descendant without passing props. There are three steps: create, provide, consume.

Step 1: create the context

import { createContext } from 'react';

export const ThemeContext = createContext('light');

The argument is the default value, used only when a component reads the context without a matching provider above it.

Step 2: provide a value

function App() {
  const [theme, setTheme] = useState('light');
  const value = { theme, toggle: () => setTheme(t => (t === 'light' ? 'dark' : 'light')) };

  return (
    <ThemeContext value={value}>
      <Layout />
    </ThemeContext>
  );
}

In React 19 you can render the context object itself as the provider (<ThemeContext value={...}>). In earlier versions, and in many existing tutorials and codebases, you will see <ThemeContext.Provider value={...}>. Both forms mean the same thing; recognise the older one because most real projects still use it.

Step 3: consume the value

import { useContext } from 'react';

function ThemeButton() {
  const { theme, toggle } = useContext(ThemeContext);
  return <button onClick={toggle}>Theme: {theme}</button>;
}

useContext returns the value from the nearest provider above the component. If the provider's value changes, React re-renders every component that read that context. Layout and Header are untouched by the change in code and do not need props.

The provider component and the custom hook

Exposing the raw context object invites mistakes (reading it outside a provider, forgetting a field). The standard pattern wraps both sides:

const SessionContext = createContext(null);

export function SessionProvider({ children }) {
  const [member, setMember] = useState(null);

  const value = useMemo(
    () => ({
      member,
      signIn: name => setMember({ name }),
      signOut: () => setMember(null),
    }),
    [member]
  );

  return <SessionContext value={value}>{children}</SessionContext>;
}

export function useSession() {
  const ctx = useContext(SessionContext);
  if (ctx === null) {
    throw new Error('useSession must be used inside <SessionProvider>');
  }
  return ctx;
}

Usage:

root.render(
  <SessionProvider>
    <App />
  </SessionProvider>
);

function Header() {
  const { member, signOut } = useSession();
  return member ? <button onClick={signOut}>Sign out {member.name}</button> : <a href="/login">Sign in</a>;
}

Benefits: components import one hook, the context object stays private, the error message tells a developer exactly what went wrong, and you can change how the session is stored without touching consumers.

Why useMemo appears in the provider

Every time SessionProvider renders, an object literal { member, signIn, signOut } would be a new object, even if member is unchanged. React compares context values with Object.is, so a new object means "changed", and every consumer re-renders. useMemo returns the same object while member is unchanged. This matters when the provider re-renders for reasons unrelated to the value (for example, its parent re-rendered). With the React Compiler enabled, such memoisation may be applied automatically, but you should still understand the cause.

Splitting contexts

A single big context holding the member, the theme, the cart, and the language forces everything to re-render when any part changes. Prefer several small contexts, each with one concern, and nest the providers:

<SessionProvider>
  <ThemeProvider>
    <CartProvider>
      <App />
    </CartProvider>
  </ThemeProvider>
</SessionProvider>

When a value changes often (cart quantities) and another rarely (theme), separating them keeps the rarely changing consumers still.

Overriding context for a subtree

A provider can be placed deeper to give one part of the tree a different value. A dark-themed sidebar inside a light page:

<ThemeContext value={{ theme: 'dark', toggle: noop }}>
  <Sidebar />
</ThemeContext>

Components inside Sidebar read 'dark'; the rest of the page still reads the outer value.

4. Choosing the right tool

Situation Best choice Reason
Two siblings need the same value Lift state to the common parent Simple and explicit
A value travels through 1 or 2 layers Pass props Easy to trace, no indirection
A wrapper must show arbitrary content children or slot props Keeps the wrapper unaware of the content
Many components at different depths read the same, slowly changing value Context Avoids drilling
Frequently updated, complex data shared widely Context plus reducer, or an external state library Plain context re-renders all consumers
Server data (lists loaded from an API) A data-fetching approach, not context alone Needs loading, error and cache handling

Context is not a state manager. It transports a value; the state still lives in useState or useReducer inside the provider.

Common pitfalls

  • Creating the value inline without memoisation in a provider that re-renders often, causing every consumer to re-render.
  • Reading context outside a provider and silently using the default value. The null default plus a throwing hook makes this bug obvious.
  • Putting everything into one context. Split by concern and update frequency.
  • Using context to avoid designing props. If only two layers are involved, props are clearer.
  • Mutating the context value (for example session.member.name = 'x'). Always update through state setters.
  • Forgetting children. A wrapper that does not render {children} shows an empty box with no error.
  • Passing JSX in a prop and expecting it to be a component. footer={<button/>} passes an element; to pass a component type, use a capitalised variable and render <Footer />.
  • Adding too many ...rest spreads, which can leak unknown attributes onto DOM elements and trigger React warnings.

Exam tips

  • When a question describes data needed by many deep components with intermediate components that do not use it, the expected answer is context (or composition), not "make it global".
  • If a question says "all consumers re-render unexpectedly", look for a provider value created inline on every render.
  • Remember: the default value of createContext applies only when there is no provider above; a provider with value={undefined} supplies undefined, not the default.
  • children is just a prop named children. Questions may show it passed explicitly as an attribute.

Mini case study: the theme that does not switch

Situation. A developer builds a theme switch. The button toggles state, but the footer, which is rendered outside the provider, never changes colour.

function App() {
  return (
    <>
      <ThemeProvider>
        <Header />
        <Main />
      </ThemeProvider>
      <Footer />
    </>
  );
}

function Footer() {
  const { theme } = useTheme();
  return <footer className={`footer footer--${theme}`}>Harbor Workshops</footer>;
}

The footer always shows the default light theme.

Analysis. useContext reads the nearest provider above the component. Footer is a sibling of ThemeProvider, not a descendant, so no provider is above it and it receives the context's default value. If the hook throws on a missing provider, the app would crash instead; here the default 'light' object hides the bug.

Fix. Move Footer inside the provider:

<ThemeProvider>
  <Header />
  <Main />
  <Footer />
</ThemeProvider>

Follow-up improvement. Change the context default to null and make useTheme throw when it is null. The original mistake would then have produced a clear error message during development. This is why the lesson recommends a guarded custom hook.

Why other fixes are worse. Passing theme to Footer as a prop would work but reintroduces drilling for every new component. Storing the theme in a global variable would not trigger re-renders when it changes.

Key terms

  • Reusable component: a component configured through props and children rather than edited per use.
  • children: the content nested between a component's tags, received as a prop.
  • Slot prop: a named prop that accepts JSX for a specific area of a component.
  • Prop drilling: passing props through components that do not use them.
  • Context: a mechanism for making a value available to a whole subtree.
  • Provider: the element that supplies a context value to its descendants.
  • Consumer: a component that reads a context, usually via useContext.
  • Default value: the value used when no provider is above the consumer.
  • Custom hook: a function starting with use that wraps hook logic for reuse.
  • Composition: building interfaces by nesting components and passing elements as props.

Summary checklist

  • I design components with small props interfaces and clear callback names.
  • I use children and slot props instead of many boolean flags.
  • I forward extra attributes with rest and spread when wrapping native elements.
  • I recognise prop drilling and try composition or closer state first.
  • I create a context, provide a value, and consume it with useContext.
  • I wrap context in a provider component and a guarded custom hook.
  • I memoise provider values that would otherwise change on every render.
  • I split contexts by concern and update frequency.
  • I know that the context default applies only without a provider.
  • I remember that context transports state; it does not replace it.

Official sources