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 and useReducer 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.

Leave a Reply