Props, Composers, and Providers: the composition pattern we're converging on

Our frontend has grown fast. Along the way we accumulated props drilled several layers deep to reach one leaf, components that fetch data, hold state, and render markup at once, and rows steered by a growing pile of props.
None of this is a disaster. It is just friction, and it compounds. So we made a deliberate call: converge on composition, and treat it as a ladder. You start on the cheapest rung, and you only climb when a concrete pain pushes you up.
This article is about that ladder: how we compose React components, and when each step is worth its cost. The wider story, where the Orus frontend is heading, is for another article.
The ladder
Four rungs. Each one buys something, and each one costs something (indirection, one more concept to hold in your head). Default to the bottom rung, and climb only when a named pain shows up.
- Plain props. Reusable UI is a function of its props.
- Compound components. When a component sprouts props for every shape, expose slots instead.
- A Composer and Providers. When the same UI must render data from swappable or deferred sources.
- Lifted state behind a contract. When state has to cross a component boundary and the layout should stay pure.
The first two rungs are still just props. No context, no provider. Most of our components never need to climb past rung 2.
Rung 1: plain props
A reusable piece of UI is a plain component that takes props. That is the whole rung, and it is where most components should stay.
This echoes something Samuel wrote earlier on this blog: abstractions should earn their place by making a current requirement simpler, not by betting on a future you cannot see. The ladder is the same idea applied to composition.
Rung 2: compose the markup with compound components
A row starts simple. Then every screen needs it a little different, and the props creep in:
<Item
leading={<ProductLogo product={product} />}
title={product.name}
subtitle={product.formattedPrice}
trailing={<Badge intent="warning">Awaiting signature</Badge>}
trailingSecondary={<ChevronRight />}
as="a"
href={`/subscribe/${product.id}/quote`}
collapsed={sidebarCollapsed}
/>
Look closely. leading and trailing are already slots, only spelled as props, so the component styles and spaces content it never chose. One trailing slot was not enough, so trailingSecondary appeared. as and href exist only to swap the tag. Every new screen adds a prop, and callers still cannot build an arrangement the props did not anticipate.
The fix is to make those slots real. Item, the row primitive in our design system, exposes sub-components instead of a prop list:
const Item = {
Root: ItemRoot,
Group: ItemGroup,
Media: ItemMedia,
Content: ItemContent,
Title: ItemTitle,
Description: ItemDescription,
Actions: ItemActions,
Separator: ItemSeparator,
};
A caller assembles exactly the parts it needs:
<Item.Root render={<Link to={`/subscribe/${product.id}/quote`} />} collapsed={sidebarCollapsed}>
<Item.Media variant="image">
<ProductLogo product={product} />
</Item.Media>
<Item.Content>
<Item.Title>{product.name}</Item.Title>
<Item.Description>{product.formattedPrice}</Item.Description>
</Item.Content>
<Item.Actions>
<Badge intent="warning">Awaiting signature</Badge>
<ChevronRight />
</Item.Actions>
</Item.Root>
It is more than a bag of divs.
First, polymorphism. Item.Root takes a render prop, so the same row can be a div or a full router Link without the as and href props from before. The Item.Media slot absorbs the leading content and owns its own sizing through a variant. And collapsed stays a plain prop on Item.Root: it is genuine row state, which is Root's own responsibility. Not everything becomes a slot.
Second, coordination without props. A Root needs to know whether it sits inside a Group (to get the right list semantics), but we do not want callers wiring that by hand. The primitive shares it through a tiny internal context:
const ItemGroupContext = createContext(false);
function ItemRoot(props: ItemRoot.Props) {
const grouped = use(ItemGroupContext);
// ...build the element...
if (grouped) {
return <div role="listitem">{element}</div>;
}
return element;
}
function ItemGroup({ children, ...rest }: ItemGroup.Props) {
return (
<ItemGroupContext.Provider value={true}>
<div role="list" {...rest}>
{children}
</div>
</ItemGroupContext.Provider>
);
}
This context is an implementation detail of one component. It never leaves the file. That is a different animal from the feature-level providers on rungs 3 and 4, even though the mechanism (React context) is identical.
Compound components also compose upward. When a feature needs an opinionated list, it does not re-implement the row. It wraps the primitive into a smaller, named API:
export const NavigationList = {
Group: NavigationListGroup,
Item: NavigationListItem,
};
NavigationList.Item is just an Item.Root with the icon slot, a title, and a chevron pre-arranged. Callers get a two-line API, the primitive keeps all its flexibility, and nobody passes a flag. The same shape repeats across the rest of our UI kit, from page sections to tabs to callouts.
Rung 3: compose the data with a Composer and Providers
Rung 2 kept everything in props. Props stop being enough the moment the same UI has to render data from more than one source.
Concrete case: a customer's quotes list. It shows active quotes and archived quotes. The two look identical. They come from different queries. The archived list should load only when the user opens its tab. And in tests we want to render the same list with no network at all.
Three sources, one piece of UI. Props alone would push the source choice up into the parent and drill it back down. Instead we split the component in two along a contract.
The contract is a small context. It carries the data (state) and the presentational knobs that depend on the source (meta):
type QuotesContextValue = {
state: { quotes: readonly CustomerSubscriptionSummary[] };
meta: { emptyLabel: string; showStatusBadge: boolean };
};
export const QuotesContext = createContext<QuotesContextValue | null>(null);
export function useQuotes(): QuotesContextValue {
const context = use(QuotesContext);
if (context === null) {
throw new Error("useQuotes must be used within a QuotesProvider");
}
return context;
}
The Composer renders the list. It reads the contract and knows nothing about where the data came from. It composes its markup with the Item primitive from rung 2, so the two rungs meet in one file:
export function QuotesListComposer(): ReactElement {
const {
state: { quotes },
meta: { emptyLabel, showStatusBadge },
} = useQuotes();
if (quotes.length === 0) {
return <Text>{emptyLabel}</Text>;
}
return (
<Item.Group variant="outline">
{quotes.map((subscription) => (
<Item.Root
key={subscription.id}
render={<Link to="/subscribe/$subscriptionId/$stepId" /* ... */ />}
>
<Item.Content>
<Item.Title>{getSubscriptionName(subscription)}</Item.Title>
</Item.Content>
<Item.Actions>
{showStatusBadge ? <Badge>{/* ... */}</Badge> : null}
<ChevronRight />
</Item.Actions>
</Item.Root>
))}
</Item.Group>
);
}
The Providers fill the contract. Each one is a different source, interchangeable behind the same shape. The active one queries live data:
export function ActiveQuotesProvider({
children,
}: {
children: ReactNode;
}): ReactElement {
const quotes = useActiveQuotes();
return (
<QuotesContext
value={{
state: { quotes },
meta: { emptyLabel: "No active quotes", showStatusBadge: true },
}}
>
{children}
</QuotesContext>
);
}
The fixture one takes quotes as props, so tests need no network:
export function FixtureQuotesProvider({
quotes = [],
emptyLabel = "No active quotes",
showStatusBadge = true,
children,
}): ReactElement {
return (
<QuotesContext
value={{ state: { quotes }, meta: { emptyLabel, showStatusBadge } }}
>
{children}
</QuotesContext>
);
}
Assembly is where the payoff shows. Swapping the source is swapping the provider, and deferring the archived load is just wrapping its provider in Suspense:
<Tabs.Panel value="active">
<ActiveQuotesProvider>
<QuotesListComposer />
</ActiveQuotesProvider>
</Tabs.Panel>
<Suspense fallback={<QuotesListSkeleton />}>
<Tabs.Panel value="archived">
<ArchivedQuotesProvider>
<QuotesListComposer />
</ArchivedQuotesProvider>
</Tabs.Panel>
</Suspense>
The fixture provider is a first-class source, not test-only scaffolding, and it makes the Composer trivial to test:
renderInRouter(
<FixtureQuotesProvider quotes={[]} emptyLabel="No archived quotes">
<QuotesListComposer />
</FixtureQuotesProvider>,
);
expect(await screen.findByText("No archived quotes")).toBeInTheDocument();
That test seam is where this rung pays off. The moment your UI needs a fixture source, you are on rung 3 whether you name it or not.
Rung 4: lift state behind a contract
The last rung is for state that has to cross a component boundary.
Our notes block is a plain props component. A container fetches the notes and threads three callbacks into it:
<NotesBlock
notes={notes}
onNoteAdded={onNoteAdded}
onNoteDeleted={onNoteDeleted}
onNoteUpdated={onNoteUpdated}
/>
For a while that is fine. The catch is that NotesBlock does not use those callbacks. It forwards them to NotesListBlock, which forwards them to each NoteBlock, which forwards onNoteUpdated again to its edit form. Here is where they finally get used, four levels below the container that owns them:
// today: NoteBlock receives the drilled callbacks and uses them
function NoteBlock({ note, onNoteDeleted, onNoteUpdated }: NoteBlockProps) {
const [editing, setEditing] = useState(false);
if (editing) {
return (
<EditNoteForm
note={note}
onSave={onNoteUpdated}
onCancel={() => setEditing(false)}
/>
);
}
return (
<div>
{/* ...note header and content... */}
<Button onClick={() => setEditing(true)}>Edit</Button>
<Button onClick={() => onNoteDeleted(note)}>
Delete
</Button>
</div>
);
}
Every new action (pin a note, react to one) is another prop threaded through every layer between the container and this button. And a sibling toolbar that wants to add a note cannot reach the handler without lifting it higher still.
This is where props stop serving. The state belongs to the feature, not to any single component in the tree, so we lift it into a provider behind a { state, actions } contract.
We have not shipped this yet, so here is where the notes block is heading. The contract holds the notes and the actions over them:
// not shipped yet
type NotesContextValue = {
state: { notes: Note[] };
actions: {
addNote: (content: string) => void;
updateNote: (noteId: string, content: string) => void;
deleteNote: (note: Note) => void;
};
};
export const NotesContext = createContext<NotesContextValue | null>(null);
export function useNotes(): NotesContextValue {
const context = use(NotesContext);
if (context === null) {
throw new Error("useNotes must be used within a NotesProvider");
}
return context;
}
The quotes list carried state and meta. This one carries state and actions. You take the slices the feature needs, and the shape stays recognizable.
One component fills the contract. The provider is the only place that talks to the server. It hides the data layer and exposes plain actions:
// not shipped yet
function NotesProvider({
targetId,
children,
}: {
targetId: string;
children: ReactNode;
}) {
// however the notes get loaded and persisted stays in here
const { notes, createNote, updateNote } = useNotesApi(targetId);
const actions: NotesContextValue["actions"] = {
addNote: (content) => createNote(content),
updateNote: (noteId, content) => updateNote(noteId, content),
deleteNote: (note) => updateNote(note.noteId, null),
};
return (
<NotesContext value={{ state: { notes }, actions }}>
{children}
</NotesContext>
);
}
Now the callbacks leave every signature above the leaf. NotesBlock and NotesListBlock stop forwarding props and just render their children. The leaf keeps the same markup but now reads the contract instead of receiving callbacks, which by our naming rule makes it a Composer:
// not shipped yet: a Composer now, same markup, no callbacks in sight
function NoteBlockComposer({ note }: { note: Note }) {
const { actions } = useNotes();
const [editing, setEditing] = useState(false);
if (editing) {
return (
<EditNoteForm
note={note}
onSave={actions.updateNote}
onCancel={() => setEditing(false)}
/>
);
}
return (
<div>
{/* ...note header and content... */}
<Button onClick={() => setEditing(true)}>Edit</Button>
<Button onClick={() => actions.deleteNote(note)}>
Delete
</Button>
</div>
);
}
The two callback props are gone from NoteBlockComposer, and from every component between it and the container.
The bigger win is the boundary. Because the provider owns the state, a component that is not even inside NotesBlock can drive it. That sibling toolbar button becomes trivial:
// not shipped yet
function AddNoteButtonComposer() {
const { actions } = useNotes();
return (
<Button onClick={() => actions.addNote("")}>
Add note
</Button>
);
}
// the provider is the boundary, the layout is not
<NotesProvider targetId={contractId}>
<PanelHeader>
<AddNoteButtonComposer />
</PanelHeader>
<NoteBlockComposer />
</NotesProvider>;
AddNoteButtonComposer sits outside NotesBlock and still adds a note, with no prop threaded to it. That is the single responsibility payoff. The state and its actions live in one place, every layer between the provider and the leaf drops a prop, and any new actor consumes the same contract instead of forcing another lift. The layout stays a function of what it renders, nothing more.
Naming, so the intent is legible
The rungs only help if a reader can tell which one they are looking at. So the name carries the role:
- A plain name (
NavigationList,NotesBlock) is a props component. Rungs 1 and 2. - The
Composersuffix (QuotesListComposer,NoteBlockComposer) is a consumer that reads a contract and does not care about the source. Rungs 3 and 4. - The
Providersuffix (ActiveQuotesProvider,FixtureQuotesProvider) is an interchangeable source that fills a contract.
When you see a Composer, you know there is a Provider somewhere and that you can add another one. When you see a plain name, you know there is no hidden context to hunt for.
When to climb, and when not to
Default to rung 1. Climb only when the pain is real and named:
- Reach for compound components when a component grows a prop per shape, or when callers keep needing a layout the props did not anticipate.
- Reach for a Composer and Providers when one piece of UI must render data from swappable or deferred sources, or the moment you want a fixture source for tests.
- Reach for lifted state when state has to cross a boundary and the layout should stay a pure function of its props.
And do not climb for these:
- Uniformity. "Every feature should look the same" is not a pain, it is a preference, and it costs indirection.
- A single source with no alternative in sight. One provider and one consumer is just a props component wearing a costume.
- State that never leaves its component. Keep it local.
Every rung is reversible on paper and annoying to reverse in practice. That asymmetry is why we start low.
Wrapping up
We adopted this pattern because our frontend had props drilled too deep, components doing too much, and rows configured by too many props. The ladder gives us a cheap default and a clear, costed step for each pain we hit.
Compose by default, and climb one rung only when something concrete pushes you. Name the rung so the next reader knows where they stand.
Like what you read? We're based in Paris and we're hiring Software Engineers! Check out our open positions!
