Solutions to frustrations with React Hooks

bnevilleoneill

Brian Neville-O'Neill

Posted on November 4, 2019

Solutions to frustrations with React Hooks

Written by Paul Cowan✏️

One of my previous posts, Frustrations with React Hooks, got an incredible amount of views and topped hacker news at one point. The post also got lots of comments, some of which have changed how I view Hooks and given me a completely new and positive way of viewing them.

The last post cited a useFetch example that abstracts away the common code for calling a remote API endpoint. A fetch abstraction is the sort of thing I expect to be made reusable by Hooks. I want loading and error states all wrapped up in a Hook just like we used to do with Redux middleware. Below is an example of what I want to write for the client code:

const asyncTask = useFetch(initialPage);
useAsyncRun(asyncTask);

const { start, loading, result: users } = asyncTask;

if (loading) {
  return <div>loading....</div>;
}

return (
  <>
    {(users || []).map((u: User) => (
      <div key={u.id}>{u.name}</div>
    ))}
  </>
);
Enter fullscreen mode Exit fullscreen mode

I cited an example based on react-hooks-async which has a useFetch Hook.

Here is a CodeSandbox containing the scaled-down example:

And here is a code listing:

const createTask = (func, forceUpdateRef) => {
  const task = {
    start: async (...args) => {
      task.loading = true;
      task.result = null;
      forceUpdateRef.current(func);
      try {
        task.result = await func(...args);
      } catch (e) {
        task.error = e;
      }
      task.loading = false;
      forceUpdateRef.current(func);
    },
    loading: false,
    result: null,
    error: undefined
  };
  return task;
};

export const useAsyncTask = (func) => {
  const forceUpdate = useForceUpdate();
  const forceUpdateRef = useRef(forceUpdate);
  const task = useMemo(() => createTask(func, forceUpdateRef), [func]);

  useEffect(() => {
    forceUpdateRef.current = f => {
      if (f === func) {
        forceUpdate({});
      }
    };
    const cleanup = () => {
      forceUpdateRef.current = () => null;
    };
    return cleanup;
  }, [func, forceUpdate]);

  return useMemo(
    () => ({
      start: task.start,
      loading: task.loading,
      error: task.error,
      result: task.result
    }),
    [task.start, task.loading, task.error, task.result]
  );
};
Enter fullscreen mode Exit fullscreen mode

Many comments mentioned the complexity of this approach and the most telling comments mentioned that this implementation is not very declarative.

LogRocket Free Trial Banner

Hooks are for reusable lifecycle behavior

Without question, the best comment in the comments section was from Karen Grigoryan who pointed out that Hooks are a place for reusable lifecycle behavior.

react-hooks-async and the example in the CodeSandbox uses the useAsyncRun function to kick start the lifecycle change event:

export const useAsyncRun = (asyncTask,...args) => {
  const { start } = asyncTask;
  useEffect(() => {
    start(...args);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [asyncTask.start, ...args]);

useEffect(() => {
    const cleanup = () => {
      // clean up code here
    };
    return cleanup;
  });
Enter fullscreen mode Exit fullscreen mode

React is often touted as being a declarative framework, and one of the reasons I fell in love with React is the one-way data flow story. useAsyncRun feels more imperative than declarative.

The tao of React

How React works best is that we change props or state, and a component reacts naturally.

Karen kindly created this CodeSandbox that not only simplifies things but also makes things feel much more reacty (yes this is now an actual word) and declarative:

useFetch now looks like this:

const fetchReducer: FetchReducer = (state, action) => {
  switch (action.type) {
    case "FETCH_START": {
      return { data: null, isLoading: true, error: null };
    }
    case "FETCH_SUCCESS": {
      return { data: action.payload, isLoading: false, error: null };
    }

    case "FETCH_ERROR": {
      return { data: null, isLoading: false, error: action.payload };
    }
    default:
      return state;
  }
};

export const useFetch = (initial) => {
  const [state, dispatch] = useReducer(fetchReducer, initialState);

  const getFetchResult = useCallbackOne(
    async (overrides) => {
      dispatch({ type: "FETCH_START" });
      try {
        const result = await api({ ...initial, ...overrides });
        dispatch({ type: "FETCH_SUCCESS", payload: (result as unknown) as T });
      } catch (err) {
        dispatch({ type: "FETCH_ERROR", payload: err });
      }
    },
    [initial]
  );

  return [state, getFetchResult];
};
Enter fullscreen mode Exit fullscreen mode

The useFetch Hook in the above code returns a getFetchResult function. getFetchResult uses the dispatch function that is returned from useReducer to orchestrate lifecycle changes.

Using useState and useReducer is what we use for triggering changes in effects but in a declarative way. Forcing a re-render is swimming upstream in React and going against the declarative nature of React. I think I have yet again fallen in love with React’s one-way data flow. The one-way data flow is what drew me to React, and it still tames the chaos out of a heavy JavaScript application.

React is supposed to work this way, we change state, and the component knows how to re-render and the useEffect blocks of code are executed in response to state changes.

The client code now looks like this:

const [fetchResult, getfetchResult] = useFetch<User[]>(initialPage);

  const { data: users, isLoading, error } = fetchResult;

  // to keep reference identity in tact until next remount
  const defaultUsersRef = useRef<User[]>([]);

  // to kick off initial request
  useEffect(() => {
    getfetchResult(initialPage);
  }, [getfetchResult]);

  if (isLoading) {
    return <div>loading....</div>;
  }

  if (error) {
    return <div>error : {JSON.stringify(error)}</div>;
  }

  return (
    <>
      <Users users={users || defaultUsersRef.current} />
      <Knobs onClick={getfetchResult} />
    </>
  );
Enter fullscreen mode Exit fullscreen mode

getFetchResult can now be used in a useEffect when the component is first mounted and also in an event handler.

A big thank you to Karen for this great example.

It is also worth noting that suspense might be dropping soon and this might be the real fit for a useFetch solution.

The observant of you will have noticed that the getFetchResult uses useCallbackOne from use-memo-one. useCallbackOne is a safe alternative to useCallback. useCallbackOne does a shallow check on the values of the dependency array and not the array references. This is still a frustration with React Hooks that we need an external library for this, which brings us on nicely to the stale closure problem.

The stale closure problem

I’ve always had a fear of closures due to weird and not so wonderful things happening when dealing with closures. Closures are a fact of life when dealing with Hooks. Below is an example that illustrates this phenomenon beautifully:

const useInterval = (callback, delay) => {
  useEffect(() => {
    let id = setInterval(() => {
    callback();
  }, 1000);
    return () => clearInterval(id);
  }, []);
};

const App = () => {
 let [count, setCount] = useState(0);

 useInterval(() => setCount(count + 1), 1000);

 return <h1>{count}</h1>;
};
Enter fullscreen mode Exit fullscreen mode

This CodeSandbox shows this great evil in action:

What happens is that useEffect in the useInterval Hook captures the count from the first render with the initial value, which is 0. The useEffect has an empty dependency array which means it is never re-applied and always reference 0 from the first render and the calculation is always 0 + 1.

If you want to use useEffect well, you need to ensure that the dependency array includes any values from the outer scope that changes over time and are used by the effect.

The react-hooks/exhaustive-deps linting rule does, for the most part, a good job of highlighting the missing dependencies and it rightly points out that callback is missing in the array passed as a second argument to useEffect:

const useInterval = (callback, delay) => {
  useEffect(() => {
    let id = setInterval(() => {
      callback();
    }, delay);

    return () => clearInterval(id);
  }, [callback, delay]);
};

const App = () => {
  let [count, setCount] = useState(0);

  useInterval(() => setCount(count + 1), 1000);

  return <h1>{count}</h1>;
};
Enter fullscreen mode Exit fullscreen mode

The problem we have is that the callback passed to useInterval is an arrow function which means it is recreated on each render:

useInterval(() => setCount(count + 1), 1000);
Enter fullscreen mode Exit fullscreen mode

One solution to stale closures

Dan Abramov made a case for storing the callback in a mutable ref in this post.

I have seen the same solution appearing in several packages in various guises based on this theme of storing the callback in a mutable ref. I am taking my example from formik which provides a useEventCallback Hook that takes care of storing the callback in a mutable Hook.

function useEventCallback(fn) {
  const ref = React.useRef(fn);

  useEffect(() => {
    ref.current = fn;
  });

  return React.useCallback(
    (...args) => ref.current.apply(void 0, args),
    []
  );
}

function useInterval(callback, delay) {
  const savedCallback = useEventCallback(callback);

  useEffect(() => {
    function tick() {
      savedCallback();
    }

    let id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]);
}

const App = () => {
  let [count, setCount] = useState(0);

  useInterval(() => {
    setCount(count + 1);
  }, 1000);

  return <h1>{count}</h1>;
};
Enter fullscreen mode Exit fullscreen mode

Storing the callback in a mutable ref means the latest callback can be saved in the ref on each render.

This CodeSandbox shows useEventCallback in action:

Conclusion

Hooks are a mind shift, and I think we need to realign our thinking. I was not looking at what they have to offer without wearing React spectacles. Hooks fit nicely into React’s declarative nature, and I think they are a great abstraction where state changes and components know how to react to the state change. Tremendous!


Editor's note: Seeing something wrong with this post? You can find the correct version here.

Plug: LogRocket, a DVR for web apps

 
LogRocket Dashboard Free Trial Banner
 
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
 
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
 
Try it for free.


The post Solutions to frustrations with React Hooks appeared first on LogRocket Blog.

💖 💪 🙅 🚩
bnevilleoneill
Brian Neville-O'Neill

Posted on November 4, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related