Embracing Declarative Programming with React Hooks
The Problem with Imperative Code
When I first presented a useFetch
example that abstracted away common code for calling a remote API endpoint, it was criticized for being too complex and imperative. React is built on declarative principles, where components react to state changes. The original useFetch
implementation felt more like an imperative approach, going against React’s grain.
A Declarative Solution
Fortunately, a revised useFetch
example was provided, simplifying the code and aligning with React’s declarative nature. By using useState
and useReducer
, we can trigger changes in effects in a declarative way. This approach feels more “React-y” and eliminates the need for forced re-renders.
function useFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const getFetchResult = useCallback(() => {
setLoading(true);
fetch(url)
.then(response => response.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(error => {
setError(error);
setLoading(false);
});
}, [url]);
return { data, error, loading, getFetchResult };
}
The Power of useReducer
In the revised useFetch
Hook, getFetchResult
returns a function that uses dispatch
from useReducer
to orchestrate lifecycle changes. This approach ensures that components react naturally to state changes, maintaining React’s one-way data flow.
const initialState = { data: null, error: null, loading: false };
function reducer(state, action) {
switch (action.type) {
case 'FETCH_SUCCESS':
return {...state, data: action.data, loading: false };
case 'FETCH_ERROR':
return {...state, error: action.error, loading: false };
case 'FETCH_LOADING':
return {...state, loading: true };
default:
throw new Error();
}
}
function useFetch(url) {
const [state, dispatch] = useReducer(reducer, initialState);
const getFetchResult = useCallback(() => {
dispatch({ type: 'FETCH_LOADING' });
fetch(url)
.then(response => response.json())
.then(data => dispatch({ type: 'FETCH_SUCCESS', data }))
.catch(error => dispatch({ type: 'FETCH_ERROR', error }));
}, [url, dispatch]);
return {...state, getFetchResult };
}
The Stale Closure Problem
One of the challenges with React Hooks is the stale closure problem. When dealing with closures, it’s essential to ensure that the dependency array includes any values from the outer scope that change over time. The react-hooks/exhaustive-deps linting rule helps highlight missing dependencies.
Solving Stale Closures
Dan Abramov’s solution involves storing the callback in a mutable ref. This approach ensures that the latest callback is saved on each render. The useEventCallback
Hook from Formik provides an elegant solution to this problem.
function useEventCallback(fn) {
const ref = useRef(fn);
useEffect(() => {
ref.current = fn;
}, [fn]);
return ref.current;
}
Rethinking Hooks
By embracing declarative programming and aligning with React’s principles, we can unlock the full potential of Hooks. With the right approach, Hooks can become a powerful abstraction for managing state changes and component reactions.
- Use
useState
anduseReducer
to trigger changes in effects in a declarative way. - Ensure that the dependency array includes any values from the outer scope that change over time.
- Store callbacks in mutable refs to solve the stale closure problem.