How to notify parent's component class (using hooks) after the child is in ready state?

Viewed 128

I'm rendering in index.js the following main component:

export default function Home() {
  return (
    <Main/>
  )
}

Where Main component is defined as:

import React from "react";
export default class Main extends Child {

  constructor(props) {
    super(props);
  }

  async componentDidMount() {
    if (this.state.ready) {
      console.log('Parent ready'); // This is NOT called!
    } else {
      console.log('Parent mounted'); // This is called fine.
    }
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevState.ready != this.state.ready) {
      console.log('Parent updated'); // This is NOT called!
    }
  }

  render() {
    return (
      <div>
        <Child/>
      </div>
    )
  }
}

And Child component is:

export default class Child extends React.Component {

  constructor(props) {
    super(props);
    this.state = {ready: false};
  }

  async componentDidMount() {
    if (!this.state.ready) {
      // I'm loading some dynamic libraries here...
      // ...then set the state as ready.
      this.setState({ready: true});
    }
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevState.ready != this.state.ready) {
      console.log('Child ready'); // This is called.
    }
  }

  render() {
    if (this.state.ready) {
      return (
        <div>Loaded.</div>
      )
    } else {
      return (
        <div>Loading...</div>
      )
    }
  }
}

After run, console log produces the following lines:

Parent mounted
Child ready

My problem is that the parent is that never notified of child's ready state (componentDidMount()), neither parent's componentDidUpdate is called.

How do I notify parent's class that the child is in ready state to perform certain actions (in parent component)?

I've already tried:

  • Referencing Main with ref="child" (in index.js) to reference parent from child instance, but had an error (Function components cannot have string refs. We recommend using useRef() instead).
  • Calling super() from Child class in different ways (such as calling hook manually).
  • Used const mainRef = useRef(); or this.mainRef = useRef(); in different ways, but without success (more errors: Error: Invalid hook call).

Is there any easier way?

3 Answers

State lives on the component and is not magically shared. Thus, you need to pass the state from one component to the other.

I suggest to read the react docs, they are quite useful - https://reactjs.org/docs/getting-started.html.

But first things first - best practice for react components nowadays is to use Functional components. To understand them, best also read the docs for the useEffect hook.

Lets rewrite your components thus and start with the main.

Main

The state must live in the Main component and be passed down into the child, because they both need the state, but the Main is rendered first.

We can define state using the useState hook. As we will need both the state itself in the child as well as a function to update the state, we pass both.

function Main() {
  const [isChildReady, setIsChildReady] = useState(false);

  const updateParent = () => {
    setIsChildReady(true);
  };

  useEffect(() => {
    if (isChildReady) {
      console.log("Parent ready"); // This is NOT called!
    } else {
      console.log("Parent mounted"); // This is called fine.
    }
  }, [isChildReady]);

  return (
    <div>
      <Child isChildReady={isChildReady} setIsChildReady={updateParent} />
    </div>
  );
}

Child

The child does not need any further useState for itself, because it already gets passed everything it needs as properties.

The useEffect hook replaces the componentDidMount method and is triggered on mounting. We use it to update the state in the parent.

function Child({
  isChildReady,
  setIsChildReady,
}: {
  isChildReady: boolean,
  setIsChildReady: () => void,
}) {
  useEffect(() => {
    setIsChildReady();
  });

  if (isChildReady) {
    return <div>Loaded.</div>;
  } else {
    return <div>Loading...</div>;
  }
}

I think you've not understood React basics. In the simplest of terms, in React, data flows down from parent to children components in the form of props, and only back up via a callback (generally passed as a prop) a child component calls. Additionally, React hooks are only compatible with React function components. If you want to use a React ref in a class-based component use the createRef function (see creating refs).

Given:

export default class Main extends Child {
  constructor(props) {
    super(props);
  }

  async componentDidMount() {
    if (this.state.ready) {
      console.log('Parent ready'); // This is NOT called!
    } else {
      console.log('Parent mounted'); // This is called fine.
    }
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevState.ready != this.state.ready) {
      console.log('Parent updated'); // This is NOT called!
    }
  }

  render() {
    return (
      <div>
        <Child/>
      </div>
    )
  }
}

The parent component's this.state.ready is undefined, i.e. falsey, and never updates. This is the reason only the "Parent mounted" log from mounting is outputted. The prevState.ready value always equals the this.state.ready value, so the "Parent updated" log will never be called.

React also prefers Composition over Inheritance, the Main component should extend React.Component, not the Child component.

React components don't share state, not even when one class component extends another. The reason your code doesn't work is because your React class component is just syntactic sugar for React.createElement(component, props, ...children). In other words, your React class component get transpiled down to a vanilla Javascript function call that is instanceless.

See React without JSX.

In order for the child component to update a state/status in the parent component, it necessarily needs to be passed a callback function it can invoke. See Lifting State Up for more details.

Here's a simple example refactor of your code applying this pattern.

Parent

import React from "react";

export default class Main extends React.Component {
  state = {
    childReady: false,
  }

  componentDidMount() {
    console.log('Parent mounted');
  }

  componentDidUpdate(prevProps, prevState) {
    console.log('Parent updated');

    if (prevState.childReady !== this.state.childReady) {
      if (this.state.childReady) {
        console.log('Parent notified Child is ready');
      }
    }
  }

  setChildReady = () => this.setState({ childReady: true });

  render() {
    return (
      <div>
        <Child setReady={this.setChildReady} />
      </div>
    );
  }
}

Child

export default class Child extends React.Component {
  state = {
    ready: false,
  }

  async componentDidMount() {
    if (!this.state.ready) {
      // I'm loading some dynamic libraries here...
      // ...then set the state as ready.
      this.setState({ ready: true });
    }
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevState.ready !== this.state.ready) {
      if (this.state.ready) {
        console.log('Child ready');
        this.props.setReady();
      }
    }
  }

  render() {
    return this.state.ready
      ? <div>Loaded.</div>
      : <div>Loading...</div>;
  }
}

Edit how-to-notify-parents-component-class-using-hooks-after-the-child-is-in-ready

Example logs:

Parent mounted 
Child ready 
Parent updated 
Parent notified Child is ready 

As I've said, this is a very trivial example but it is the general basis for passing data/values/etc back up from children to parent components. You will need to tailor this pattern to suite your app's needs. For example, for more complicated state or more deeply nested children you will want to use a React Context to expose the common state and setters. If this isn't enough there are other global state management libraries like Redux (Redux Toolkit) that makes working with global state easier with dispatchable actions.

Don't fight React, lift state up

Regardless of whether you rewrite it to hooks (which you should), the answer is the same: lift state up. It's not a direct solution to your problem, rather a way to not have the problem in the first place (which I guess is also a solution? :) ).

React advises to lift state up to the component that needs it. Since the internal state of your Child component needs to be known by its parent (whether it's ready or not), this principle applies here.

There's no real reason why you'd have to initialize these dynamic libraries from within the child component. You could just as well do this in the parent component, and then create a simpler child component that takes the resulting data as props, if any.

In your case it means removing the Child's componentDidMount method, and executing its contents in the Main component's componentDidMount instead.

async componentDidMount() {
  if (!this.state.ready) {
    // I'm loading some dynamic libraries here...
    // ...then set the state as ready.
    this.setState({ready: true});
  }
}

If state is needed by a parent component, you can just move the state to the topmost component that needs it. With hooks this is particularly easy, but even with classes it's often just moving a few lines around and adding some props.

There's no point fighting React's data flow direction if it's so easy to move up your state instead. From there it can reach any inner component using props, or context if your tree is complex.

As a result the parent already knows everything it has to, and there is no more need to notify the parent when any of its children completes.

function Main() {
  const [appState, setAppState] = useState(null);
  // isReady could also be stored as state and updated in the effect.
  const isReady = appState !== null;
  
  useEffect(()=>{
    doSomeSlowTask().then(result => setAppState( doSomethingWith(result) );
  }, []);

  return <div>
    { !isReady ? <LoadingState/> : <Child state={appState}/> }
  </div>;
}

function Child({appState}) {
  return <span>Hello {appState.username}</span>;
}

function LoadingState() {
  return <span>Loading...</span>;
}

Which component to extend?

Well, none, use function components... But if you do use classes, you should only extend React's base class. I see you extended your Main component from your Child component.

export default class Main extends Child {

This in practice won't do anything more than letting it extend React.Component, because Child does. Besides sharing lifecycle method implementations. But React won't put any special link between instances of the 2 classes.

I guess you assumed this would allow the components to share state, but it doesn't.

Related