Skip to content

Instantly share code, notes, and snippets.

@jamesgpearce
Last active September 22, 2017 23:34
Show Gist options
  • Star 42 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save jamesgpearce/53a6fc57677870f93248 to your computer and use it in GitHub Desktop.
Save jamesgpearce/53a6fc57677870f93248 to your computer and use it in GitHub Desktop.
DIMOC: Do It Myself or Callback - a simple pattern for React components

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

@mjackson
Copy link

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.

var Number: React.ReactClass = React.createClass({
  propTypes: {
    number: React.PropTypes.number.isRequired,
    onIncrement: React.PropTypes.func
  },
  getInitialState: function(): any {
    return {i: this.props.number};
  },
  managesOwnState: function(): boolean {
    return !this.props.onIncrement;
  },
  componentWillReceiveProps: function(nextProps: any): void {
    if (!this.managesOwnState()) {
      this.setState({i: nextProps.number});
    }
  },
  onClick: function(e: React.Event): void {
    if (this.managesOwnState()) {
      this.setState({i: this.state.i + 1});
    } else {
      this.props.onIncrement();
    }
  },
  render: function(): React.ReactComponent {
    return (
      <button onClick={this.onClick}>
        {this.state.i}
      </button>
    );
  }
});

This makes it a little easier for me to think about what the truthiness checks mean.

@sebmarkbage
Copy link

It is tempting to create two separate components. One simple stateless component and one wrapper component that fully manages the state of the inner one. However, this will end up limiting you on more complex components since you may want to control one property but not another and every possible combinations of control.

A good component can hide implementation details that I don't care about yet is flexible enough to expose them if I do.

@mjackson I definitely agree that a helper is needed but ideally it should be named after the prop or combination of props that you're controlling. That way it is clear when you add more controlled/uncontrolled props. This is also the reason we haven't really added a convenient automated way of doing this in React. It can be error prone when you can't reason about which combination can be controlled together.

@jamesgpearce For React's built-in we explicitly don't use event listeners to determine whether or not a component is controlled or not. Listening to something happening should never change its behavior. We don't want out double-slit experiment changing by the observer effect.

One use case is that you may need to listen to the value changing without necessarily controlling it.

For example, you may need to keep a copy of it in your own state so that you can use it when you hit a submit button or something. This is especially important when there are multiple events for a change.

That's why we have something like initialNumber and number to determine which one gets used.

You're also kind of abusing state for something that isn't stateful which means some extra scheduling and we can't avoid unnecessary memory allocation for something that isn't stateful.

I tend to prefer the model where you read directly from props when it is fully controlled:

render() {
  return (
      <button onClick={this.onClick}>
        {this.managesOwnNumber() ? this.state.i : this.props.number}
      </button>
  );
}

This ensures that while it is managed there is nothing that could accidentally go wrong but updating state internally without switching back. There is only one source of truth at a time.

This also allow you to temporarily diverge state. E.g. by switching managesOwnNumber while an edit-box is checked for example. So that the model can remain consistent outside this field.

@jamesgpearce
Copy link
Author

^ what @sebmarkbage said 😀

@hamzakubba
Copy link

How about:

/* @flow */
var React = require('react');

var Letter: React.ReactClass = React.createClass({
  render: function(): React.ReactComponent {
    return (
      <button onClick={this.props.onClickCallback}>
        {this.props.letter}
      </button>
    );
  }
});

var Number: React.ReactClass = React.createClass({
  render: function(): React.ReactComponent {
    return (
      <button onClick={this.props.onClickCallback}>
        {this.props.number}
      </button>
    );
  }
});

var SeatPicker: React.ReactClass = React.createClass({
  getInitialState: function(): any {
    return {
      number: 1,
      letter: 0
    };
  },
  incrementLetter: function: void () {
    this.setState({letter: (this.state.letter + 1) % 26});
  },
  incrementNumber: function: void () {
    this.setState({number: this.state.number + 1});
  },
  render: function(): React.ReactComponent {
    return (
      <div>
        <Letter letter={String.fromCharCode(65 + this.state.letter)} onClickCallback={this.incrementLetter} />
        <Number number={this.state.number} onClickCallback={this.incrementNumber} />
      </div>
    );
  },
});

This makes the child component code extremely simple (no local state!), gets the data to flow from top to bottom as is the React way, and makes the child components flexible enough to work with any external logic. In fact, if Numbers and Letters look the same on the app, only one of them (renamed appropriately) is needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment