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>
))}
</>
);
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!
Editor's note: Seeing something wrong with this post? You can find the correct version here.
Plug: LogRocket, a DVR for web apps
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.
Top comments (0)