This is part of a series of posts about writing component API for great developer experience.
When it comes to React components, props are the API that developers consume. You want to make it easier for them to use your component
Here’s part #1 and #2 if you missed it: Writing good component API
Let’s start with a simple React component which renders an anchor tag:
<Link href="sid.st">Click me</Link>
// will render:
<a href="sid.st" class="link">Click me</a>
And here’s what the code for the component looks like:
const Link = props => {
return (
<a href={props.href} className="link">
{props.children}
</a>
)
}
We also want folks to be able to add html attributes to the element like an id
, target
, title
, data-attr
etc.
Because there are a lot of html attributes, we could just pass along all the props and add the ones we need (like className
)
(Note: You should not forward attributes that are invented by you for this component and not in the HTML spec)
In this case, we just have className
const Link = props => {
/*
we use the spread operator to pass all the
properties along (including children)
*/
return <a {...props} className="link" />}
This is where it gets interesting.
It’s all when fun and games when someone passes an id
or a target
:
<Link href="sid.st" id="my-link">Click me</Link>
// will render:
<a href="sid.st" id="my-link" class="link">Click me</a>
but what happens when someone passes a className
?
<Link href="sid.st" className="red-link">Click me</Link>
// will render:
<a href="sid.st" class="link">Click me</a>
Well, nothing happened. It completely ignored the custom class. Let’s go back to the implementation:
const Link = props => {
return <a {...props} className="link" />}
Okay, let’s compile that ...props
in our minds, the above code is equivalent to:
const Link = props => {
return (
<a
href="sid.st"
className="red-link" className="link" >
Click me
</a>
)
}
You see the conflict? There are two className
props. How does React handle this?
Well, React doesn’t. Babel does!
Remember JSX is a short hand for React.createElement
. The props are converted into an object and passed as an argument. Objects don’t support duplicate keys, so the second className
will override the first one.
const Link = props => {
return React.createElement(
'a',
{ className: 'link', href: 'sid.st' },
'Click me'
)
}
Now that we understand the problem, how do we fix it?
It’s useful to notice that a bug due to name-conflict like this can happen with any prop, not just className
. So the solution depends on the behavior you want to implement.
There can be 3 desired outcomes:
- The developer using our component should be able to override the default value of a prop
- We don’t want to allow the developer to change certain props
- The developer should be able to add values while keeping the default value
Let’s tackle them one at a time.
1. The developer using our component should be able to override the default value of a prop
This is the behavior you usually expect from the other attributes - id
, title
, etc.
We often see customisation of test id in cosmos (the design system I work on). Each component gets data-test-id
by default, sometimes developers want to attach their own test id instead to denote a specific usage.
Here’s one such use case:
const Breadcrumb = () => (
<div className="breadcrumb" data-test-id="breadcrumb">
<Link data-test-id="breadcrumb.link">Home</Link> <Link data-test-id="breadcrumb.link">Parent</Link>
<Link data-test-id="breadcrumb.link">Page</Link>
</div>
)
The Breadcrumb
uses a Link
but you want to be able to target it in tests with a more specific data-test-id
. Makes sense.
In most use cases, the user’s props should have preference over the default ones.
In implementation, this means the default props should come first followed by {...props}
to override them.
const Link = props => {
return (
<a className="link" data-test-id="link" {...props} /> )
}
Remember the second occurance of data-test-id
(from props) will override the first one (default). So, when you attach your own data-test-id
or className
, it will override the default one:
1. <Link href="sid.st">Click me</Link>
2. <Link href="sid.st" data-test-id="breadcrumb.link">Click me</Link>
// will render:
1. <a class="link" href="sid.st" data-test-id="link">Click me</a>
2. <a class="link" href="sid.st" data-test-id="breadcrumb.link">Click me</a>
We can extend this example to a custom className
as well:
<Link href="sid.st" className="red-link">Click me</Link>
// will render:
<a href="sid.st" class="red-link" data-test-id="link">Click me</a>
That looks plain weird, I’m not sure we should even allow that! Let’s talk about that use case next.
2. We don’t want to allow developers to change certain props
Lets says we don’t want folks to change the appearance (via className
) but we’re okay with them changing other props like id
, data-test-id
, etc.
We can make this possible by carefully ordering our attributes:
const Link = props => {
return (
<a data-test-id="link" {...props} className="link" /> )
}
Remember, the attribute on the right will override the attribute on the left. So everything before {...props}
can be overridden, but everything after it can’t be overridden.
To improve the developer experience, we would also want to show a warning to notify the user that className
isn’t accepted.
I like to create a custom prop-types validation for this:
Link.PropTypes = {
className: function(props) {
if (props.className) {
return new Error(
`Invalid prop className supplied to Link,
this component does not allow customisation`
)
}
}
}
I have a video which explains custom prop-types validation if you’re curious how to write them.
Now when a user tries to override the className
, not only does it not work, but they get a warning as well.
<Link href="sid.st" className="red-link">Click me</Link>
// will render:
<a href="sid.st" class="link">Click me</a>
Warning: Failed prop type:
Invalid prop className supplied to Link,
this component does not allow customisation
To be honest, I’ve only had to use this pattern once or twice. Usually you trust the developer using your component to not use in evil ways.
Which brings to a collaborative use case
3. The developer can add additional values on top of the default value
This is probably the most common use case when it comes to classes.
<Link href="sid.st" className="underline">Click me</Link>
// will render:
<a href="sid.st" class="link underline">Click me</a>
The implementation looks like this:
const Link = props => {
/* grab className using object destructing */
const { className, otherProps } = props /* append these classes to the default */
const classes = 'link ' + className
return (
<a
data-test-id="link"
className={classes} {...otherProps} /* pass along all the other props */
/>
)
}
This pattern is also useful to accept event handlers (like onClick
) on a component which already has them internally.
<Switch onClick={value => console.log(value)} />
Here’s what the implementation of this component looks like:
class Switch extends React.Component {
state = { enabled: false }
onToggle = event => {
/* perform the component's own logic first */
this.setState({ enabled: !this.state.enabled })
/* then call event handler from props */
if (typeof this.props.onClick === 'function') { this.props.onClick(event, this.state.enabled) } }
render() {
/*
our component already has a click handler ⬇️
*/
return <div class="toggler" onClick={this.onToggle} /> }
}
There’s another way to avoid name-conflict in even t handlers, if you read my previous post about naming behaviors not interactions, you would know 😉
Here’s the core idea: Name your props with the behavior you want (toggle
) and let the component internally take care of the event handler it needs to use for the user (onClick
, onKeyPress
, onDrag
, etc.)
<Switch onToggle={value => console.log(value)} />
Those were the three scenarios.
For each scenario, you might want to take a different approach.
- Most of the time: The developer should be able to override the default value of the prop
- Usually for styles and event handlers: The developer should be able to add a value on top of the default value
- Rare occasion when you have to restrict: The developer isn’t allowed to change the behavior, ignore it but show a warning
Hope this was helpful on your journey
Sid