I feel a lot is being written about JavaScript, and I find it difficult to contribute with something genuinely original and useful. This is quite possibly also not original as it sort of came naturally from working intensely with React Redux over the past few years, so I can only imagine that others have come to similar conclusions (and possibly built a library or two around it); but at least it is useful and I have not seen this mentioned anywhere.
The problem
If you’ve worked with React Redux for a while (especially if you’ve refactored large amounts of code) then you know to try your best to keep every component as pure as possible. Small amounts of internal state, such a whether a navigation is open or not can be fine; but anything more than that will be more difficult to present, test, refactor, and reason about.
In React Redux you can pull out data from the global state from any connected component, so it is common to see people wrap pure (or “dumb”) components in a connected container component. Let’s see a quick example.
First the pure component. It takes a name, and a function and greets the person. The person can fire off a “sayHello” action back by clicking a button.
// greeting.js
const Greeting = props => (
<div>
<p>Hello {props.name}</p>
<button onClick={e => props.sayHello()}>Say hello</button>
</div>
)
Greeting.propTypes = {
name: PropTypes.string.isRequired,
sayHello: PropTypes.func.isRequired
}
export default Greeting
The container component does not have any real UI; its purpose is to fetch
whatever is needed from the state based on some parameter(s); in this case a
userId
.
// greeting-container.js
import Greeting from './greeting'
import {sayHello} from './actions'
const GreetingContainer = props => (
<Greeting name={props.name} sayHello={props.sayHello} />
)
GreetingContainer.propTypes = {
name: PropTypes.string.isRequired,
sayHello: PropTypes.func.isRequired
}
const mapStateToProps = (state, props) => ({
name: selectors.getNameById(state, props.userId)
})
export default(mapStateToProps, {sayHello})(GreetingContainer)
In case we want to create another pure component that makes use of this one,
then we’d use the greeting.js
version and make sure to provide
everything that it needs and pass that need upwards to whoever is using our new
component.
The problem is that this quickly gets very unwieldy. It gets unwieldy in two ways:
propTypes
grow. One component I had had apropTypes
object spanning 32 lines. While using this component was still simple enough, just give it what it needs; it felt a bit like you were fetching half the app state and a quarter of the actions for it to run. It would’ve of course been even worse if said component had been used in many places.- If something on the bottom changes its requirements then you’ll be
hammering at your keyboard for a while. As an example we had our
Avatar
component, a very central piece of the UI, change itspropTypes
from a required photo url, to a non-required photo url and a required first name and non-required last name (to produce some initials to show in case the user did not have a photo). It took a while to refactor.
Again, both situations above are simple to work with (and that is Redux’ stregth); everyone gets it. It just felt like a lot of boilerplate for not that much benefit.
Solution
The goal is to get to a point where we have nested pure components, but where
we also aren’t dealing directly with each and every prop
of every sub
component on every level.
We want to err on the side of caution here though, because there is a very fine line that we should not cross. Like I wrote just above, dealing with everything at every level is very explicit, and explicitness is good. Where we don’t want to go is magic-land where everything is implicit and new people on the team have no way to find their way around.
With that in mind, let’s do a bit of refactoring on the Greeting
component above. First we remove the notion of a component being either pure or
connected by exporting both versions (and its mapStateToProps
) from the same
file.
// greeting.js
import {sayHello} from './actions'
export const Greeting = props => (
<div>
<p>Hello {props.name}</p>
<button onClick={e => props.sayHello()}>Say hello</button>
</div>
)
Greeting.propTypes = {
name: PropTypes.string.isRequired,
sayHello: PropTypes.func.isRequired
}
export const mapStateToProps = (state, props) => ({
name: selectors.getNameById(state, props.userId)
})
export default connect(mapStateToProps, {sayHello})(Greeting)
Now, depending on what we import, we have two ways of using the same component. If we do not care about purity, then we can just import it like we did above and move on with our day.
It gets more interesting when we are working with the pure version, because now
we have the mapStateToProps
to help us.
// profile.js
import {Greeting, mapStateToProps as mapGreetingProps} from './greeting'
import {sayHello} from './actions'
// ...
const Profile = props => (
// ...
<Greeting sayHello={props.sayHello} {...props.greetingProps} />
)
// ...
const mapStateToProps = (state, props) => ({
greetingProps: mapGreetingProps(state, {userId: 'foo'})
})
export default connect(mapStateToProps, {sayHello})(Profile)
So now we are not dealing directly with a name
. Sure, if we want, we can pass
it in, essentially overriding whatever came out of mapGreetingProps
, but it
will default to whatever the component asked for itself.
This is exactly what we wanted. We got rid of dealing with every piece of the
props passed down while remaining explicit about what we are doing. Perhaps
even more so: The different props are grouped under their respective keys
(greetingProps
above).
If you were to console.log
the props within Profile
you’d see
something like
{
greetingProps: {
name: 'John',
sayHello: function () { ... }
},
// + whatever `profile` needs
}
What about the action?
We may no longer have to deal directly with the data that we are passing down to it, but we are still dealing with the actions. Can we change that somehow?
We can, but we need to go back to our original greeting.js
first and dispatch
the action in a slightly different way.
// instead of this
<button onClick={e => props.sayHello()}>Say hello</button>
// we do
import {sayHello} from './actions'
<button onClick={e => props.dispatch(sayHello())}>Say hello</button>
Slightly more verbose, but more explicit, and now we only rely on dispatch
,
no matter how many different actions we want to dispatch. This means that
greeting.js
itself has to import actions.sayHello
, but as long as that
action itself is pure1, then that’s okay. We are not breaking
its purity.
Using it, we can change profile.js
accordingly
// profile.js
// ...
const Profile = props => (
// ...
<Greeting dispatch={props.dispatch} {...props.greetingProps} />
)
// ...
// if we provide no second argument, `dispatch` is injected instead.
export default connect(mapStateToProps)(Profile)
What about propTypes?
PropTypes.shape
is eminent here. I will change the Profile
to include a few
more components and this time provide the propTypes
so you can get an idea of
what it will all look like.
// profile.js
import {Greeting, mapStateToProps as mapGreetingProps} from './greeting'
import {TodoList, mapStateToProps as mapTodoListProps} from './todo-list'
// ...
export const Profile = props => (
// ...
<img src={props.photo} />
<Greeting dispatch={props.dispatch} {...props.greetingProps} />
<TodoList dispatch={props.dispatch} {...props.todoListProps} />
)
Profile.propTypes = {
dispatch: PropTypes.func.isRequired,
photo: PropTypes.string.isRequired,
greetingProps: PropTypes.shape(Greeting.propTypes).isRequired,
todoListProps: PropTypes.shape(TodoList.propTypes).isRequired
}
export const mapStateToProps = (state, {userId}) => ({
greetingProps: mapGreetingProps(state, {userId}),
todoListProps: mapTodoListProps(state, {userId}),
photo: selectors.getPhotoFromId(state, userId)
})
export default connect(mapStateToProps)(Profile)
Closing
This isn’t as battle tested as I would have liked yet; but I will start
doing my react components like this from now on, and hopefully will gain some
experience with it. Error messages seem good when props are missing or wrong,
and my initial impression is that the composability is amazing. My only concern
is that the components may not be memoized very well since a lot of that, as I
understand it, is handled by connect
. Only time will tell how well this
performs in the long run.
Notes.
- If you are using thunks in your project, then I suggest you have a look at “withExtraArgument” in the docs to keep your actions pure and testable despite calling your api and whatnot.