Paul Cowan Contract software developer

Solutions to frustrations with React Hooks

4 min read 1388

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>
    ))}
  </>
);

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]
  );
};

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

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;
  });

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];
};

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} />
    </>
  );

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>;
};

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>;
};

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);

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>;
};

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!

Plug: , a DVR for web apps

LogRocket is a frontend application monitoring solution 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.

.
Paul Cowan Contract software developer

One Reply to “Solutions to frustrations with React Hooks”

  1. Thanks for this follow-up, Paul. I’ve skimmed through it but need to re-read it in detail. Much appreciate your efforts on this subject matter!

Leave a Reply