Skip to content
C.W.K.
Stream
Lesson 05 of 05 · published

Slots, Props, and the Thing You Didn't Anticipate

~12 min · ui, extension-points, components, design

Level 0Loose Parts
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Two Ways to Let an App Differ

The shared UI chrome — the panel that docks an assistant surface into an app — has to accommodate more than a dozen hosts with different visual identities, different explanations of their own controls, and different platforms. It does that with exactly two kinds of extension point, and the distinction is worth holding.

  • A prop is a value you pass in: the embed URL, a storage key prefix, the header wording, an emblem. The shared component knows the shape of what it will receive and where it goes.
  • A slot is a region the app fills with whatever it renders. The shared component knows only where it goes, and nothing about what it is.

Props are cheaper to reason about and should be the default. Slots are for the cases where anticipating the shape is the mistake.

The Case That Proved the Slot

Every host puts a sentence in the panel's unavailable state: the assistant is offline, try again. One host is different — a desktop app that ships a local model, so when the remote assistant is unreachable it can still hold a conversation. It wanted an entire interactive chat surface where the others put one line.

The shared component needed no change at all. The slot already accepted arbitrary content; the app supplied its offline surface and un-centered the slot's default layout in its own stylesheet. A prop would have had to anticipate this — an offlineMessage string could not hold a chat interface, and by the time you have generalized it enough to, you have invented a slot with extra steps.

When an extension point absorbs a use nobody designed for, that is evidence it is the right kind. The test of a seam is not whether it handles the cases you enumerated — you built it from those, so of course it does. It is whether it handles one you did not. A seam that requires a change for every new use is a list of blessed variations wearing the costume of an abstraction.

When One Seam Is Not Enough

A second slot shape had to be added later, and the reason is instructive because it is about the DOM rather than about design. Apps explain their controls two different ways. Some pass attributes onto a control — a title, an aria description, extra handlers. One renders a tooltip component that produces a sibling node beside the control, which cannot be expressed as attributes on that control no matter how many attributes you allow.

So there are two seams: merge attributes onto a control, or wrap a control in the app's own node. Both optional, neither disturbing the consumers that use neither. The lesson is not that you should ship two — it is that the second one was added when a real case proved that the first could not express it, rather than pre-emptively because two sounded more flexible.

Code

Props, slots, and two ways to let an app dress a control·typescript
type SidekickProps = {
  // PROPS: values. The component knows the shape and the destination.
  embedUrl: string;
  storagePrefix: string;
  title?: string;
  mark?: string;              // an app emblem for the header
  iframeAllow?: string;       // one app needs microphone; see track 7

  // SLOTS: regions. The component knows only WHERE, never WHAT.
  beforeFrame?: React.ReactNode;
  unavailable?: React.ReactNode;

  // SEAMS for dressing a control the component owns. Two shapes,
  // because apps explain themselves two different ways:
  controlProps?: (id: string) => Record<string, unknown>;   // attributes
  controlWrap?: (id: string, node: React.ReactNode) => React.ReactNode;
};

function Control({ id, children, controlProps, controlWrap }: ControlArgs) {
  const node = <button {...(controlProps?.(id) ?? {})}>{children}</button>;
  // A tooltip renders a SIBLING node beside the button, which no
  // number of attributes on the button could ever express. That is
  // why the second seam exists - and it was added when a real case
  // proved the first could not do it, not pre-emptively.
  return controlWrap ? controlWrap(id, node) : node;
}

// Almost every app passes a sentence to `unavailable`:
//
//   unavailable={<p>Assistant is offline. Try again shortly.</p>}
//
// One passes an entire local-model chat surface:
//
//   unavailable={<OfflineChat model="local" persona={persona} />}
//
// The shared component did not change for that. The app un-centered
// the slot in its own stylesheet:
//
//   .app .sidekick-unavailable { display: block; text-align: left; }

External links

Exercise

Take a shared component you own and list its extension points, marking each as a value or a region. Then find the most recent feature request that required editing the component itself. Ask whether an existing extension point could have absorbed it — and if not, whether the right fix was a new prop or a slot.
Hint
The tell for 'should have been a slot' is a prop whose type has been widened over time: a string that became a string-or-node, then gained an optional render function. Each widening was an attempt to keep a value doing a region's job.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.