Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Object.is equality check in react hook is causing multiple versions of the same function

I'm not sure what the correct fix is to stop the following scenario. I've created this codesandbox to highlight the problem.

I have this hook and here is a scaled down version:

export const useAbortable = <T, R, N>(
  fn: () => Generator<Promise<T>, R, N>,
  options: Partial<UseAbortableOptions<N>> = {}
) => {
  const resolvedOptions = {
    ...DefaultAbortableOptions,
    ...options
  } as UseAbortableOptions<N>;
  const { initialData, onAbort } = resolvedOptions;
  const initialState = initialStateCreator<N>(initialData);
  const abortController = useRef<AbortController>(new AbortController());
  const counter = useRef(0);

  const [state, dispatch] = useReducer(reducer, initialState);

  const runnable = useMemo(
    () =>
      makeRunnable({
        fn,
        options: { ...resolvedOptions, controller: abortController.current }
      }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [counter.current]
  );

  const runner = useCallback(
    (...args: UnknownArgs) => {
      console.log(counter.current);

      dispatch(loading);

      runnable(...args)
        .then(result => {
          dispatch(success<N>(result));
        })
        .finally(() => {
          console.log("heree");
          counter.current++;
        });
    },
    [runnable]
  );

  return runner;
};

The hook takes a function and options object and as they are recreated on each render, and the hooks use Object.is comparison, it was creating a new version of the returned function no matter what I do.

So I have hacked it like this, to use a counter:

  const runnable = useMemo(
    () =>
      makeRunnable({
        fn,
        options: { ...resolvedOptions, controller: abortController.current }
      }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [counter.current]
  );

I have had to silence the linter to make this possible.

What the linter suggests is this:

  const runnable = useMemo(
    () => makeRunnable({ fn, options: { ...resolvedOptions, controller: abortController.current } }),
    [fn, resolvedOptions],
  );

But fn and resolvedOptions are causing a new runnable function to be created each time.

It is a real pain having to wrap everything in useCallback, useMemo, and friends.

I've had a look at other fetch libraries and they are doing other things like this, like JSON.stringify dependency arrays to get around this problem.

I like hooks but the Object.is equality check is killing the whole paradigm.

What would be the correct way for me to use the dependency array correctly so I don't get a new function each time and also keep the linter happy? The 2 requirements seem to add odds with each other.

like image 278
dagda1 Avatar asked May 10 '20 17:05

dagda1


People also ask

What is the one problem with using useEffect?

The infinite re-renders problem The reason our component is re-rendering is because our useEffect dependency is constantly changing. But why? We are always passing the same object to our hook! While it is true that we are passing an object with the same key and value, it is not the same object exactly.

Does useEffect use shallow comparison?

Reference is changed every time render occurs. In other words, useEffect runs even though the object value(e.g. user.name ) is still the same. useEffect uses shallow equality comparison to identify whether dependencies are updated.

What is useMemo in React Hooks?

The React useMemo Hook returns a memoized value. Think of memoization as caching a value so that it does not need to be recalculated. The useMemo Hook only runs when one of its dependencies update. This can improve performance. The useMemo and useCallback Hooks are similar.

What is referential equality in React?

Therefore, React classifies these values as equal and does not trigger a re-render. Hence, the UI does not reflect the state change. This process is called Referential Equality, for objects are considered equal based on their memory location and not the values.


Video Answer


2 Answers

You must note that you need not provide any hack to get around the callback problem

ESLint warnings about missing dependency are there to help users avoid mistakes make unknowingly and not to be enforced.

Now in your case

If you look at your useAbortable function, you are passing a generator function and an options object, now both of them are created on every re-render.

You can memoize the options and function passed to useAbortable to avoid issues with dependencies

  • If you use callback pattern for setMessages, you can create onAbort once only by providing [] dependency to useCallback

  • The generator function depends on a time delay from state so you can create it with useCallback and provide delay as a dependency

      const onAbort = useCallback(() => {
        setMessages(prevMessage => (["We have aborted", ...prevMessage]));
      }, []); // 


      const generator = useCallback(
        function*() {
          const outsideLoop = yield makeFetchRequest(delay, "outside");

          processResult(outsideLoop);

          try {
            for (const request of requests) {
              const result = yield makeFetchRequest(delay, `${request.toString()}`);

              processResult(result);
            }
          } catch (err) {
            if (err instanceof AbortError) {
              setMessages(["Aborted"]);
              return;
            }
            setMessages(["oh no we received an error", err.message]);
          }
        },
        [delay, processResult]
      );

      const options = useMemo(() => ({ onAbort }), [onAbort]);
      const { run, state, abortController, reset, counter, ...rest } = useAbortable<
        Expected,
        void,
        Expected
      >(generator, options);

Now inside useAbortable you need not worry about fn or options changing as they will only change when they absolutely have too if we implement it like above

So you your runnable instance in useAbortable can be clearly created with the correct dependencies

const resolvedOptions = useMemo(() => ({
    ...DefaultAbortableOptions,
    ...options
  }), [options]);

  const runnable = useMemo(
    () =>
      makeRunnable({
        fn,
        options: { ...resolvedOptions, controller: abortController.current }
      }),
    [fn, resolvedOptions]
  );

Working demo

like image 97
Shubham Khatri Avatar answered Oct 21 '22 23:10

Shubham Khatri


The problem is with resolvedOptions and fn and not with runnable. You should rewrite runnable dependencies as the linter suggests and modify resolvedOptions to also be a memoized.

// I assume that `DefaultAbortableOptions` is defined outside of the `useAbortable` hook
const resolvedOptions = useMemo(() => ({
  ...DefaultAbortableOptions,
  ...options
}), [options]};

The hook takes a function and options object and as they are recreated on each render, and the hooks use Object.is comparison, it was creating a new version of the returned function no matter what I do.

When you consume your hook you should also pay attention to not recreate stuff unnecessarily and use useMemo and useCallback for data and functions defined inside your components.

function MyComponent () {
  const runnable = useCallback(() => {}, [/*let the linter auto-fill this*/])
  const options = useMemo(() => ({}), [/*let the linter auto-fill this*/])
  const runner = useAbortable(runnable, options)
}

This way the runner function will only be recreated when it really needs to be (when the dynamic dependencies of runnable and options change).

You have to go all-in with hooks if you use them and really wrap everything in them to make things work without shady bugs. I personally dislike them because of these traits.

Extra note: as the React docs point out, useMemo is not guaranteed to run only when its dependencies change, it may run on any render and cause runner to be recreated. In the current version of React this does not happen but it may happen in the future.

like image 42
Bertalan Miklos Avatar answered Oct 21 '22 22:10

Bertalan Miklos