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.
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.
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.
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);
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:
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!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
One Reply to "Solutions to frustrations with React Hooks"
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!