Skip to content

Better explanations for opting out of a render cycle #3678

Description

@noahgrant

Hello!

So I've been thinking about something in React for a long time, and I think I have a good opportunity now to explain. Imagine I have an <ExpensiveComponent /> that I want to keep from rendering unnecessarily. I have two options:

  1. sCU, via classes. Here I can directly compare this.props and nextProps for each render cycle:
shouldComponentUpdate(nextProps) {
   // compare current props and the very next set of props and return false, if necessary
}
  1. The areEqual function passed to React.memo, via function components. This is also the only method I can use if I am using hooks. However, I can't always directly compare current props with next props. Every time I opt out of a render cycle, my 'current props' become stale, making it very difficult to make comparisons that track my normal data flow:
// cycle 1 — comparing props1 and props2 are prevProps and nextProps, as currently listed in the docs
memo(<ExpensiveComponent, (props1, props2) => return true;);

// cycle 2 !!! — !!! comparing props1 and props3 are not prevProps/nextProps! props1 does not get updated to
// props2! comparison is thus much more difficult to make
memo(<ExpensiveComponent, (props1, props3) => return ...);

This part I think is a documentation issue, but I've been struggling with this idea for awhile now, because opting out of successive render cycles is very difficult. Let's say we have a props-driven resource request and no memoization:

// pseudo-ish-code
function Parent() {
  var {hasLoaded, isLoading, myData} = useResources(resources, props);

  return <ExpensiveComponent {...props} />;
}

As props change to trigger an additional resource request, we have to go through two render cycles:

Render Cycles:
1. props change
2. useResources, via a `useEffect` hook that occurs _after_ the first render, makes the request and 
    changes `isLoading` to true

With no memoization, ExpensiveComponent is rendered twice every time we fetch new data. But with memoization, it's still pretty difficult to keep ExpensiveComponent from rendering both times since, because of the two render cycles, we can't solely rely on comparing prevProps.isLoading with nextProps.isLoading the way we could with shouldComponentUpdate:

MemoizedExpensiveComponent = memo(<ExpensiveComponent />, areEqual);

// areEqual pass 1. our loading states don't change, so we can't use them to prevent a render
(props1, props2) => {
  console.log(props1.isLoading); // false
  console.log(props2.isLoading); // false

  // both are false, and so ExpensiveComponent will still render
  return !props1.isLoading && props2.isLoading;
}

// areEqual pass 2:
(props2, props3) => {
  console.log(props2.isLoading); // false
  console.log(props3.isLoading); // true
  
  // here we will keep ExpensiveComponent from rendering
  return !props2.isLoading && props3.isLoading;
}

For the first cycle, we can compare the changed props that triggered the data request in order to opt-out of the first render, but we have to be careful, because as mentioned previously, we still get props1 in the second areEqual call:

MemoizedExpensiveComponent = memo(<ExpensiveComponent />, areEqual);

// let's say we are fetching new data because the value of `props.query` changed from `'foo'` to `'bar'`.

// areEqual pass 1. compare the value of props.query
(props1, props2) => {
  console.log(props1.query); // 'foo'
  console.log(props2.query); // 'bar'

  // they are unequal, and so ExpensiveComponent will not render this time!
  return !props1.query && props2.query;
}

// areEqual pass 2:
(props1, props3) => {
  console.log(props1.isLoading); // false
  console.log(props3.isLoading); // true
 
  // also here our loading states are not equal, so now we keep ExpensiveComponent from rendering this time, too!!
  // except...
  console.log(props1.query); // 'foo'
  // womp. be careful of this, because now our 'prevProps' is actually not in sync with our data flow. 
 
 return !props1.isLoading && props3.isLoading; 
}

This is difficult to do in a generalized way, but it is what we ended up doing for the resourcerer library that I work on. And it makes me wonder why, from an API perspective, React ever got rid of componentWillReceiveProps (and never offered an equivalent for hooks)? With cWRP, none of this was an issue, because we only ever had a single render cycle for a new data fetch:

Render Cycles:
1. props change, `isLoading` state is set to true in componentWillReceiveProps prior to render

Here, <ExpensiveComponent /> is at most rendered once per new data fetch, and preventing that render also becomes much easier because we only ever need to compare loading states.

Thank you very much for reading; I know this was long. But I would appreciate any insights into why we can't bring in a 'cWRP-like shortcut' for hooks for this sort of thing, and if I'm missing anything that renders my argument baseless, I really apologize. Otherwise, this is a long post to both (a) see if you would agree that the implementation of areEqual could benefit from more detailed documentation and (b) prompt a discussion about the still-useful merits of componentWillReceiveProps for preventing extra renders.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions