Monday, January 29, 2018

React: Parent-child lifecycle methods and refs



Introduction

This post is going to cover the order in which lifecycle methods get called in parent components and their children and where the ref callback fits in this flow, with a little story about passing a ref from a child to a sibling of that child which we are going to solve out gradually.

Backstory

Here's a little context for what got me to write this post, if you are more interested in the meat and potatoes of the thing, you might skip this section, altough things should be much clearer if you read it.

I'll clarify why things behave like this at the end of the post.

Me at work, the design has put us in a situation where a button has to focus a text input that's inside a container that's a sibling of the button's container component. I know that's confusing, so here is an image to illustrate it.

As y'all know in React, focus == ref.

So the parent had to reach into the CatList's text input to get a ref then pass it to the buttonBar.

Approach 1


We can go get the starting point straight out of the official docs

class Parent extends React.Component {
  render() {
    return (
      <div>
        <ButtonBar/>
        <CatList getRef={input => this.catInput = input}/>
      </div>
    );
  }
}

class CatList extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      cats: [
        "Cat1"
      ]
    };
  }

  render() {
    return (
      <div>
        <ul>
          {
            this.state.cats.map((cat, i) => 
              <li key={i}>
                <p>{cat}</p>
              </li>
            )
          }
        </ul>
        <input type="text" ref={this.props.getRef} />
        <button> Send </button>
      </div>
    );
  }
}

class ButtonBar extends React.Component {
  render() {
    return (
      <button> Focus </button>
    );
  }
}

So We've sent the getRef function to the CatList component which is going to extract the DOM ref from the input and pass it up to the catInput property of the Parent.

So far so good. If we want to set focus to the input when the parent mounts it's as easy as

// In the Parent component
componentDidMount() {
  this.catInput.focus();
}

That works, the input is focused whenever the parent is mounted.

So we can pass the property down to the ButtonBar like that, right?

class Parent extends React.Component {
  render() {
    return (
      <div>
        <ButtonBar inputRef={this.catInput}/>
        <CatList getRef={input => this.catInput = input}/>
      </div>
    );
  }
}

class ButtonBar extends React.Component {
  constructor(props) {
    super(props);
    this.focusInput = this.focusInput.bind(this);
  }

  focusInput(e) {
    this.props.inputRef.focus();
  }

  render() {
    return (
      <button onClick={this.focusInput}> Focus </button>
    );
  }
}

Wrong, that would give an error that your props.inputRef is undefined.

Approach 2


Use the parent component's state to store the ref and pass it down to the other sibling.

class Parent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      catInput: null
    };
  }

  componentDidMount() {
    this.setState({
      catInput: this.catInput
    });
    this.catInput.focus();
  }

  render() {
    return (
      <div>
        <ButtonBar inputRef={this.state.catInput}/>
        <CatList getRef={input => this.catInput = input}/>
      </div>
    );
  }
}

class CatList extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      cats: [
        "Cat1"
      ]
    };
  }

  render() {
    return (
      <div>
        <ul>
          {
            this.state.cats.map((cat, i) => 
              <li key={i}>
                <p>{cat}</p>
              </li>
            )
          }
        </ul>
        <input type="text" ref={this.props.getRef} />
        <button> Send </button>
      </div>
    );
  }
}

class ButtonBar extends React.Component {
  constructor(props) {
    super(props);
    this.focusInput = this.focusInput.bind(this);
  }

  focusInput(e) {
    this.props.inputRef.focus();
  }

  render() {
    return (
      <button onClick={this.focusInput}> Focus </button>
    );
  }
}

This works, but not for the reason you might think it works for. In fact if we set any state on the parent component, and pass the this.catInput property of the Parent (like we did in approach 1) it will work.

The thing is, setState causes a re-render, so the props are sent correctly (next section explains why the props went right this time).

That means we can replace the code inside componentDidMount of the Parent component with a forceUpdate and it will work.

// Parent component
componentDidMount() {
  this.forceUpdate();
  this.catInput.focus();
}

And in the render method we pass in the class property this.catInput to the ButtonBar.

// Parent component
render() {
  return (
    <div>
      <ButtonBar inputRef={this.catInput}/>
      <CatList getRef={input => this.catInput = input}/>
    </div>
  );
}

Meat and potatoes

To understand the reasons behind what worked and what didn't work in the previous story we can first start with how lifecycle methods are called. The order is as follows:

  • The render method of the parent (passing the props).
  • The render method of the children.
  • Ref callbacks in children.
  • The componentDidMount method of the children.
  • The componentDidMount method of the parent.

So to wrap up

  • the props are already sent before any of the hierarchy's componentDidMount methods are called.
  • The ref is not available until just before the componentDidMount of the children is called.
  • componentDidMount of the Parent is then called after all its children had called theirs.

That's why we could read the input ref in the componentDidMount of the Parent but the input prop was undefined when passed to the other component (the ButtonBar's props.inputRef).

Causing a re-render makes the prop be sent with the right value, because re-renders always give the child components the up-to-date values of their props, and that's what forceUpdate did in the last example above.

forceUpdate might not be the best solution to this kind of situation, I'd like to know what your approach would be for that matter.


That's it! I hope you enjoyed the little story.

if you have any suggestions, want to point out mistakes or share knowledge, you can always leave a comment.