Skip to content
elephantoo

Lifting state & composition patterns

Lesson 16 of 30 16 min read

Single source of truth, controlled components, children and slots, compound components and render props.


React data flows one way: from parents down to children through props. That raises two everyday questions: where should a piece of state live? and how do I structure components so they're flexible and reusable? This lesson answers both with two core skills — lifting state up and composition.

Lifting state up#

Imagine an FAQ where only one panel may be open at a time. If each Panel owns its own isOpen state, they can't coordinate:

JSX
function Panel({ title, children }) {
  const [isOpen, setIsOpen] = useState(false); // each panel is independent – can't close the others
  return (
    <section>
      <h3>{title}</h3>
      {isOpen ? <p>{children}</p> : <button onClick={() => setIsOpen(true)}>Show</button>}
    </section>
  );
}

The fix: move the state up to their common parent and pass it down:

JSX
import { useState } from "react";

function Panel({ title, isActive, onShow, children }) {
  return (
    <section>
      <h3>{title}</h3>
      {isActive ? <p>{children}</p> : <button onClick={onShow}>Show</button>}
    </section>
  );
}

export default function Faq() {
  const [activeIndex, setActiveIndex] = useState(0);

  return (
    <>
      <h2>FAQ</h2>
      <Panel title="What is Elephantoo?" isActive={activeIndex === 0} onShow={() => setActiveIndex(0)}>
        A friendly place to learn programming.
      </Panel>
      <Panel title="Is it free?" isActive={activeIndex === 1} onShow={() => setActiveIndex(1)}>
        Yes, every course is free.
      </Panel>
    </>
  );
}

The recipe:

  1. Remove the state from the children.
  2. Pass the values down as props (isActive).
  3. Pass event handlers down so children can ask the parent to change it (onShow).

Panel is now a controlled component — its parent decides what it shows. It's also simpler and easier to test.

A single source of truth#

For each piece of state, pick one component that owns it. Find it like this:

  1. List every component that reads or changes the state.
  2. Find their closest common parent.
  3. Put the state there (or higher, if it makes more sense).

A classic example: a temperature converter where two inputs edit the same value.

JSX
import { useState } from "react";

const toF = (c) => (c * 9) / 5 + 32;
const toC = (f) => ((f - 32) * 5) / 9;
const round = (n) => Math.round(n * 10) / 10;

function TemperatureInput({ label, value, onChange }) {
  return (
    <label>
      {label}{" "}
      <input type="number" value={value} onChange={(e) => onChange(e.target.value)} />
    </label>
  );
}

export default function Converter() {
  const [celsius, setCelsius] = useState(20); // the ONE source of truth

  return (
    <fieldset>
      <TemperatureInput label="°C" value={round(celsius)} onChange={(v) => setCelsius(Number(v))} />
      <TemperatureInput label="°F" value={round(toF(celsius))} onChange={(v) => setCelsius(toC(Number(v)))} />
      <p>{celsius >= 100 ? "Water boils 🔥" : "Water does not boil"}</p>
    </fieldset>
  );
}

Fahrenheit isn't stored at all — it's derived during render. Storing both would let them drift out of sync.

Tip: if a value can be calculated from props or other state, don't put it in state. Derive it.

Composition with children#

Components can accept JSX as props. The most common is children — whatever you nest inside the tags:

JSX
function Card({ title, children }) {
  return (
    <article className="card">
      <h3>{title}</h3>
      <div className="card-body">{children}</div>
    </article>
  );
}

function Profile() {
  return (
    <Card title="Aditi">
      <img src="/aditi.png" alt="" width={64} />
      <p>Learning React on Elephantoo.</p>
    </Card>
  );
}

Card doesn't know or care what's inside it. This makes it reusable for profiles, lessons, settings — anything.

Multiple "slots"#

Any prop can hold JSX, so you can create several slots:

JSX
function Layout({ header, sidebar, children }) {
  return (
    <div className="layout">
      <header>{header}</header>
      <aside>{sidebar}</aside>
      <main>{children}</main>
    </div>
  );
}

function App() {
  return (
    <Layout header={<Logo />} sidebar={<CourseNav />}>
      <Lesson />
    </Layout>
  );
}

Composition beats prop drilling#

Prop drilling is passing props through components that don't use them, just to reach a deep child:

JSX
function App() {
  const [user, setUser] = useState({ name: "Aditi" });
  return <Page user={user} />; // Page doesn't use user…
}
function Page({ user }) {
  return <Header user={user} />; // …neither does Header…
}
function Header({ user }) {
  return <Avatar user={user} />; // …only Avatar does.
}

Before reaching for context, try composition — let the parent build the deep element and pass it as JSX:

JSX
function App() {
  const [user] = useState({ name: "Aditi" });
  return <Page header={<Header avatar={<Avatar user={user} />} />} />;
}
function Page({ header }) {
  return <div>{header}</div>;
}
function Header({ avatar }) {
  return <nav>Elephantoo {avatar}</nav>;
}
function Avatar({ user }) {
  return <span>{user.name}</span>;
}

Now Page and Header know nothing about user. When many distant components need the same data (theme, current user, language), that's when context earns its place.

Specialisation#

Build specific components by configuring general ones:

JSX
function Dialog({ title, message, children }) {
  return (
    <div role="dialog" aria-labelledby="dialog-title" className="dialog">
      <h2 id="dialog-title">{title}</h2>
      <p>{message}</p>
      {children}
    </div>
  );
}

function ConfirmDeleteDialog({ onConfirm, onCancel }) {
  return (
    <Dialog title="Delete lesson?" message="This cannot be undone.">
      <button onClick={onCancel}>Cancel</button>
      <button onClick={onConfirm}>Delete</button>
    </Dialog>
  );
}

React doesn't use inheritance between components — composition covers every case.

Compound components#

Some components are made of parts that work together, like <select> and <option>. Compound components share state between a parent and its parts through context, so callers can arrange the parts freely:

JSX
import { createContext, useContext, useState } from "react";

const TabsContext = createContext(null);

function Tabs({ defaultValue, children }) {
  const [active, setActive] = useState(defaultValue);
  return <TabsContext value={{ active, setActive }}>{children}</TabsContext>;
}

function TabList({ children }) {
  return <div role="tablist">{children}</div>;
}

function Tab({ value, children }) {
  const { active, setActive } = useContext(TabsContext);
  return (
    <button role="tab" aria-selected={active === value} onClick={() => setActive(value)}>
      {children}
    </button>
  );
}

function TabPanel({ value, children }) {
  const { active } = useContext(TabsContext);
  return active === value ? <div role="tabpanel">{children}</div> : null;
}

export default function CoursePage() {
  return (
    <Tabs defaultValue="lessons">
      <TabList>
        <Tab value="lessons">Lessons</Tab>
        <Tab value="reviews">Reviews</Tab>
      </TabList>
      <TabPanel value="lessons">30 lessons, from JSX to Server Components.</TabPanel>
      <TabPanel value="reviews">⭐⭐⭐⭐⭐ “So clear!”</TabPanel>
    </Tabs>
  );
}

The caller never touches the active state, yet every part stays in sync. Component libraries such as Radix UI use this pattern.

Render props (passing a function)#

Sometimes a parent needs to give the child data to render with. Pass a function:

JSX
function MouseTracker({ render }) {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  return (
    <div style={{ height: 200 }} onPointerMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
      {render(pos)}
    </div>
  );
}

function App() {
  return <MouseTracker render={({ x, y }) => <p>Pointer at {x}, {y}</p>} />;
}

Today, custom hooks (useMousePosition()) usually replace render props, but you'll still see the pattern in libraries like form and table components.

Keeping or resetting state#

React keeps state as long as the component stays at the same position in the tree. Two useful consequences:

JSX
// Same position, same type → state is preserved when `isFancy` changes
{isFancy ? <Counter fancy /> : <Counter />}

// A different key → a brand-new component with fresh state
<ChatRoom key={contactId} contactId={contactId} />

Giving a component a key that changes is the cleanest way to reset its state — for example, clearing a draft message when switching contacts.

Never define components inside components#

JSX
function Parent() {
  // ❌ A new `Child` type is created on every render, so its state resets each time
  function Child() {
    const [text, setText] = useState("");
    return <input value={text} onChange={(e) => setText(e.target.value)} />;
  }
  return <Child />;
}

Always declare components at the top level of a module.

Common mistakes#

  • Duplicating the same state in two components and trying to keep them in sync with effects.
  • Storing derived values (totals, filtered lists, the "other" temperature) in state.
  • Drilling props through five layers instead of using composition or context.
  • Defining components inside other components.
  • Mirroring a prop into state (useState(props.value)) and expecting it to update when the prop changes.

What's next#

Your components are well structured — now let's make them look good. Next up: styling React apps.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Two sibling components need to show and change the same value. Where should the state live?

  2. 2.What is the children prop?

  3. 3.Which is usually the best first fix for prop drilling through one or two layers?

Finished reading?

Mark this lesson complete to track your progress.