0

While doing a code review, I came across this custom hook:

import { useRef, useEffect, useCallback } from 'react'

export default function useLastVersion (func) {
  const ref = useRef()
  useEffect(() => {
    ref.current = func
  }, [func])
  return useCallback((...args) => {
    return ref.current(...args)
  }, [])
}

This hook is used like this:

const f = useLastVersion(() => { // do stuff and depends on props })

Basically, compared to const f = useCallBack(() => { // do stuff }, [dep1, dep2]) this avoids to declare the list of dependencies and f never changes, even if one of the dependency changes.

I don't know what to think about this code. I don't understand what are the disadvantages of using useLastVersion compared to useCallback.

Maxime Chéramy
  • 17,761
  • 8
  • 54
  • 75
  • You can just return `ref.current` this is so redundant, this `useCallback` is useless, can you ask why its there? – Dennis Vash Jan 20 '21 at 19:23
  • 1
    @DennisVash returning `ref.current` isn't the same: here we return a function that never changes whereas `ref.current` will change. – Maxime Chéramy Jan 21 '21 at 08:42
  • My question is why having a function, the ref object itself (return ref) has the same life span as the function, why having another wrapper? – Dennis Vash Jan 21 '21 at 08:47
  • Just using useCallback seems to do the same thing as you suggested – Dennis Vash Jan 21 '21 at 08:52
  • 1
    @DennisVash no it doesn't. When using the function directly, a new function is created on each render and if it's passed as props, the child component will also rerender. When using `useCallback`, the reference won't change if the dependency array doesn't change so we avoid potential rerenders on the child components. Using this `useLastVersion` hook, we push it even further because we mutate to avoid any rerender. – Maxime Chéramy Jan 21 '21 at 13:34
  • Which potential rerenders? You already have one in useEffect, do you have practical example to play with? – Dennis Vash Jan 21 '21 at 13:39
  • It's a custom hook. The rerenders would be in the component that wants to use that hook. https://codesandbox.io/s/react-playground-forked-wfpz6?file=/index.js – Maxime Chéramy Jan 21 '21 at 14:15

2 Answers2

1

That question is actually already more or less answered in the documentation: https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback

The interesting part is:

Also note that this pattern might cause problems in the concurrent mode. We plan to provide more ergonomic alternatives in the future, but the safest solution right now is to always invalidate the callback if some value it depends on changes.

Also interesting read: https://github.com/facebook/react/issues/14099 and https://github.com/reactjs/rfcs/issues/83

The current recommendation is to use a provider to avoid to pass callbacks in props if we're worried that could engender too many rerenders.

Maxime Chéramy
  • 17,761
  • 8
  • 54
  • 75
0

My point of view as stated in the comments, that this hook is redundant in terms of "how many renders you get", when there are too frequent dependencies changes (in useEffect/useCallback dep arrays), using a normal function is the best option (no overhead).

This hook hiding the render of the component using it, but the render comes from the useEffect in its parent.

If we summarize the render count we get:

  • Ref + useCallback (the hook): Render in Component (due to value) + Render in hook (useEffect), total of 2.
  • useCallback only: Render in Component (due to value) + render in Counter (change in function reference duo to value change), total of 2.
  • normal function: Render in Component + render in Counter : new function every render, total of 2.

But you get additional overhead for shallow comparison in useEffect or useCallback.

Practical example:

function App() {
  const [value, setValue] = useState("");
  return (
    <div>
      <input
        value={value}
        onChange={(e) => setValue(e.target.value)}
        type="text"
      />
      <Component value={value} />
    </div>
  );
}

function useLastVersion(func) {
  const ref = useRef();
  useEffect(() => {
    ref.current = func;
    console.log("useEffect called in ref+callback");
  }, [func]);
  return useCallback((...args) => {
    return ref.current(...args);
  }, []);
}

function Component({ value }) {
  const f1 = useLastVersion(() => {
    alert(value.length);
  });

  const f2 = useCallback(() => {
    alert(value.length);
  }, [value]);

  const f3 = () => {
    alert(value.length);
  };

  return (
    <div>
      Ref and useCallback:{" "}
      <MemoCounter callBack={f1} msg="ref and useCallback" />
      Callback only: <MemoCounter callBack={f2} msg="callback only" />
      Normal: <MemoCounter callBack={f3} msg="normal" />
    </div>
  );
}

function Counter({ callBack, msg }) {
  console.log(msg);
  return <button onClick={callBack}>Click Me</button>;
}

const MemoCounter = React.memo(Counter);

Edit Use Last Version


As a side note, if the purpose is only finding the length of input with minimum renders, reading inputRef.current.value would be the solution.

Dennis Vash
  • 50,196
  • 9
  • 100
  • 118
  • Of course, for this example we could probably do better, it was only meant to give an example. As you can see, using `useLastVersion`, instead of rerendering `Counter` (imagine that it's an heavy component to render), we only execute a very light effect. – Maxime Chéramy Jan 21 '21 at 16:09