Blog

Part 45 · The Slots Pattern in React

A slot is a prop that holds JSX, so one component can have many holes for the parent to fill. Build a Card, a page layout and a notice with named slots.

In Part 3 we met children. A component with children has one “hole”, and the parent fills it with any JSX. In Part 19 we passed a second element in a prop called title. Part 19 said a prop like that is sometimes called a slot, and promised this part.

So this part is about components with many holes. We’ll build a card, a page layout and a notice that the reader can close. We’ll see what to do when a slot is empty. And we’ll see why slot content doesn’t render again when the card’s own state changes. We’ll compare slots with the compound components of Part 38. And we’ll meet three mistakes that are easy to make.

This part closes the stage on patterns, after Part 43 and Part 44.

Try this first

Read this code. Don’t press Run yet.

import type { ReactNode } from 'react'

type CardProps = {
  header: ReactNode
  footer: ReactNode
  children: ReactNode
}

function Card({ header, footer, children }: CardProps) {
  return (
    <section>
      <div>{header}</div>
      <div>{children}</div>
      <div>{footer}</div>
    </section>
  )
}

export default function App() {
  return (
    <Card footer={<button>Save</button>} header={<h2>Your profile</h2>}>
      <p>Name: Ana</p>
    </Card>
  )
}

App writes the footer prop first and the header prop second. Make a guess. Which one shows at the top of the card: the “Save” button or the “Your profile” heading?

Now press Run.

The heading is at the top, and the button is at the bottom. The order of the props in App doesn’t matter. Card decides where each one goes. App only decides what goes in each place.

One hole is not always enough

Part 3’s Card took a title as text, and everything else as children. That works while a card has one main area. But real cards often have three: a top line, the main text, and a row of buttons at the bottom.

With only children, the parent has to build those three areas itself:

<Card>
  <div className="card-top">
    <h2>Your profile</h2>
  </div>
  <p>Name: Ana</p>
  <div className="card-buttons">
    <button>Save</button>
  </div>
</Card>

Now every card in the app repeats the same two <div> tags. If one page forgets the card-buttons div, its buttons look different. And Card can’t help, because it gets everything as one piece.

The fix is to give Card more than one hole, each with its own name.

A slot is a prop that holds JSX

Look at “Try this first” again. header={<h2>Your profile</h2>} is a normal prop. Its value is an element, the object JSX makes. Part 2 showed what an element is.

Say a prop holds JSX, and the component puts it in one place in its own JSX. That prop is called a slot. header and footer are named slots. children is a slot too. It is the one you fill by writing between the tags.

So there is no new React feature here. A slot is a common way of using props.

  • The component decides where each slot goes, and what is drawn around it.
  • The parent decides what goes in each slot.

Where the name comes from

The word comes from Web Components. Web Components are a way to make your own HTML tags, like <my-paragraph>, with no library. They have a real <slot> tag. MDN is the web’s main guide to HTML. It says a <slot> is a place inside a Web Component. You can fill it “with your own markup when the component is used”. Markup means the tags that make up a page.

Here is MDN’s example. Inside the component’s own HTML, a slot has a name and some text to show when nothing fills it:

<p><slot name="my-text">My default text</slot></p>

Then the page that uses the component fills the slot. The slot attribute names the hole to fill:

<my-paragraph>
  <span slot="my-text">Let's have some different text!</span>
</my-paragraph>

React doesn’t need any of this. In React, the name of the prop does the job of the slot attribute. But it is the same idea: named holes, filled by the page that uses the component. And there’s a default when nothing fills them. We’ll get to defaults soon.

A card with three slots

Here is a card with a header slot, a body (children) and an actions slot for buttons. It draws a border around itself and a line between the parts:

import type { ReactNode } from 'react'

type CardProps = {
  header: ReactNode
  actions: ReactNode
  children: ReactNode
}

function Card({ header, actions, children }: CardProps) {
  return (
    <section style={{ border: '1px solid #999', borderRadius: 8, margin: 8, maxWidth: 320 }}>
      <div style={{ padding: 8, borderBottom: '1px solid #999' }}>{header}</div>
      <div style={{ padding: 8 }}>{children}</div>
      <div style={{ padding: 8, borderTop: '1px solid #999', textAlign: 'right' }}>{actions}</div>
    </section>
  )
}

export default function App() {
  return (
    <div>
      <Card header={<h2>Ana</h2>} actions={<button>Follow</button>}>
        <p>Ana draws birds.</p>
      </Card>
      <Card
        header={<h2>Ben</h2>}
        actions={
          <>
            <button>Follow</button>
            <button>Send a message</button>
          </>
        }
      >
        <p>Ben builds small robots.</p>
        <p>He lives in Pune.</p>
      </Card>
    </div>
  )
}

Run it. Both cards have the same frame: a border, a line under the name and a line above the buttons. What goes inside is different.

Ben’s actions slot holds two buttons. A prop holds one value, so they are wrapped in a Fragment, <>..., as in Part 1. Ben’s body has two paragraphs. That needs no Fragment, because everything between the tags becomes children.

Here is how one card gets filled:

1. App renders first. Its JSX makes three elements. 2. React calls Card, with the elements as its props. 3. Card returns its frame. Each prop lands in its own hole. 4. React commits. The page shows the card. elements App made what Card returns {header} {children} {actions} <h2>Ana</h2> <p>Ana draws birds.</p> <button>Follow</button>

App makes the elements. Card decides where they go. Press play, or step through it.

The same steps in words:

  1. App renders first. Its JSX makes three elements: <h2>Ana</h2>, <p>Ana draws birds.</p> and <button>Follow</button>.
  2. React calls Card, and passes the elements in as props: header, children and actions.
  3. Card returns its frame. It has a hole for each prop: {header}, {children} and {actions}. Each element lands in its own hole.
  4. React commits. The page shows the card, with each part where Card put it.

An everyday example

Think of a lunch box with three parts: a big one for the main food, and two small ones. The box maker decides the shape and where each part is. The person who packs the lunch decides what goes in each part.

The box is Card. The parts are the slots. The person packing it is the parent.

The exact version

A lunch box holds the food itself. A slot holds an element: a description of what to show, made by the parent. Nothing is drawn until React renders the card. And the parent can change what it puts in a slot every time it renders.

Empty slots

So far every slot was required. Often a slot is optional. A card may have no buttons, or no header. There are two good ways to handle an empty slot.

  1. Show nothing, not even the box around it.
  2. Show a default, like MDN’s “My default text”.
import type { ReactNode } from 'react'

type CardProps = {
  header?: ReactNode
  actions?: ReactNode
  children: ReactNode
}

function Card({ header, actions, children }: CardProps) {
  return (
    <section>
      <div className="header">{header ?? <h2>No title</h2>}</div>
      <div className="body">{children}</div>
      {actions && <div className="actions">{actions}</div>}
    </section>
  )
}

export default function App() {
  return (
    <div>
      <Card header={<h2>Ana</h2>} actions={<button>Follow</button>}>
        <p>All three slots.</p>
      </Card>
      <Card>
        <p>No header, no actions.</p>
      </Card>
      <Card header={false}>
        <p>The header is false.</p>
      </Card>
    </div>
  )
}

The ? in header?: ReactNode makes the prop optional, as Part 3 showed. Run it, and look at each card.

  • The first card fills every slot.
  • The second card fills none. Its header shows “No title”, the default. It has no actions box at all.
  • The third card passes header={false}. Its header box is there, but empty.

Show nothing: &&

{actions && <div className="actions">{actions}</div>} draws the box only when actions has something in it. Part 7 covered &&. We read the HTML of the second card: it has no actions div. Without the &&, it would have an empty box, with its padding and its line still on the page.

&& has two traps. actions={0} shows a 0 on the page, the 0 bug from Part 7. And actions={[]}, an empty array, still draws the empty box, because JavaScript counts an empty array as true. We tried both. Testing actions != null instead didn’t help with either one. So when a slot has nothing to show, the parent should leave it out, or pass null.

Show a default: ??

header ?? <h2>No title</h2> uses JavaScript’s ?? operator. It means: use header, but if header is null or undefined, use the default instead. A prop you leave out is undefined, so the second card gets “No title”.

false is not null or undefined. So ?? keeps it, and false shows nothing, as in Part 1. That is why the third card has an empty header. This is often what you want. A parent can write header={isAdmin && <h2>Admin</h2>}, and get no header when isAdmin is false.

The || operator is different. It uses the default for false too, and for '' and 0. We changed ?? to || and ran it again. The third card then showed “No title”. Choose the one you mean. Practice 2 below lets you try it.

Building a page layout with slots

Slots are not only for small cards. A whole page has slots too: a top bar, a side menu and the main area. Here is a PageLayout that holds them. Each part also gets the right HTML tag, which matters for screen readers, as the next section explains.

import type { ReactNode } from 'react'

type PageLayoutProps = {
  top: ReactNode
  side: ReactNode
  children: ReactNode
}

function PageLayout({ top, side, children }: PageLayoutProps) {
  return (
    <div>
      <header style={{ padding: 8, background: '#1e293b', color: 'white' }}>{top}</header>
      <div style={{ display: 'flex', gap: 16, padding: 8 }}>
        <aside style={{ width: 120 }}>{side}</aside>
        <main>{children}</main>
      </div>
    </div>
  )
}

function Card({ header, children }: { header: ReactNode; children: ReactNode }) {
  return (
    <section style={{ border: '1px solid #999', borderRadius: 8, padding: 8 }}>
      {header}
      {children}
    </section>
  )
}

export default function App() {
  return (
    <PageLayout
      top={<h1>Bird Club</h1>}
      side={
        <nav aria-label="Pages">
          <a href="#home">Home</a>
          <br />
          <a href="#members">Members</a>
        </nav>
      }
    >
      <h2>New members</h2>
      <Card header={<h3>Ana</h3>}>
        <p>Ana draws birds.</p>
      </Card>
    </PageLayout>
  )
}

Run it. The top bar is dark, the menu is on the left, and the main area is on the right. Inside the main area, a Card sits in the children slot of PageLayout. Slots can hold components that have slots of their own.

PageLayout is used once on the page. Card is used many times. That difference matters for the next section.

Slots and accessibility

Accessibility means everyone can use the page. That includes people who use a screen reader, a program that reads the page out loud. Part 46 starts a stage on it. Two things matter for slots.

Let the parent choose the heading

MDN says most screen readers can make a list of all the headings on a page. People use it to jump from heading to heading. So MDN’s page on headings says: “Do not skip heading levels: always start from <h1>, followed by <h2> and so on.”

Look at the layout above. The page title is an <h1>. The main area starts with an <h2>. The card’s name is an <h3>. The order has no gaps.

Notice who chose each level. PageLayout and Card didn’t. The parent passed the whole heading into the slot. Only the parent knows where the card sits on the page. Somewhere else, the same card might sit right under an <h1>, and then its name should be an <h2>. If Card wrote <h3>{title}</h3> itself, it would be wrong in one of the two places.

So make heading slots hold JSX, not text. Then the parent picks the level.

Use page-part tags in the layout, carefully

HTML has five tags for the big parts of a page: <header>, <nav>, <main>, <aside> and <footer>. MDN gives each one a role: banner, navigation, main, complementary and contentinfo. These are landmark roles. MDN’s page on the navigation role says screen readers use landmarks for this. They let people jump to the important parts of a page with the keyboard.

PageLayout is a good place for them, because there is one layout per page. In our layout, the menu is a <nav>, so it is a landmark already. The <aside> around it is a choice, not a must. MDN says an <aside> holds content “only indirectly related to the document’s main content”.

Be careful in a component that is used many times, like Card.

  • <main>: MDN says a page must not have more than one <main>, unless the others are hidden. So Card must never use <main>. Ten cards would make ten.
  • <header>: inside <main>, <article>, <section>, <nav> or <aside>, MDN says “it loses its landmark status”. There it only marks the top part of that section. So a <header> inside a card’s <section> is fine.

Slot content belongs to the parent

A slot holds an element that the parent made. That one fact explains three things: which state it sees, which context it sees, and when it renders.

It sees the parent’s state

import { createContext, useContext, useState, type ReactNode } from 'react'

const ThemeContext = createContext('plain')

function ThemeName({ part }: { part: string }) {
  const theme = useContext(ThemeContext)
  return <p>The {part} sees the {theme} theme.</p>
}

function Card({ header, children }: { header: ReactNode; children: ReactNode }) {
  return (
    <section>
      <div>{header}</div>
      <ThemeContext value="light">
        <div>{children}</div>
      </ThemeContext>
    </section>
  )
}

export default function App() {
  const [name, setName] = useState('Ana')
  return (
    <ThemeContext value="dark">
      <button onClick={() => setName('Ben')}>Change the name</button>
      <Card
        header={
          <>
            <h2>Hello, {name}!</h2>
            <ThemeName part="header" />
          </>
        }
      >
        <ThemeName part="body" />
      </Card>
    </ThemeContext>
  )
}

Press Run, then “Change the name”. The heading says “Hello, Ben!”.

name is state in App. Card doesn’t know about it. App wrote {name} into the heading when it made the element, so the heading shows App‘s value. When App‘s state changes, App renders again and makes a new heading element with the new name.

It sees context from where the card puts it

Now look at the two theme lines. Part 23 showed context: a value a component reads from the nearest provider above it.

  • The header says “dark”. Card put {header} in a plain <div>, so the nearest provider above it is App‘s.
  • The body says “light”. Card put {children} inside its own <ThemeContext value="light">. That provider is nearer.

So the parent makes the element, but context is read from where the element ends up in the React tree. Part 42 found the same with portals. React’s useContext page says it “always looks for the closest provider above the component that calls it”. Here, that component is ThemeName. Above it, in the tree, is wherever Card placed the slot.

Most of the time, Card adds no providers, and slot content sees the same context as the parent. When a component does add one, it can be on purpose. A dark card could set the theme for everything inside it.

It doesn’t render again when the card’s own state changes

Part 19 showed this for children, and it works the same for every slot. Here the card has its own state, a “Likes” counter:

import { useState, type ReactNode } from 'react'

function Title() {
  console.log('Title renders')
  return <h2>Ana</h2>
}

function Card({ header, children }: { header: ReactNode; children: ReactNode }) {
  const [likes, setLikes] = useState(0)
  console.log('Card renders')
  return (
    <section>
      <div>{header}</div>
      <div>{children}</div>
      <button onClick={() => setLikes(likes + 1)}>Likes: {likes}</button>
    </section>
  )
}

export default function App() {
  return (
    <Card header={<Title />}>
      <p>Ana draws birds.</p>
    </Card>
  )
}
Card renders
Card renders
Title renders
Title renders
Card renders
Card renders
Card renders
Card renders

Press Run, then click “Likes” twice. Each line comes twice. That is Strict Mode, which renders each component twice while you develop, as Part 2 showed.

The first 4 lines are the first render. Then each click adds “Card renders” twice, and “Title renders” never comes back.

A click changes state in Card, so React renders Card again. But App doesn’t render, so it makes no new elements. The header prop is the very same <Title /> element as before, and React skips it. We counted with Strict Mode off too: two clicks rendered Card 2 times and Title 0 times.

The rule from Part 19 still holds. Slot content does render again when the parent that made it renders, because then the parent makes a new element. It also renders for its own state, or a context it reads.

When a slot needs the card’s data

A slot’s content is made by the parent. So its props can only hold what the parent has. What if it needs something that only the card has?

Here is a notice that the reader can close. The notice keeps its own open state. Its buttons go in an actions slot. But a “Got it” button must close the notice, and only the notice can change open.

There are three ways to get close to the button.

Way 1: a render prop

This is the render prop from Part 40. The slot holds a function, not an element. The notice calls it, and passes in what the button needs:

import { useState, type ReactNode } from 'react'

type NoticeProps = {
  title: ReactNode
  children: ReactNode
  actions: (close: () => void) => ReactNode
}

function Notice({ title, children, actions }: NoticeProps) {
  const [open, setOpen] = useState(true)
  if (!open) {
    return <button onClick={() => setOpen(true)}>Show the notice</button>
  }
  const close = () => setOpen(false)
  return (
    <section style={{ border: '1px solid #999', padding: 8 }}>
      {title}
      {children}
      {actions(close)}
    </section>
  )
}

export default function App() {
  return (
    <Notice
      title={<h2>New hours</h2>}
      actions={close => <button onClick={close}>Got it</button>}
    >
      <p>The library now opens at 9.</p>
    </Notice>
  )
}

Run it. Click “Got it”, and the notice closes. Click “Show the notice”, and it comes back.

title and children are plain slots. actions is a slot that holds a function. App decides what the button looks like. Notice gives it the close function.

A render prop runs every time Notice renders, so it makes new elements each time. Part 40 showed what that costs, in Render props and memo.

Way 2: a provider around the slot

You saw above that slot content reads context from where the card puts it. So Notice can put {actions} inside a provider. A small CloseButton reads close from it:

import { createContext, useContext, useState, type ReactNode } from 'react'

const CloseContext = createContext<(() => void) | null>(null)

function CloseButton({ children }: { children: ReactNode }) {
  const close = useContext(CloseContext)
  if (close === null) {
    throw new Error('CloseButton must be used inside a Notice')
  }
  return <button onClick={close}>{children}</button>
}

type NoticeProps = { title: ReactNode; children: ReactNode; actions: ReactNode }

function Notice({ title, children, actions }: NoticeProps) {
  const [open, setOpen] = useState(true)
  if (!open) {
    return <button onClick={() => setOpen(true)}>Show the notice</button>
  }
  return (
    <section style={{ border: '1px solid #999', padding: 8 }}>
      {title}
      {children}
      <CloseContext value={() => setOpen(false)}>{actions}</CloseContext>
    </section>
  )
}

export default function App() {
  return (
    <Notice title={<h2>New hours</h2>} actions={<CloseButton>Got it</CloseButton>}>
      <p>The library now opens at 9.</p>
    </Notice>
  )
}

Run it. It works like Way 1, but actions is a plain slot again. The parent writes <CloseButton>, and Notice gives it close through context. The throw is the same guard as Part 38’s part used outside its parent.

Way 3: lift the state up

If App needs to know whether the notice is open, move the open state up into App, as in Part 9. Then App has setOpen itself, and actions can be a plain slot.

There is a fourth tool, cloneElement, which copies an element and adds props to it. Part 43 showed it. It works on one element at a time, but a slot can hold text, a Fragment or a list. So the component must first check what it got, as Part 43 did. That makes it the least common choice here.

Typing slots in TypeScript

A plain slot is usually ReactNode, the type for “anything React can show”. React’s TypeScript page calls it “a union of all the possible types that can be passed as children in JSX”. A union is a type that allows any one of several types. Here that means an element, text, a number, null and more.

Sometimes you want a slot to take an element only, not text. Then use ReactElement. And a slot that holds a render prop gets a function type:

import type { ReactElement, ReactNode } from 'react'

type CardProps = {
  header?: ReactNode
  icon?: ReactElement
  actions?: (close: () => void) => ReactNode
  children: ReactNode
}

const ok: CardProps = { header: 'Ana', icon: <b>A</b>, children: 'Ana draws birds.' }

Here header may be plain text, like 'Ana'. icon must be an element, like <b>A</b>. We checked: icon: 'A' is a type error.

TypeScript can’t go further than that. React’s page says “you cannot use TypeScript to describe that the children are a certain type of JSX elements”. So no type can say “the header slot takes only an <h2>“. The same is true for any slot.

Finding slots by type, and why props are better

There is another way to make named slots. The parent writes special child tags, like <CardHeader>. Then Card looks through children for them, and checks each element’s type: child.type === CardHeader.

Part 44 showed how that breaks. Say the parent puts its header in its own small component, BenHeader. Then Card sees an element whose type is BenHeader, not CardHeader. It doesn’t find a header, and no error says why.

Named props don’t have this problem. header={<BenHeader />} works, because Card never looks inside the element. It only puts it in the right place.

Slots or compound components?

Part 38 built an accordion from parts that work together, like <Accordion.Item> and <Accordion.Trigger>. A card could be built that way too. Here are both, side by side.

With slots:

<Card
  header={<h2>Ana</h2>}
  actions={<button>Follow</button>}
>
  <p>Ana draws birds.</p>
</Card>

With compound components:

<Card>
  <Card.Header>
    <h2>Ana</h2>
  </Card.Header>
  <Card.Body>
    <p>Ana draws birds.</p>
  </Card.Body>
  <Card.Actions>
    <button>Follow</button>
  </Card.Actions>
</Card>

Both work. They differ in who is in charge.

Slots Compound components
Who decides the order of the parts the component the parent
Can the parent add its own tags between parts no yes
Can the parent repeat a part no, one value per slot yes
When parts share state a render prop, or a provider around a slot context
TypeScript checks each slot’s name and type, and missing required slots only each part’s own props
Pieces to learn one component several

Slots fit when the shape is fixed and each part appears once: a card, a dialog box, a page layout. TypeScript can tell the parent that a required slot is missing. And the component stays simple, with no context at all.

Compound components fit when parts share state and the parent needs freedom. That means any number of items, any order, extra tags in between. Tabs, menus and accordions are like that. Part 38 also showed their costs: two contexts, and a check that only runs while the app runs.

Common mistakes

Passing a component instead of an element

import type { ReactNode } from 'react'

function Header() {
  return <h2>Your profile</h2>
}

function Card({ header, children }: { header: ReactNode; children: ReactNode }) {
  return (
    <section>
      <div>{header}</div>
      <div>{children}</div>
    </section>
  )
}

export default function App() {
  return (
    <Card header={Header}>
      <p>Name: Ana</p>
    </Card>
  )
}

header={Header} passes the function Header, not an element. TypeScript stops you: Type '() => Element' is not assignable to type 'ReactNode'.

The playground doesn’t check types, so press Run. The header is empty, and React prints this error in the Console:

Functions are not valid as a React child. This may happen if you return Header instead of <Header /> from render. Or maybe you meant to call this function rather than return it.
  <div>{Header}</div>

The second line shows where the function ended up: inside Card‘s <div>.

The fix: header={<Header />}. Writing it as a tag makes an element.

Too many slots

import type { ReactNode } from 'react'

type CardProps = {
  headerLeft?: ReactNode
  headerRight?: ReactNode
  subtitle?: ReactNode
  badge?: ReactNode
  image?: ReactNode
  imageCaption?: ReactNode
  body?: ReactNode
  actionsLeft?: ReactNode
  actionsRight?: ReactNode
  footerNote?: ReactNode
}

Each new need got a new slot. Now nobody remembers which ten slots exist, or how they fit together. This is the same problem Part 38 found with a long list of props.

Group slots instead. headerLeft, headerRight, subtitle and badge can be one header slot. The parent fills it with whatever row of things it needs. If parts must still be free to move around, it is time for compound components.

Slot content that starts over when the layout changes

This card can switch between a narrow and a wide layout. Each slot holds a small counter:

import { useState, type ReactNode } from 'react'

function Counter({ label }: { label: string }) {
  const [count, setCount] = useState(0)
  return (
    <button onClick={() => setCount(count + 1)}>
      {label} stars: {count}
    </button>
  )
}

function Card({ header, children }: { header: ReactNode; children: ReactNode }) {
  const [wide, setWide] = useState(false)
  return (
    <section>
      <button onClick={() => setWide(!wide)}>Change the layout</button>
      {wide ? (
        <div style={{ display: 'flex', gap: 16 }}>
          <aside>{header}</aside>
          <div>{children}</div>
        </div>
      ) : (
        <div>
          <div>{header}</div>
          <div>{children}</div>
        </div>
      )}
    </section>
  )
}

export default function App() {
  return (
    <Card header={<Counter label="Header" />}>
      <Counter label="Body" />
    </Card>
  )
}

Run it. Click each counter twice, so both show 2. Then click “Change the layout”.

The header counter goes back to 0. The body counter keeps its 2.

It is the same element in the same slot. But Part 18 showed that React keeps state only for the same type at the same place in the tree. In the wide layout, the header sits in an <aside>. In the narrow one, it sits in a <div>. A different tag at that place means React throws away everything inside it, the counter too. The body sits in a <div> in both layouts, so React keeps it.

We checked it with Strict Mode on and off. Both gave 0 for the header and 2 for the body. We also looked at the page itself. After the switch, the header’s button was a new button on the page: React had removed the old one. The body’s button was the same one as before.

The fix: keep the same tags in both layouts, and change only the style.

<div style={{ display: wide ? 'flex' : 'block', gap: 16 }}>
  <div>{header}</div>
  <div>{children}</div>
</div>

Now the header is always in a <div> at the same place, and nothing starts over. Practice 4 lets you try it.

Practice

Press Edit on the examples above and try these. Strict Mode is on, so count the doubles.

  1. In “Try this first”, move the line <div>{footer}</div> up, so it is the first line inside <section>. Run it. What shows first now?
  2. In “Empty slots”, change ?? to ||. What does the third card show? Then change it back, and change the third card to header={null}. What does it show now?
  3. In the “Likes” example, delete {header} from Card, and write <Title /> in its place. Run it, then click “Likes” twice. How many lines does the Console show in all?
  4. In “Slot content that starts over”, change Card to use the fix: one <div> with display: wide ? 'flex' : 'block', holding two <div> tags. Click each counter twice, then “Change the layout”. What do the counters show?
Answers
  1. The “Save” button now shows first, above “Your profile”. App didn’t change. Only Card decides the order.
  2. With ||, the third card shows “No title”, because || uses the default for false too. With ?? and header={null}, it also shows “No title”, because ?? uses the default for null and undefined.
  3. 12 lines. The first render gives “Card renders” twice and “Title renders” twice. Then each click gives “Card renders” twice and “Title renders” twice. Card now makes a new <Title /> element every time it renders. The header prop isn’t used anymore, so the <Title /> from App is never shown.
  4. Both counters keep their 2. The header is in a <div> at the same place in both layouts, so React keeps it. Only the style changes.

Interview questions

Try to answer each one out loud before you open the answer.

What is the slots pattern in React?

A component takes JSX in several named props, like header, actions and children. It puts each one in its own place. The component decides where each part goes and what is drawn around it. The parent decides what goes in each part. React has no special feature for it. A slot is a prop whose value is an element.

A strong answer names where the word comes from: the <slot> tag of Web Components. It also has named slots and default content.

How is a named slot different from children?

children is one slot, filled by writing between the tags. Named slots are more holes, each filled through its own prop. With only children, the component gets everything as one piece. So the parent must build the layout itself. With named slots, the component builds the layout, and each part arrives separately.

A Card has its own state. Does the content of its slots render again when that state changes?

No, unless something else changes. The parent made the slot elements. When only the card’s state changes, the parent doesn’t render. So the slot props are the same element objects as before. React skips them. In our test, two clicks rendered the card 2 times and the slot content 0 times, with Strict Mode off.

A strong answer gives the limits. Slot content still renders again when the parent that made it renders. It also renders for its own state, or for a context it reads. And a render prop is different: it runs on every render of the card.

Which context does slot content see: the parent’s or the component’s?

The context where the component places it. Slot content reads the nearest provider above it in the tree. If the card puts a slot inside its own provider, the slot content sees that value. If not, it sees the providers above the card, which are usually the parent’s. State is different. The parent’s state values are written into the element when the parent makes it.

How do you pass data from a component into its slot?

Not through the element’s props, because the parent made the element before the component ran. There are three common ways. A render prop: the slot holds a function, and the component calls it with the data, like actions={close => <button onClick={close}>Got it</button>}. A provider around the slot: the component wraps {actions} in a context provider, and the slot content reads it. Or lift the state up into the parent, so the parent has the data itself.

A strong answer also names cloneElement, which adds props to a copy of one element. It is the least common. A slot may hold text or a list, so the component must first check that it got one element.

When would you choose slots over compound components?

When the layout is fixed and each part appears once, like a card, a dialog box or a page layout. Slots are simple: no context, and TypeScript checks each slot’s name and type. Compound components fit when the parent needs to choose the order, repeat parts or add tags in between. Tabs and accordions are like that. Their costs are context and a check that only runs while the app runs.

A strong answer warns about finding slots by type with Children. It breaks as soon as the parent wraps a part in its own component.

What type should a slot prop have in TypeScript?

Usually ReactNode, which allows anything React can show. Use ReactElement to allow only elements, not text. A render prop slot gets a function type, like (close: () => void) => ReactNode. TypeScript can’t limit a slot to one kind of tag, like only <h2>.

A strong answer mentions the common bug: header={Header} passes a function. TypeScript says a function is not a ReactNode. React shows nothing there and warns that functions are not valid as a React child.

Sources

  • Passing Props to a Component, react.dev: children as a “hole” that the parent fills with JSX.
  • <slot> and Using templates and slots, MDN: what a <slot> is, named slots, default content, and the <my-paragraph> example.
  • useContext, react.dev: context comes from “the closest provider above the component that calls it.”
  • Preserving and Resetting State, react.dev: state is kept for the same component at the same position.
  • Using TypeScript, react.dev: ReactNode, ReactElement, and that TypeScript can’t limit children to one kind of element.
  • <nav>, <footer> and the navigation role, MDN: each tag’s role, and that screen readers use landmark roles to jump to the important parts of a page.
  • Heading elements, <main>, <header> and <aside>, MDN: heading order and what screen readers do with headings, one <main>, <header> inside a section, and what an <aside> holds.
  • StrictMode, react.dev: the extra render in development.
  • The render counts, the logs, the HTML, the React error and the TypeScript errors above come from running React 19.3.0 and TypeScript 7.0.2 for this post, with Strict Mode on and off.
  • This part follows the Slots Pattern kata in react-katas.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.