TLDR: a React component should either manage its own state, or expose a callback so that its parent can. But never both.
Sometimes our first impulse is for a component to entirely manage its own state. Consider this simple theater seating picker that has a letter for a row, and a number for a seat. Clicking the buttons to increment each value is hardly the height of user-interface design, but never mind - that's how it works:
/* @flow */
var React = require('react');
var Letter: React.ReactClass = React.createClass({
getInitialState: function(): any {
return {i: 0};
},
onClick: function(e: React.Event): void {
this.setState({i: (this.state.i + 1) % 26});
},
render: function(): React.ReactComponent {
return (
<button onClick={this.onClick}>
{String.fromCharCode(65 + this.state.i)}
</button>
);
}
});
var Number: React.ReactClass = React.createClass({
getInitialState: function(): any {
return {i: 1};
},
onClick: function(e: React.Event): void {
this.setState({i: this.state.i + 1});
},
render: function(): React.ReactComponent {
return (
<button onClick={this.onClick}>
{this.state.i}
</button>
);
}
});
var SeatPicker: React.ReactClass = React.createClass({
render: function(): React.ReactComponent {
return (
<div>
<Letter />
<Number />
</div>
);
},
});
This works fine until the parent class wants to start tracking these values. Imagine now that we want to prepopulate the two picker buttons, and even get the picker to indicate the position of the selected row relative to the stage... obviously we need that index to be stored in the state of SeatPicker
itself:
var Letter: React.ReactClass = React.createClass({
propTypes: {
letter: React.PropTypes.number.isRequired,
},
getInitialState: function(): any {
return {i: this.props.letter};
},
...
var Number: React.ReactClass = React.createClass({
propTypes: {
number: React.PropTypes.number.isRequired,
},
getInitialState: function(): any {
return {i: this.props.number};
},
...
var SeatPicker: React.ReactClass = React.createClass({
getInitialState: function(): any {
return {letter: 6};
},
render: function(): React.ReactComponent {
return (
<div>
<Letter letter={this.state.letter} />
<Number number={12} />
<br />
[row is {this.state.letter} from the front]
...
All well and good.
But of course this doesn't really work, because although clicking increments the row in the child component, the parent doesn't know that's happened. We need a callback to be passed into Letter
so that it can tell the SeatPicker
that it's changed:
var Letter: React.ReactClass = React.createClass({
propTypes: {
letter: React.PropTypes.number.isRequired,
onIncrement: React.PropTypes.func.isRequired,
},
...
onClick: function(e: React.Event): void {
this.props.onIncrement();
},
...
var SeatPicker: React.ReactClass = React.createClass({
...
onLetterIncrement: function(): void {
this.setState({letter: (this.state.letter + 1) % 26});
},
render: function(): React.ReactComponent {
return (
<div>
<Letter letter={this.state.letter} onIncrement={this.onLetterIncrement} />
...
(As an aside, notice how the parent has a function name that's more abstract or semantic than just onClick
since the implementation of the child's UI in order to affect that increment might change.)
But this still doesn't work. And in fact now we are in trouble. Because the row index (the letter) now has two states-of-truth: one in the parent (passed down as a prop once) and one in the child's own state. And of course the button value (which stays the same) and the label (which increments) get immediately out of sync - precisely because we removed the incrementing code to replace it with the callback.
Yes, we could keep the incrementing code in both places and attempted to keep the two states-of-truth in sync, but that would be duplicating business logic (are there really only 26 rows in the theater anyway?) and that sounds even worse. And as a calling parent, how would I know that's what's happening inside?
So then, let's make sure that the props passed down from the parent replace the child state every time there's a re-render:
var Letter: React.ReactClass = React.createClass({
...
componentWillReceiveProps: function(nextProps: any): void {
this.setState({i: nextProps.letter});
},
Now we are in business. It works!
Yes, componentWillReceiveProps
seems like a pretty nifty trick to keep things in sync. Maybe we should add it to the Number
component too? After all, it has an equivalent getInitialState
too...
var Number: React.ReactClass = React.createClass({
...
componentWillReceiveProps: function(nextProps: any): void {
this.setState({i: nextProps.number});
},
Cool! I click the letter. It increments. The label updates. I click the number. It increments too. Woohoo.
I click the letter again. But... whoah! The number has reset to 12. Huge bug. And it could have been easy to miss.
What is going on?
Well of course, when we update the letter again after having updated the number, the parent is being re-rendered. And so that means our literal 12
is being passed back into the number component and blowing away its internal state.
In React, there can often be this sort of tension between the possible locations of the state-of-truth. And working through a component data flow like this requires a clarity of though on the part of the developer, and adherence to consistent practices. In the real-world though, you might not even be responsible for writing the component you're using. So every time you use a component, you need to ask yourself: does it have an internal state? Can you populate it with initial prop values? Does it have callbacks when data changes? How much do I have to manage and how much can it do for itself?
So make this easy to answer, one technique that I have found useful - even for entirely my own code - is a simple pattern call DIMOC, which means that component will "Do It Myself Or Callback". Nothing too clever, but the operative word here is "OR": the child component is offering a contract to EITHER manage its own state (which we might also assume includes some sort of persistence) OR take a callback to tell its parent about everything it wants to have done to itself.
But never both.
How does this work in reality? Well the simple trick is to make the callback parameter itself optional. If it is passed in, the child is agreeing to delegate all related state management back to its parent, and it won't even attempt to do it itself, preferring to receive new props getting passed down.
If the callback is NOT passed in, then the component knows it is on its own, and it should ignore new props relating to that part of the state. Simple.
This is how I would make the Letter
component adhere to this DIMOC principle:
var Number: React.ReactClass = React.createClass({
propTypes: {
number: React.PropTypes.number.isRequired,
onIncrement: React.PropTypes.func,
},
getInitialState: function(): any {
return {i: this.props.number};
},
componentWillReceiveProps: function(nextProps: any): void {
if (this.props.onIncrement) {
this.setState({i: nextProps.number});
}
},
onClick: function(e: React.Event): void {
var onIncrement = this.props.onIncrement;
if (!onIncrement) {
this.setState({i: this.state.i + 1});
} else {
onIncrement();
}
},
render: function(): React.ReactComponent {
return (
<button onClick={this.onClick}>
{this.state.i}
</button>
);
}
});
Note A) that the PropType
no longer isRequired
, B) that componentWillReceiveProps
checks to see if a callback has been passed in before blowing away internally-manage state, and C) how the onClick
function will similarly check to see whether it should manage its own state or be controlled from above.
Not only does that immediately get our SeatPicker
error-free again, but it also means that if we choose in the future to start storing the number in the parent SeatPicker
's state as we have been doing for the letter, we won't have to change the child implementation at all. We just start passing the callback in and the singular state-of-truth's location changes.
In my experience, deciding where the state is managed simply by using or omitting the callback prop should always make it fairly clear to the calling component what its two choices about the location of state-of-truth are, and unambiguity and brittleness are more likely to be avoided.
Have fun, please comment... and let me know if this idea is any help or not.
@jamespearce
This is good stuff James. Thanks for sharing! :)
In your final example, I'd be tempted to add a
managesOwnState
method, to make it a little more clear what the truthiness checks are all about. So, e.g.This makes it a little easier for me to think about what the truthiness checks mean.