Stage | Status |
---|---|
Approved | ✅ |
Adopted | 🚧 |
Note: Consumer is used multiple times on this page. It refers to the developers consuming the component API and not end users.
-
Prefer using children for “content”
-
For composite components, the API should be decided by how much customisation is available for children.
For components that have design decisions baked in, should use strict props. For example, the color of the icon inside a Button component is decided by the variant
prop on the Button. The API does not allow for changing that.
<Button variant="danger" leadingIcon={TrashIcon}>
Delete branch
</Button>
On the other hand, if we want consumers to have more control over children, a composite API is the better choice.
<ActionList.Item>
<ActionList.LeadingVisual>
<Avatar src="https://github.com/mona.png" />
<ActionList.LeadingVisual>
mona
</ActionList.Item>
With React, children
is the out-of-the-box way for putting phrasing content inside your component. By using children
instead of our own custom prop, we can make the API “predictable” for its consumers.
// prefer this
<Flash variant="success">Changes saved!</Flash>
// over this
<Flash variant="success" text="Changes saved!"/>
Children as an API for content is an open and composable approach. The contract here is that the Flash
controls the container while the consumer of the component controls how the contents inside look.
Take this example of composition:
import {Flash} from '@primer/react'
import {CheckIcon} from '@primer/octicons-react'
render(
<Flash variant="success">
<CheckIcon /> Changes saved!
</Flash>,
)
-
The component is aware of recommended use cases and comes with those design decisions backed-in. For example, using an
Icon
withFlash
is a recognised use case. We don’t lock-in a specific icons, but we do set the size, variant-compatible color and the margin between the icon and text.For example:
<Flash variant="success"> <CheckIcon /> Changes saved! </Flash> <Flash variant="danger"> <AlertIcon /> Your changes were not saved! </Flash>
-
You can bring your own icon components, the component does not depend on a specific version of octicons.
-
When a product team wants to explore a use cases which isn’t baked into the component, they are not blocked by our team.
Example:
<Flash variant="success" sx={{display: 'flex', justifyContent: 'space-between'}}> <span> <CheckIcon /> Changes saved! </span> <Button variant="invisible" icon={CheckIcon} aria-label="Hide flash message" onClick={onDismiss} /> </Flash>
Our goal isn't to put all content inside children though. Composition offers flexibility to the consumer of the component; this flexibility, however, can also lead to inconsistencies.
<Flash variant="success">
// uh oh, we don't know if that color or icon size is the right choice!
<CheckIcon size={20} color="success.emphasis" /> Changes saved!
</Flash>
<Flash variant="success">
<CheckIcon /> Changes saved!
</Flash>
Note: We need to assume good intent here, developers using the components aren’t trying to break the system. They are either trying to implement something that the system’s happy path does not support OR there are multiple ways of doing something with Primer and they have unintentionally picked the approach that is not recommended.
The general wisdom is to Make the right (recommended) thing easy to do and the wrong (not recommended) hard to do. When going off the happy path, developers should feel some friction, some weight, code that “feels hacky” or feels like a workaround.
In the above case, if we want to make the recommended path easier and other paths harder, we can change the API to look something like this:
<Flash variant="success" icon={CheckIcon}>
Changes saved!
</Flash>
We are still using children
for text content, but we have moved the icon
back as a prop with reduced flexibility.
When intentionally going off the happy path, developers can still drop down an abstraction level to add an Icon
to children
, though they would have to pick up the additional effort of setting compatible color, size and margin themselves. However, when it’s unintentional, it would feel like way too much work that the component should be doing automatically.
<Flash variant="success">
<CheckIcon size={20} color="success.emphasis" sx={{marginRight: 2}} />
Changes saved!
</Flash>
Sidenote: We might want to name this prop leadingIcon
, even if there is no trailingIcon
. Consistent names across components plays a big role in making the API predictable.
You can see this pattern used in Button
:
The icon gets its color and margin based on the variant and size of the Button
. This is the happy path we want folks to be on, so we ask for the icon component instead of asking the developer to render the icon.
<Button leadingIcon={SearchIcon}>Search</Button>
<Button leadingIcon={SearchIcon} variant="primary" size="large">Search</Button>
// we prefer this:
<Button leadingIcon={SearchIcon}>Search</Button>
// over these:
<Button><SearchIcon/> Search</Button>
<Button leadingIcon={<SearchIcon/>}>Search<Button>
1. Scenarios where we want to restrict flexibility and bake-in design decisions for the most part, but allow some configuration.
Consider this fake example:
We want users to be able to customise if the Icon
is outline or filled. (I know I know the example is bit silly, but please go with it)
Extending our strict
API, we could add another prop to the component:
<Flash variant="success" icon={CheckIcon} iconVariant="filled">
Changes saved!
</Flash>
When we have an “element” and “elementProp” as props on a component, it’s a sign that we should create a child component here that is tied to the parent component:
<Flash variant="success">
<Flash.Icon icon={CheckIcon} variant="filled" />
Changes saved!
</Flash>
The Parent.Child
syntax signals that this component is tied to the parent.
We can look at Flash.Icon
as a stricter version of Icon
that automatically works with different variants of Flash
. It does not need to support all the props of Icon
like size or color, only the ones that are compatible in the context of a Flash
.
It can be tempting to "future proof" our API and adopt this pattern early, but we should resist that until the use case presents itself. It is always easier to open up the API later than to close it down.
Note: We might want to name this component Flash.LeadingIcon
, even if there is no trailingIcon
. We should try to keep the names consistent across components with the same behavior, but that should not be a deciding factor in making the choice between prop or child component.
We use this pattern as well in Button
, Button.Counter
is a restricted version of CounterLabel
that automatically adjusts based on the variant and size of a Button
:
<Button>
Watch <Button.Counter>1</Button.Counter>
</Button>
<Button variant="primary">
Upvote <Button.Counter>1</Button.Counter>
</Button>
Another place where composite patterns lead to aesthetic predictable APIs is when we want to expose customisation options for internal components.
For Example, legacy ActionMenu accepted overlayProps
and anchorContent
to pass it down to the implementation details:
<ActionMenu overlayProps={{width: 'medium'}} anchorContent="Open column menu"></ActionMenu>
When we see a a prop which resembles “childProps" or renderChild
on the container/parent, it's a sign that we should surface this detail in the API by creating a composite component:
// we created an additional layer so that
// the overlay props go on the overlay component
<ActionMenu>
<ActionMenu.Button>Open column menu</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList>...</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
In components where there is a place for consumers to fill in freeform or unstructured content, we should prefer the composite children components. This is especially common in the cases of Dialogs, Menus, Groups.
Consider this fake Flash
example where description is unstructured content:
// prefer this:
<Flash variant="success" icon={CheckIcon}>
<Flash.Title>Changes saved</Flash.Title>
<Flash.Description>
These changes will be applied to your next build. <Link href="/docs/builds">Learn more about builds.</Link>
</Flash.Description>
</Flash>
// over this:
// Trying to systemise content by finding patterns in
// unconstructured content can lead to overly prescriptive API
// that is not prectictable and hard to remember
<Flash
variant="success"
icon={CheckIcon}
text="Changes saved"
description="These changes will be applied to your next build."
link={{ text: "Learn more about builds.", url: "/docs/builds" }}
/>
Sidenote: It’s tempting to change icon
to Flash.Icon
here so that it’s consistent with the rest of the contents. This is a purely aesthetic optional choice here:
<Flash variant="success">
<Flash.Icon icon={CheckIcon} />
<Flash.Title>Changes saved</Flash.Title>
<Flash.Description>
These changes will be applied to your next build. <a href="/docs/builds">Learn more about builds.</a>
</Flash.Description>
</Flash>
We use this pattern in ActionList
:
<ActionList showDividers>
<ActionList.Item>
<ActionList.LeadingVisual><Avatar src=""/></ActionList.LeadingVisual>
mona
<ActionList.Description>Monalisa Octocat</ActionList.Description>
<ActionList.Item>
<ActionList.Item>
<ActionList.LeadingVisual><Avatar src=""/></ActionList.LeadingVisual>
primer-css
<ActionList.Description variant="block">GitHub</ActionList.Description>
<ActionList.Item>
</ActionList>
Prefer using children for “content”
// we prefer:
<Button>Watch<Button>
<Button variant="primary">Watch<Button>
// over this:
<Button label="Watch"/>
<Button label="Watch" variant="primary"/>
The Icon should adapt to variant and size of the Button
. We could use a EyeIcon
in children here:
<Button>
<EyeIcon /> Watch
</Button>
But, we want to discourage customising the Icon’s color and size in the application. So, in the spirit of making the right thing easy and the wrong thing hard, we ask for the component in a prop instead:
// we prefer:
<Button leadingIcon={EyeIcon}>Watch</Button>
// over these:
<Button><EyeIcon/> Watch</Button>
<Button leadingIcon={<EyeIcon/>}>Watch</Button>
We want to add a Counter
that adapts to the variant without supporting all the props of a CounterLabel
like scheme
.
Button.Counter
is a restricted version of CounterLabel
, making the right thing easy and wrong thing hard:
// we prefer:
<Button leadingIcon={EyeIcon}>
Watch <Button.Counter>1</Button.Counter>
</Button>
// over this:
<Button>
<EyeIcon/> Watch <CounterLabel>1</CounterLabel>
</Button>
// it's possible to make a strong case for this option as well:
<Button leadingIcon={EyeIcon} counter={1}>Watch</Button>