JasonGenX
JasonGenX

Reputation: 5434

React - How to detect when all sub-components of a parent component are visible to the user?

TL;DR

How can a parent component know when the rendering of every child component under it has finished and the DOM visible to the user with its most up to date version?

Let's say I have a component A that has a child Grid component consisting of 3x3 grandchild components. Each of those grandchild components fetches data from a restful API endpoint and renders itself when the data becomes available.

I would like to cover the entire area of Component A with a loader placeholder, to be unveiled only when the last of the components in the grid has fetched the data successfully, and rendered it, such that it's already on the DOM and can be viewed.

The user experience should be a super smooth transition from "loader" to a fully populated grid without flickering.

My problem is knowing exactly when to unveil the components under the loader.

Is there any mechanism I can rely on to do this with absolute accuracy? I don't to hard code a time limit for the loader. As I understand relying on ComponentDidMount for every child is also unreliable as it doesn't actually guarantee the component is fully visible to the user at the time of the call.

To distill the question even further:

I have a component that renders some kind of data. After it's initialized it doesn't have it, so in its componentDidMount it hits an API endpoint for it. Once it receives the data, it's changing its state to reflect it. This understandably causes a re-render of the final state of that component. My question is this: How do I know when that re-render has taken place and is reflected in the User facing DOM. That point in time != the point in time when the component's state has changed to contain data.

Upvotes: 18

Views: 14428

Answers (6)

Ravi Chaudhary
Ravi Chaudhary

Reputation: 670

There are multiple approaches you can go with:

  1. Easiest way is to pass a callback to child component. These child components can then call this callback as soon as they have rendered after fetching their individual data or any other business logic you want to put. Here is a sandbox for the same: https://codesandbox.io/s/unruffled-shockley-yf3f3
  2. Ask the dependent/child components to update when they are done with rendering fetched data to a global app state which your component would be listening to
  3. You can also use React.useContext to create localised state for this parent component. Child components can then update this context when they are done with rendering fetched data. Again, parent component would be listening to this context and can act accordingly

Upvotes: 1

keikai
keikai

Reputation: 15146

First of all, life-cycle could have async/await method.

What we need to know about componentDidMount and componentWillMount,

componentWillMount is called first parent then child.
componentDidMount do the opposite.

In my consideration, simply implement them using normal life-cycle would be good enough.

  • child component
async componentDidMount() {
  await this.props.yourRequest();
  // Check if satisfied, if true, add flag to redux store or something,
  // or simply check the res stored in redux in parent leading no more method needed here
  await this.checkIfResGood();
}
  • parent component
// Initial state `loading: true`
componentDidUpdate() {
  this.checkIfResHaveBad(); // If all good, `loading: false`
}
...
{this.state.loading ? <CircularProgress /> : <YourComponent/>}

Material-UI CircularProgress

Since child re-rendering causing parent to do the same, catch it in didUpdate would be fine.
And, since it's called after the rendering, the pages shouldn't be changed after that if you set the check function as your demand.

We use this implementation in our prod which has an api causing 30s to get the response, all worked well as far as I see.

enter image description here

Upvotes: 1

Andrew Sinner
Andrew Sinner

Reputation: 1881

There are two lifecycle hooks in React that are called after a component's DOM has rendered:

For your use case your parent component P is interested when N child components have each satisfied some condition X. X can be defined as a sequence:

  • async operation completed
  • component has rendered

By combining the state of the component and using the componentDidUpdate hook, you can know when the sequence has completed and your component meets condition X.

You can keep track of when your async operation has completed by setting a state variable. For example:

this.setState({isFetched: true})

After setting state, React will call your components componentDidUpdate function. By comparing the current and previous state objects within this function you can signal to the parent component that your async operation has completed and your new component's state has rendered:

componentDidUpdate(_prevProps, prevState) {
  if (this.state.isFetched === true && this.state.isFetched !== prevState.isFetched) {
    this.props.componentHasMeaningfullyUpdated()
  }
}

In your P component, you can use a counter to keep track of how many children have meaningfully updated:

function onComponentHasMeaningfullyUpdated() {
  this.setState({counter: this.state.counter + 1})
}

Finally, by knowing the length of N you can know when all meaningful updates have occurred and act accordingly in your render method of P:

const childRenderingFinished = this.state.counter >= N

Upvotes: 8

Dan
Dan

Reputation: 10538

You could potentially solve this using React Suspense.

The caveat is that it's not a good idea to Suspend past the component tree that does the render (that is: If your component kicks off a render process, it's not a good idea to have that component suspend, in my experience), so it's probably a better idea to kick the requests off in the component that renders the cells. Something like this:

export default function App() {
  const cells = React.useMemo(
    () =>
      ingredients.map((_, index) => {
        // This starts the fetch but *does not wait for it to finish*.
        return <Cell resource={fetchIngredient(index)} />;
      }),
    []
  );

  return (
    <div className="App">
      <Grid>{cells}</Grid>
    </div>
  );
}

Now, quite how Suspense pairs with Redux I'm not sure. The whole idea behind this (experimental!) version of Suspense is that you start the fetch immediately during the render cycle of a parent component and pass an object that represents a fetch to the children. This prevents you having to have some kind of Barrier object (which you would need in other approaches).

I will say that I don't think waiting until everything has fetched to display anything is the right approach because then the UI will be as slow as the slowest connection or may not work at all!

Here's the rest of the missing code:

const ingredients = [
  "Potato",
  "Cabbage",
  "Beef",
  "Bok Choi",
  "Prawns",
  "Red Onion",
  "Apple",
  "Raisin",
  "Spinach"
];

function randomTimeout(ms) {
  return Math.ceil(Math.random(1) * ms);
}

function fetchIngredient(id) {
  const task = new Promise(resolve => {
    setTimeout(() => resolve(ingredients[id]), randomTimeout(5000));
  });

  return new Resource(task);
}

// This is a stripped down version of the Resource class displayed in the React Suspense docs. It doesn't handle errors (and probably should).
// Calling read() will throw a Promise and, after the first event loop tick at the earliest, will return the value. This is a synchronous-ish API,
// Making it easy to use in React's render loop (which will not let you return anything other than a React element).
class Resource {
  constructor(promise) {
    this.task = promise.then(value => {
      this.value = value;
      this.status = "success";
    });
  }

  read() {
    switch (this.status) {
      case "success":
        return this.value;

      default:
        throw this.task;
    }
  }
}

function Cell({ resource }) {
  const data = resource.read();
  return <td>{data}</td>;
}

function Grid({ children }) {
  return (
    // This suspense boundary will cause a Loading sign to be displayed if any of the children suspend (throw a Promise).
    // Because we only have the one suspense boundary covering all children (and thus Cells), the fallback will be rendered
    // as long as at least one request is in progress.
    // Thanks to this approach, the Grid component need not be aware of how many Cells there are.
    <React.Suspense fallback={<h1>Loading..</h1>}>
      <table>{children}</table>
    </React.Suspense>
  );
}

And a sandbox: https://codesandbox.io/s/falling-dust-b8e7s

Upvotes: 2

Seth Lutske
Seth Lutske

Reputation: 10686

I would set it up so that you're relying a global state variable to tell your components when to render. Redux is better for this scenario where many components are talking to each other, and you mentioned in a comment that you use it sometimes. So I'll sketch out an answer using Redux.

You'd have to move your API calls to the parent container, Component A. If you are wanting to have your grandchildren render only after the API calls complete, you can't keep those API calls in the grandchildren themselves. How can an API call be made from a component that doesn't exist yet?

Once all the API calls are made, you can use actions to update a global state variable containing a bunch of data objects. Every time data is recieved (or an error is caught), you can dispatch an action to check if your data object is fully filled out. Once its completely filled out, you can update a loading variable to false, and conditionally render your Grid component.

So for example:

// Component A

import { acceptData, catchError } from '../actions'

class ComponentA extends React.Component{

  componentDidMount () {

    fetch('yoururl.com/data')
      .then( response => response.json() )
      // send your data to the global state data array
      .then( data => this.props.acceptData(data, grandChildNumber) )
      .catch( error => this.props.catchError(error, grandChildNumber) )

    // make all your fetch calls here

  }

  // Conditionally render your Loading or Grid based on the global state variable 'loading'
  render() {
    return (
      { this.props.loading && <Loading /> }
      { !this.props.loading && <Grid /> }
    )
  }

}


const mapStateToProps = state => ({ loading: state.loading })

const mapDispatchToProps = dispatch => ({ 
  acceptData: data => dispatch( acceptData( data, number ) )
  catchError: error=> dispatch( catchError( error, number) )
})
// Grid - not much going on here...

render () {
  return (
    <div className="Grid">
      <GrandChild1 number={1} />
      <GrandChild2 number={2} />
      <GrandChild3 number={3} />
      ...
      // Or render the granchildren from an array with a .map, or something similar
    </div>
  )
}
// Grandchild

// Conditionally render either an error or your data, depending on what came back from fetch
render () {
  return (
    { !this.props.data[this.props.number].error && <Your Content Here /> }
    { this.props.data[this.props.number].error && <Your Error Here /> }
  )
}

const mapStateToProps = state => ({ data: state.data })

Your reducer willhold the global state object which will say if things are all ready to go yet or not:

// reducers.js

const initialState = {
  data: [{},{},{},{}...], // 9 empty objects
  loading: true
}

const reducers = (state = initialState, action) {
  switch(action.type){

    case RECIEVE_SOME_DATA:
      return {
        ...state,
        data: action.data
      }

     case RECIEVE_ERROR:
       return {
         ...state,
         data: action.data
       }

     case STOP_LOADING:
       return {
         ...state,
         loading: false
       }

  }
}

In your actions:


export const acceptData = (data, number) => {
  // First revise your data array to have the new data in the right place
  const updatedData = data
  updatedData[number] = data
  // Now check to see if all your data objects are populated
  // and update your loading state:
  dispatch( checkAllData() )
  return {
    type: RECIEVE_SOME_DATA,
    data: updatedData,
  }
}

// error checking - because you want your stuff to render even if one of your api calls 
// catches an error
export const catchError(error, number) {
  // First revise your data array to have the error in the right place
  const updatedData = data
  updatedData[number].error = error
  // Now check to see if all your data objects are populated
  // and update your loading state:
  dispatch( checkAllData() )
  return {
    type: RECIEVE_ERROR,
    data: updatedData,
  }
}

export const checkAllData() {
  // Check that every data object has something in it
  if ( // fancy footwork to check each object in the data array and see if its empty or not
    store.getState().data.every( dataSet => 
      Object.entries(dataSet).length === 0 && dataSet.constructor === Object ) ) {
        return {
          type: STOP_LOADING
        }
      }
  }

Aside

If you are really married to the idea that your API calls live inside each grandchild, but that the whole Grid of grandchildren does not render until all API calls are completed, you'd have to use a completely different solution. In this case, your grandchildren would have to be rendered from the start to make their calls, but have a css class with display: none, which only changes after the global state variable loading is marked as false. This is also doable, but sort of besides the point of React.

Upvotes: 1

Pranay Tripathi
Pranay Tripathi

Reputation: 1882

As you are making an api call in componentDidMount, when the API call will resolve you will have the data in componentDidUpdate as this lifecycle will pass you prevProps and prevState. So you can do something like below:

class Parent extends Component {
  getChildUpdateStatus = (data) => {
 // data is any data you want to send from child
}
render () {
 <Child sendChildUpdateStatus={getChildUpdateStatus}/>
}
}

class Child extends Component {
componentDidUpdate = (prevProps, prevState) => {
   //compare prevProps from this.props or prevState from current state as per your requirement
  this.props.sendChildUpdateStatus();
}

render () { 
   return <h2>{.. child rendering}</h2>
  }
}

If you want to know this before the component re renders, you can use getSnapshotBeforeUpdate.

https://reactjs.org/docs/react-component.html#getsnapshotbeforeupdate.

Upvotes: 0

Related Questions