This is intended only as a proposal and not a set of final guidelines. It assumes you have working knowledge of React & Redux. Last updated March 8th, 2018.
This document outlines a system for developing React applications that should be able to provide a clean and natural way of thinking about data within your UI and how it binds to components. The system should try to get out of the way as much as possible, and make it simple to refactor your application when the time arises. Finally it should keep things separate from eachother, and make it clear how each piece can be independently tested.
These are problems specific to React/Redux applications and is not meant to be a critique of any specific project.
Imagine you have a data model called User
which looks like the following,
{
"id": 1,
"email": "[email protected]",
"firstName": "Joe",
"lastName": "Kent",
"photo": "http://78.media.tumblr.com/tumblr_m3i2kerw6o1qejbiro1_1280.png",
}
You then create a component to display the user in a module,
const User = (props) => {
const { firstName, lastName, photo } = props;
return (
<div>
<h1>{firstName}, {lastName}</h1>
<img src={photo} />
</div>
);
};
User.propTypes = {
firstName: PropTypes.string.isRequired,
lastName: PropTypes.string.isRequired,
photo: PropTypes.string.isRequired,
};
User.mapStateToProps = (state) => ({
firstName: state.user.firstName,
lastName: state.user.lastName,
photo: state.user.photo,
});
This works fine until the API team decides to make some changes to the user model. For example, let's say that firsrtName
and lastName
get moved under a new object with the key name
.
{
"name": {
"first": PropTypes.string.isRequired,
"last": PropTypes.string.isRequired,
},
"photo": PropTypes.string,
}
This change would require us to update our User component, because firstName
is no longer a valid prop. When this happens to one component, it's not a show-stopper. But imagine if we also had a Comment component which had a user
prop as well.
const Comment = (props) => {
const { user, message } = props;
const { firstName, lastName } = user;
return (
<div>
<p>{ message }</p>
<h3>{ firstName }, { lastName }</h3>
</div>
);
};
Now this component also has to be refactored in how it references the name data, along with it's asscociated prop types. The first step to solving this should be creating selectors, which are nothing more than functions that accept an entire state and select a specific value from it. In the case of our user, it would look something like this,
// before
function selectUserFirstName(state) {
return state.user.firstName;
}
// after
function selectUserFirstName(state) {
return state.user.name.first;
}
// ...
User.mapStateToProps = (state) => ({
firstName: selectUserFirstName(state),
});
In turn, this means if the original component recieved its props from the selector function it would have never broken. In the case of the Comment component, however, it's still going to be borked. This is because the Comment component is recieving the entire user as a prop, and is directly accessing the name values based on the outdated key name.
There are two solutions to this problem, depending on what you're looking to solve. In the case of the comment, where the amount of properties is sparse and well defined, you can simply bind the users first and last name directly to the component rather than the user object.
In many cases however this isn't feasible. For example, imagine if there was a component that stored all of the comments on a blog or social post of some kind.
const CommentFeed = (props) => {
const { comments } = props;
return comments.map(comment => <Comment key={comment.id} {...comment} />);
}
You could bind every instance of firstName as a new prop by prepending the comment id to the key name, but I think we all agree that would look awful. This is where chain_ selectors come into play (I'm not sure if that's a real term or I just made it up). Chain selectors simply means that the selector functions can be composed together easily depending on your situation. The defining principles should be that,
- A selector should always use another selector to reference it's parent.
- There should be a version of the selector that retrieves the value from an object, not the Redux state.
In the case of the user, it would look something like this,
// Before
function selectUserFromState(state) {
return state.user;
}
function selectFirstNameFromUser(user) {
return user.name.first;
}
function selectUserFirstNameFromState(state) {
return selectFirstNameFromUser(selectUserFromState(state));
}
// After
function selectUserFromState(state) {
return state.user;
}
function selectFirstNameFromUser(user) {
return user.name.first;
}
function selectUserFirstNameFromState(state) {
return selectFirstNameFromUser(selectUserFromState(state));
}
Now your components can reliably access any property from any data source. However, we still haven't addressed a few sub-problems, and we also invented a new one, extensive boiler-plate.
The good news is we can likely automate much of the common selectors one would need.