DEV Community

anpos231
anpos231

Posted on

React hooks & the closure hell 2

React hooks & the closure hell 2

This is a continuation of my last post (React hooks & the closure hell)

Just a quick tl;dr

Functional components require you to regenerate all your callbacks on each re-render because there is nowhere to store them (In old class based components you could just bind your functions as method and you were good to go).

Previous solutions required you to either specify dependencies so they could be passed to existing function, or to work with objects that would store current properties and values. I think these solutions were cumbersome, so I kept tinkering and created even better solution!

Meet useCallbacks

const useCallbacks = (reinit) => {
  const data = useRef({ callbacks: {}, handlers: {} })
  const callbacks = data.current.callbacks
  const handlers = data.current.handlers

  // Generate new callbacks
  reinit(callbacks)

  // Generate new handlers if necessary
  for (let callback in callbacks) {
    if (!handlers[callback]) {
      handlers[callback] = (...args) => callbacks[callback](...args)
    }
  }

  // Return existing handlers
  return handlers
}

Usage (Try here)

const App = () => {
  const [value, setValue] = useState(1);

  const handlers = useCallbacks(callbacks => {
    callbacks.handleClick = (event) => {
      setValue(value + 1)
    }
  })

  // Check console, the state has changed so the App function will re-run
  // but memoized ExpensiveComponent won't be called because the actual handling
  // function hasn't changed.
  console.log(value)

  return (
    <div className="app">
      <ExpensiveComponent onClick={handlers.handleClick} />
      <button onClick={handlers.handleClick}>
        I will not trigger expensive re-render
      </button>
    </div>
  );
};

And that's it!
You don't have to specify any dependencies or work with messy objects.
The callback is regenerated but the actual handling function is not, so your pure components or memoized components won't re-render unnecessarily.

Everything works as hooks intended!
Tell me what you think.

Top comments (8)

Collapse
 
shiatsumat profile image
Yusuke Matsushita • Edited

Your post enlightened me a lot.
I wrote a simpler function based on your idea. On TypeScript, this style may be preferable since different handlers can have different types.
codesandbox.io/s/react-usehandler-...

/* based on https://dev.to/anpos231/react-hooks-the-closure-hell-2-58g9 */

import React, { useState, useRef, memo } from "react";
import ReactDOM from "react-dom";

import "./styles.css";

const useHandler = preHandler => {
  let ref = useRef({ altHandler: null, handler: null });
  if (!ref.current.handler) {
    ref.current.handler = (...args) => ref.current.altHandler(...args);
  }
  ref.current.altHandler = preHandler;
  return ref.current.handler;
};

let times = 0;
const ExpensiveComponent = memo(({ onClick }) => (
  <p onClick={onClick}>re-render: {times++}</p>
));

const App = () => {
  const [value, setValue] = useState(1);
  const handleClick = useHandler(_ => {
    setValue(value + 1);
  });
  /* instead of
  const handleClick = _ => {
    setValue(value + 1);
  }; 
  */
  return (
    <div className="app">
      <ExpensiveComponent onClick={handleClick} />
      <button onClick={handleClick}>increment value</button>
      <p>{value}</p>
    </div>
  );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Collapse
 
shiatsumat profile image
Yusuke Matsushita • Edited

In this particular situation, if we pass an update function to setValue instead, it suffices to simply use useCallback with an empty dependency list. (Notably, useCallback((...) => ..., []) is equivalent to useRef((...) => ...).current.)

/* based on https://dev.to/anpos231/react-hooks-the-closure-hell-2-58g9 */

import React, { useState, useRef, memo } from "react";
import ReactDOM from "react-dom";

import "./styles.css";

let times = 0;
const ExpensiveComponent = memo(({ onClick }) => (
  <p onClick={onClick}>re-render: {times++}</p>
));

const App = () => {
  const [value, setValue] = useState(1);
  const handleClick = useCallback(_ => {
    setValue(value => value + 1);
  }, []);
  return (
    <div className="app">
      <ExpensiveComponent onClick={handleClick} />
      <button onClick={handleClick}>increment value</button>
      <p>{value}</p>
    </div>
  );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Collapse
 
anpos231 profile image
anpos231

So are you saying that we can function to setValue? And it will call it with the current state? Just like the old this.setState()?

How could I miss it!

Collapse
 
stereobooster profile image
stereobooster • Edited

play with this code and see where it goes wrong


const App = () => {
  const [value, setValue] = useState(1);
  const [value1, setValue1] = useState(1);

  const handlers = useCallbacks(callbacks => {
    console.log('here')
    callbacks.handleClick = (event) => {
      setValue(value + 1)
    }
  })

  // Check console, the state has changed so the App function will re-run
  // but memoized ExpensiveComponent won't be called because the actual handling
  // function hasn't changed.
  console.log(value)

  return (
    <div className="app">
      <button onClick={handlers.handleClick}>
        I will not trigger expensive re-render
      </button>
      <button onClick={() => setValue1(value1 + 1) }>
        I will not trigger expensive re-render
      </button>
    </div>
  );
};
Collapse
 
anpos231 profile image
anpos231 • Edited

I think I am missing what you are trying to accomplish here? For me everything works as expected.

If you want to know why is console.log('here') being called? It's because callbacks are regenerated on each render, this way you can always access fresh values from your closure. But values inside the handlers object are always the same, so you are passing the same value to your components.

To visualise:
[handler] calls [callback]

[callback] changes on each render
[handler] always stays the same

[handler] is what you are passing to your descendant components.
[callback] is the function that does stuff.

Collapse
 
stereobooster profile image
stereobooster • Edited

If I will click second button, I will see here in the console e.g. function in your hook gets recreated even though state connected to it isn't changed. But I see from the comment, that this is exactly what you want ¯\_(ツ)_/¯

Collapse
 
robchristian profile image
Rob Christian • Edited

You don't need to put the handlers into your ref. I provided the following example in a reply to your previous post. You can solve this with a simple 4-line change, which will also be a lot easier for other developers to understand and work with.

const App = () => {
  const [value, setValue] = useState(1);
  const valueRef = useRef(value); // this was added
  valueRef.current = value; // this was added


  const handleClick = useCallback(
    () => {
      setValue(valueRef.current + 1) // this was updated
    },
    [], // this was updated
  );
Collapse
 
anpos231 profile image
anpos231

I see.
My solution was is definitely not the best one here.

I'd like to encourage you to check @shiatsumat answer above. It's probably the cleanest way to approach this problem, this method was even mentioned in the official react docs!

The problem with your solution is that it's not clear what valueRef.current is and how to handle it. Some developers might try setting valueRef.current directly, and be surprised that it does not work.

@shiatsumat solution allows you to write the code like this, but without valueRef. Just a single custom hook.