25

I'm trying to turn from class components to functional components using the new Hooks. However it feels that with useCallback I will get unnecessary renders of children unlike with class functions in class components.

Below I have two relatively simple snippets. The first is my example written as classes, and the second is my example re-written as functional components. The goal is to get the same behaviour with functional components as with class components.

Class component test-case

class Block extends React.PureComponent {
  render() {
    console.log("Rendering block: ", this.props.color);

    return (
        <div onClick={this.props.onBlockClick}
          style = {
            {
              width: '200px',
              height: '100px',
              marginTop: '12px',
              backgroundColor: this.props.color,
              textAlign: 'center'
            }
          }>
          {this.props.text}
         </div>
    );
  }
};

class Example extends React.Component {
  state = {
    count: 0
  }
  
  
  onClick = () => {
    console.log("I've been clicked when count was: ", this.state.count);
  }
  
  updateCount = () => {
    this.setState({ count: this.state.count + 1});
  };
  
  render() {
    console.log("Rendering Example. Count: ", this.state.count);
    
    return (
      <div style={{ display: 'flex', 'flexDirection': 'row'}}>
        <Block onBlockClick={this.onClick} text={'Click me to log the count!'} color={'orange'}/>
        <Block onBlockClick={this.updateCount} text={'Click me to add to the count'} color={'red'}/>
      </div>
    );
  }
};

ReactDOM.render(<Example/>, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>

<div id='root' style='width: 100%; height: 100%'>
</div>

Functional component test-case

const Block = React.memo((props) => {
  console.log("Rendering block: ", props.color);
  
  return (
      <div onClick={props.onBlockClick}
        style = {
          {
            width: '200px',
            height: '100px',
            marginTop: '12px',
            backgroundColor: props.color,
            textAlign: 'center'
          }
        }>
        {props.text}
       </div>
  );
});

const Example = () => {
  const [ count, setCount ] = React.useState(0);
  console.log("Rendering Example. Count: ", count);
  
  const onClickWithout = React.useCallback(() => {
    console.log("I've been clicked when count was: ", count);
  }, []);
  
  const onClickWith = React.useCallback(() => {
    console.log("I've been clicked when count was: ", count);
  }, [ count ]);
  
  const updateCount = React.useCallback(() => {
    setCount(count + 1);
  }, [ count ]);
  
  return (
    <div style={{ display: 'flex', 'flexDirection': 'row'}}>
      <Block onBlockClick={onClickWithout} text={'Click me to log with empty array as input'} color={'orange'}/>
      <Block onBlockClick={onClickWith} text={'Click me to log with count as input'} color={'cyan'}/>
      <Block onBlockClick={updateCount} text={'Click me to add to the count'} color={'red'}/>
    </div>
  );
};

ReactDOM.render(<Example/>, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>

<div id='root' style='width: 100%; height: 100%'>
</div>

In the first one(class components) I can update the count via the red block without re-rendering either of the blocks, and I can freely console log the current count via the orange block.

In the second one(functional components) updating the count via the red-block will trigger a re-render of both the red and cyan block. This is because the useCallback will make a new instance of it's function because the count has changed, causing the blocks to get a new onClick prop and thus re-render. The orange block won't re-render because the useCallback used for the orange onClick does not depend on the count value. This would be good but the orange block will not show the actual value of the count when you click on it.

I thought the point of having useCallback was so that children don't get new instances of the same function and don't have unnecessary re-renders, but that seems to happen anyways the second the callback function uses a single variable which happens quite often if not always from my experience.

So how would I go about making this onClick function within a functional component without having the children re-render? Is it at all possible?

Update (solution): Using Ryan Cogswell's answer below I've crafted a custom hook to make creating class-like functions easily.

const useMemoizedCallback = (callback, inputs = []) => {
    // Instance var to hold the actual callback.
    const callbackRef = React.useRef(callback);
    
    // The memoized callback that won't change and calls the changed callbackRef.
    const memoizedCallback = React.useCallback((...args) => {
      return callbackRef.current(...args);
    }, []);

    // The callback that is constantly updated according to the inputs.
    const updatedCallback = React.useCallback(callback, inputs);

    // The effect updates the callbackRef depending on the inputs.
    React.useEffect(() => {
        callbackRef.current = updatedCallback;
    }, inputs);

    // Return the memoized callback.
    return memoizedCallback;
};

I can then use this in the function component very easily like so and simply pass the onClick to the child. It will no longer re-render the child but still make use of updated vars.

const onClick = useMemoizedCallback(() => {
    console.log("NEW I've been clicked when count was: ", count);
}, [count]);
ApplePearPerson
  • 4,209
  • 4
  • 21
  • 36

2 Answers2

31

useCallback will avoid unnecessary child re-renders due to something changing in the parent that is not part of the dependencies for the callback. In order to avoid child re-renders when the callback's dependencies are involved, you need to use a ref. Ref's are the hook equivalent to an instance variable.

Below I have onClickMemoized using the onClickRef which points at the current onClick (set via useEffect) so that it delegates to a version of the function that knows the current value of the state.

I also changed updateCount to use the functional update syntax so that it doesn't need to have a dependency on count.

const Block = React.memo(props => {
  console.log("Rendering block: ", props.color);

  return (
    <div
      onClick={props.onBlockClick}
      style={{
        width: "200px",
        height: "100px",
        marginTop: "12px",
        backgroundColor: props.color,
        textAlign: "center"
      }}
    >
      {props.text}
    </div>
  );
});

const Example = () => {
  const [count, setCount] = React.useState(0);
  console.log("Rendering Example. Count: ", count);

  const onClick = () => {
    console.log("I've been clicked when count was: ", count);
  };
  const onClickRef = React.useRef(onClick);
  React.useEffect(
    () => {
      // By leaving off the dependency array parameter, it means that
      // this effect will execute after every committed render, so
      // onClickRef.current will stay up-to-date.
      onClickRef.current = onClick;
    }
  );

  const onClickMemoized = React.useCallback(() => {
    onClickRef.current();
  }, []);

  const updateCount = React.useCallback(() => {
    setCount(prevCount => prevCount + 1);
  }, []);

  return (
    <div style={{ display: "flex", flexDirection: "row" }}>
      <Block
        onBlockClick={onClickMemoized}
        text={"Click me to log with empty array as input"}
        color={"orange"}
      />
      <Block
        onBlockClick={updateCount}
        text={"Click me to add to the count"}
        color={"red"}
      />
    </div>
  );
};

ReactDOM.render(<Example />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>

<div id='root' style='width: 100%; height: 100%'>
</div>

And, of course, the beauty of hooks is that you can factor out this stateful logic into a custom hook:

import React from "react";
import ReactDOM from "react-dom";

const Block = React.memo(props => {
  console.log("Rendering block: ", props.color);

  return (
    <div
      onClick={props.onBlockClick}
      style={{
        width: "200px",
        height: "100px",
        marginTop: "12px",
        backgroundColor: props.color,
        textAlign: "center"
      }}
    >
      {props.text}
    </div>
  );
});

const useCount = () => {
  const [count, setCount] = React.useState(0);

  const logCount = () => {
    console.log("I've been clicked when count was: ", count);
  };
  const logCountRef = React.useRef(logCount);
  React.useEffect(() => {
    // By leaving off the dependency array parameter, it means that
    // this effect will execute after every committed render, so
    // logCountRef.current will stay up-to-date.
    logCountRef.current = logCount;
  });

  const logCountMemoized = React.useCallback(() => {
    logCountRef.current();
  }, []);

  const updateCount = React.useCallback(() => {
    setCount(prevCount => prevCount + 1);
  }, []);
  return { count, logCount: logCountMemoized, updateCount };
};
const Example = () => {
  const { count, logCount, updateCount } = useCount();
  console.log("Rendering Example. Count: ", count);

  return (
    <div style={{ display: "flex", flexDirection: "row" }}>
      <Block
        onBlockClick={logCount}
        text={"Click me to log with empty array as input"}
        color={"orange"}
      />
      <Block
        onBlockClick={updateCount}
        text={"Click me to add to the count"}
        color={"red"}
      />
    </div>
  );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<Example />, rootElement);

Edit useCallback and useRef

Ryan Cogswell
  • 75,046
  • 9
  • 218
  • 198
  • Thank you for the answer. I do find it pretty tedious that what can be done in a class component with a singular function would in this functional component take the actual function, 1 ref to store the function in an instance var, 1 memoized function to pass to the child, and 1 `useEffect` to update the instance var. Before I accept this as answer I was wondering if you think it's possible to put these 4 in a custom hook so it remains a simple singular function call? – ApplePearPerson Mar 07 '19 at 15:50
  • Yes, that's where the beauty of hooks shines. Answer updated. – Ryan Cogswell Mar 07 '19 at 16:01
  • It looks beautiful, the sandbox has an error though but I can definitely work with this! – ApplePearPerson Mar 07 '19 at 16:02
  • 1
    I forgot to save the latest version of my sandbox -- link updated. – Ryan Cogswell Mar 07 '19 at 16:03
  • If you are using eslint for the hook. You will see that logCount function is required wrapped in useCallback with count parameter. – Long Nguyen May 25 '20 at 11:36
  • @LongNguyen Thanks, I have addressed the eslint warning by removing the dependency array on the `useEffect` which sets the ref. – Ryan Cogswell May 25 '20 at 15:40
  • Why not put `logCountRef.current = logCount;` outside of `useEffect`? – mowwwalker Aug 13 '21 at 17:03
  • 1
    @mowwwalker Because render shouldn't have any side effects (such as setting a ref) since the render might not be committed; whereas the effect only gets executed if the render has been committed to the DOM. – Ryan Cogswell Aug 13 '21 at 17:09
  • @RyanCogswell that makes sense, thanks! Any recommended reading on that? – mowwwalker Aug 13 '21 at 17:11
  • 1
    @mowwwalker I've read about it in numerous places, but I don't have a good reference to point you to. Articles about preparing for StrictMode and concurrent rendering will touch on it. I follow several of the React team on Twitter and have seen some related conversations there (e.g. search Twitter for "@sebmarkbage ref"). – Ryan Cogswell Aug 13 '21 at 17:41
  • wow.. this is awesome. thank you for saving my life LOL – cakpep Nov 24 '21 at 15:56
1

This works too with minimal change in the current code.

  • The deps parameters from useCallback is removed
  • update the ref value when state changes
  • Instead of using the value from the state use it from ref inside useCallback block since the deps is removed and the state value will not be update.

const {useState} = React

const Block = React.memo((props) => {
  console.log("Rendering block: ", props.color);
  
  return (
      <div onClick={props.onBlockClick}
        style = {
          {
            width: '200px',
            height: '100px',
            marginTop: '12px',
            backgroundColor: props.color,
            textAlign: 'center'
          }
        }>
        {props.text}
       </div>
  );
});

const Example = () => {
  const [ count, setCount ] = useState(0);
  
  const countRef = React.useRef(count);
  console.log("Rendering Example. Count: ", count);
  
  const onClickWithout = React.useCallback(() => {
    console.log("I've been clicked when count was: ", count, countRef.current);
  }, []);
  
  const onClickWith = React.useCallback(() => {
    console.log("I've been clicked when count was: ", count, countRef.current);
  }, [ ]);
  
  const updateCount = React.useCallback(() => {
    setCount(count => { 
      countRef.current = count+1
      return count + 1 
    });
  }, [ ]);
  
  return (
    <div style={{ display: 'flex', 'flexDirection': 'row'}}>
      <Block onBlockClick={onClickWithout} text={'Click me to log with empty array as input'} color={'orange'}/>
      <Block onBlockClick={onClickWith} text={'Click me to log with count as input'} color={'cyan'}/>
      <Block onBlockClick={updateCount} text={'Click me to add to the count'} color={'red'}/>
    </div>
  );
};


ReactDOM.render( <Example /> , document.getElementById("react"));
//ReactDOM.render( < Example / > , document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="react"></div>