Handling props and class names in React
If you spend any time writing React then you'll find a common task is managing components and their props. This post will detail a pattern that I've found works quite well.
A Profile
Let's imagine a Profile
component that would be responsible for displaying a
username:
import React from 'react';
const {div} = React.DOM;
const Profile = props => div({className: 'Profile Profile--large'}, props.username);
export default Profile;
If this looks unfamiliar then take a moment to read up on stateless functional components.
Currently it just accepts one prop (which is the username) and renders it in a div
with some default classes. Here's how you might construct it:
React.createElement(Profile, {
username: 'simonsmith'
});
And the output:
<div class="Profile Profile--large">simonsmith</div>
Nothing groundbreaking at the moment.
Adding more props
Now we'll throw in a couple of extra props, one to distinguish this user as an admin, and also some additional classes for styling:
React.createElement(Profile, {
username: 'simonsmith',
isAdmin: true,
className: 'u-flexGrow1'
});
We're mixing presentation and logic related props together here, but that's
fine. In theory we should be able to throw any additional component props in the
mix such as onClick
or aria-hidden
and expect it work. The Profile
component can extract
the props it cares about and send the rest along to the rendered HTML.
I've found that extracting the 'logic' props at the top of the render
method makes it clear what the component relies on:
const {isAdmin, className, username} = props;
The second thing to do is create an object that contains the remaining props.
The omit
function from lodash is perfect
for this:
const elemProps = omit(
props,
'username',
'className',
'isAdmin'
);
This creates a new object with any remaining props that may have been passed in,
and now they can be passed directly to the div
root element.
If you're using Babel and the object rest spread transform plugin then you can use an even shorter pattern:
const {isAdmin, className, username, ...elemProps} = props;
This 'spreads' out the remaining properties and their values into a new object
called elemProps
.
const Profile = props => {
const {
isAdmin,
className,
useDefaultClassName,
username,
...elemProps
} = props;
return (
div(elemProps, username)
);
};
It's worth noting that React won't allow just any old attribute to come through to the rendered HTML. It maintains a whitelist of all the attributes that can be used so in theory you could rely on this and skip the
omit
/spread
step. However, I favour being explicit for readability and there are no guarantees this behaviour won't change. Additionally this approach is mentioned in the React documentation.
Read more on that topic in this GitHub issue.
Managing class names
To recap, the Profile
component already has two default class names (Profile
and Profile--large
) and we need to add is-admin
if the isAdmin
prop is true
as well as any additional class names.
The aptly named classNames library is the ideal tool for the job. It allows a mixture of input types and allows you to forget about manual string concatenation:
classNames('foo', 'bar'); // => 'foo bar'
classNames('foo', { bar: true }); // => 'foo bar'
Now we can create a new object containing our custom className
prop and then
merge it into elemProps
before they're applied to the div
element. Once again
lodash can help with this via assign
.
assign(elemProps, {
className: classNames('Profile Profile--large', {
'is-admin': isAdmin
}, className)
});
<div class="Profile Profile--large is-admin u-flexGrow1">simonsmith</div>
This may look somewhat heavy handed for just a few of classes but it really comes into its own when we add more conditions.
Disabling the default class names
When I started using this pattern it wasn't long before I ended up in a situation where I needed to disable the default class names and customise the component more than usual. By allowing the default classes to depend on a prop we can disable them from outside the component.
First we need a default prop:
Profile.defaultProps = {
useDefaultClassName: true
};
And then adjust the classNames
function call:
assign(elemProps, {
className: classNames({
'Profile': useDefaultClassName,
'Profile--large': useDefaultClassName,
'is-admin': isAdmin
}, className)
});
Now the user of the component can easily disable the default class names and use their own:
React.createElement(Profile, {
isAdmin: true,
username: 'simonsmith',
useDefaultClassName: false,
className: 'user_profile user_profile__large'
});
<div class="user_profile user_profile__large is-admin">simonsmith</div>
The final product
After a few refactors the Profile
component looks ready to go:
import React, {PropTypes} from 'react';
import omit from 'lodash.omit';
import assign from 'lodash.assign';
import classNames from 'classnames';
const {div} = React.DOM;
const Profile = props => {
const {
isAdmin,
className,
useDefaultClassName,
username,
...elemProps
} = props;
assign(elemProps, {
className: classNames({
'Profile': useDefaultClassName,
'Profile--large': useDefaultClassName,
'is-admin': isAdmin
}, className)
});
return (
div(elemProps, username)
);
};
Profile.defaultProps = {
useDefaultClassName: true
};
Profile.propTypes = {
className: PropTypes.string,
isAdmin: PropTypes.bool,
username: PropTypes.string,
useDefaultClassName: PropTypes.bool
};
export default Profile;
One addition above is the use of propTypes
. I tend to put props here that the
component will act on, or logic props as mentioned earlier. This helps further
with debugging and maintenance.
What about testing?
In an earlier post
I recommended use of the excellent skin-deep library to
aid with unit testing. With that in mind we can easily test that the correct
props are used in the Profile
component:
import {expect} from 'chai';
import React from 'react';
import Profile from '../../components/profile';
import sd from 'skin-deep';
describe('Profile component', () => {
it('should have the correct props', () => {
const tree = sd.shallowRender(React.createElement(Profile, {
isAdmin: true,
username: 'simonsmith',
className: 'u-flexGrow1'
}));
expect(tree.props).to.contain({
children: 'simonsmith',
className: 'Profile Profile--large is-admin u-flexGrow1'
});
});
it('should allow default class names to be disabled', () => {
const tree = sd.shallowRender(React.createElement(Profile, {
isAdmin: true,
useDefaultClassName: false,
username: 'simonsmith',
className: 'user-profile'
}));
expect(tree.props).to.contain({
children: 'simonsmith',
className: 'is-admin user-profile'
});
});
});
Profile component
✓ should have the correct props
✓ should allow default class names to be disabled
And it passes as expected!
You can grab the example code for this post on GitHub