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.
No comments:
Post a Comment