DEV Community

loading...
Cover image for Refactoring An Old React App: Creating a Custom Hook to Make Fetch-Related Logic Reusable

Refactoring An Old React App: Creating a Custom Hook to Make Fetch-Related Logic Reusable

Yurui Zhang
Full-stack developer. Dark mode enthusiast. Quality software devotee.
Updated on ・7 min read

I recently picked up an old project from two years ago. The app is not a very complicated one - it reads data from a simple API server and present them to the users, pretty standard stuff. The client has been pretty happy about the results so now they have come back with more feature requirements they'd like to include in the next iteration.

The Old Fashioned Way

Before actually start working on those features, I decided to bring all the dependencies up-to-date (it was still running React 16.2 - feels like eons ago) and do some "house cleaning". I'm glad that me from 2 years ago took the time to write plenty of unit and integration tests so this process was mostly painless. However when I was migrating those old React lifecycle functions (componentWill* series) to newer ones, a familiar pattern emerged:

class FooComponent extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      isLoading: true,
      error: null,
      data: null,
    };
  }

  componentDidMount() {
    this.fetchData();
  }

  componentDidUpdate(prevProps) {
    if (prevProps.fooId !== this.props.fooId) {
      this.fetchData();
    }
  }

  fetchData() {
    const url = compileFooUrl({ fooId: this.props.fooId });

    fetch(url).then(
      // set data on state
    ).catch(
      // set error on state
    );
  }

  render() {
    // redacted. 
  }
}
Enter fullscreen mode Exit fullscreen mode

Does this look familiar to you? The FooComponent here fetches foo from a remote source and renders it. A new foo will be fetched when the fooId in the props changes. We are also using some state field to track the request and the data fetched.

In this app I'm trying to improve, this pattern is seen in multiple components, but before hooks, it's often not very straight forward to share logic like this, but not anymore! Let's try to create a re-usable hook to improve our code.

First Iteration With Hooks

Now before we actually write a reusable custom hook, let's try to refactor this component. I think it's pretty obvious that we are going to need useState to replace this.state and let useEffect handle the data fetching part. useState is pretty easy to handle, but if you are not familiar with useEffect yet, Dan Abramov has a really good (and lengthy) blog article about it: https://overreacted.io/a-complete-guide-to-useeffect/

Our hooked component now looks like this:

const FooComponent = ({ fooId }) => {
  const [state, setState] = useState({
    isLoading: true,
    error: null,
    data: null,
  });

  useEffect(() => {
    const url = compileFooUrl({ fooId });

    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json().then(data => {
            setState({
              isLoading: false,
              data,
              error: null,
            });
          });
        }

        return Promise.reject(response);
      })
      .catch(/* similar stuff here */);
  }, [fooId]);

  return (
     // redacted
  );
};
Enter fullscreen mode Exit fullscreen mode

Pretty easy, eh? Our component now works almost* exactly like before with fewer lines (and cooler hook functions!), and all integration tests are still green! It fetches foo when it mounts and re-fetches it when fooId changes.

Making Our Logic Re-useable

The next step would be making this fetch-and-set-state logic re-usable. Luckily it is extremely easy to write a custom hook - we just need to cut-and-paste our code to a separate file!

Let's name our reusable hook useGet, which takes an url - since apparently not all components are gonna use foo and not all getRequests depend on a single ID I think it's probably easier to leave that url-building logic to each component that wants to use our custom hook. Here's what we are aiming for:

const FooComponent = ({ fooId }) => {
  const fooUrl = compileFooUrl({ fooId: this.props.fooId });

  const { isLoading, data, error } = useGet({ url });

  return (
    // same jsx as before
  );
};
Enter fullscreen mode Exit fullscreen mode

Let's cut-and-paste:

export function useGet = ({ url }) => {
  const [state, setState] = useState({
    isLoading: true,
    error: null,
    data: null,
  });

  useEffect(() => { /* fetch logic here */}, [url]);

  // return the `state` so it can be accessed by the component that uses this hook.

  return state;
};
Enter fullscreen mode Exit fullscreen mode

By the way, then/catch is so 2017, let's use async/await instead to reduce the nested callbacks - everybody hates those. Unfortunately useEffect cannot take an async function at this moment, we will have to define an async function inside of it, and call it right away. Our new useEffect looks something like this:

useEffect(() => {
  const fetchData = async () => {
    setState({
      isLoading: true,
      data: null,
      error: null,
    });

    try {
      const response = await fetch(url);

      if (!response.ok) {
        // this will be handled by our `catch` block below
        throw new Error(`Request Error: ${response.status}`);
      }

      setState({
        isLoading: false,
        data: await response.json(),
        error: null,
      });
    } catch(e) {
      setState({
        isLoading: false,
        data: null,
        error: e.message,
      });
    }
  };

  fetchData();
}, [url]);
Enter fullscreen mode Exit fullscreen mode

Much easier to read, isn't it?

The Problem with useState

In simple use cases like we have above, useState is probably fine, however there is a small problem with our code: we have to provide values to all of the fields in the state object every time we want to use setState. And sometimes, we don't necessarily want to reset other fields when a new request is fired (e.g. in some cases we might still want the user to be able to see the previous error message or data when a new request fires). You might be tempted to do this:

setState({
  ...state,
  isLoading: true,
})
Enter fullscreen mode Exit fullscreen mode

However that means state also becomes a dependency of useEffect - and if you add it to the array of dependencies, you will be greeted with an infinite fetch loop because every time state changes, React will try to call the effect (which in turn, produces a new state).

Luckily we have useReducer - it's somewhat similar to useState here but it allows you to separate your state-updating logic from your component. If you have used redux before, you already know how it works.

If you are new to the concept, you can think a reducer is a function that takes a state and an action then returns a new state. and useReducer is a hook that let you define an initial state, a "reducer" function that will be used to update the state. useReducer returns the most up-to-date state and a function that you will be using to dispatch actions.

const [state, dispatch] = useReducer(reducerFunction, initialState);
Enter fullscreen mode Exit fullscreen mode

Now in our use case here, we've already got our initialState:

{
  isLoading: false,
  data: null,
  error: null,
}
Enter fullscreen mode Exit fullscreen mode

And our state object is updated when the following action happens:

  • Request Started (sets isLoading to true)
  • Request Successful
  • Request Failed

Our reducer function should handle those actions and update the state accordingly. In some actions, (like "request successful") we will also need to provide some extra data to the reducer so it can set them onto the state object. An action can be almost any value (a string, a symbol, or an object), but in most cases we use objects with a type field:

// a request successful action:
{
  type: 'Request Successful', // will be read by the reducer
  data, // data from the api
}
Enter fullscreen mode Exit fullscreen mode

To dispatch an action, we simply call dispatch with the action object:

const [state, dispatch] = useReducer(reducer, initialState);

// fetch ... and dispatch the action below when it is successful
dispatch({
  type: 'Request Successful'
  data: await response.json(),
});
Enter fullscreen mode Exit fullscreen mode

And usually, we use "action creators" to generate those action objects so we don't need to construct them everywhere. Action creators also makes our code easier to change if we want to add additional payloads to an action, or rename types.

// example of action creator:

// a simple function that takes some payload, and returns an action object:
const requestSuccessful = ({ data }) => ({
  type: 'Request Successful',
  data,
}); 
Enter fullscreen mode Exit fullscreen mode

Often to avoid typing each type string again and again - we can define them separately as constants, so both the action creators and the reducers can re-use them. Typos are very common in programming - typos in strings are often harder to spot, but if you make a typo in a variable or a function call, your editors & browsers will alert you right away.

// a contants.js file

export const REQUEST_STARTED = 'REQUEST_STARTED';
export const REQUEST_SUCCESSFUL = 'REQUEST_SUCCESSFUL';
export const REQUEST_FAILED = 'REQUEST_FAILED';
export const RESET_REQUEST = 'RESET_REQUEST';
Enter fullscreen mode Exit fullscreen mode
// action creators:

export const requestSuccessful = ({ data }) => ({
  type: REQUEST_SUCCESSFUL,
  data,
});
Enter fullscreen mode Exit fullscreen mode
// dispatching an action in our component:

dispatch(requestSuccessful({ data: await response.json() }));
Enter fullscreen mode Exit fullscreen mode

Now, onto our reducer - it updates the state accordingly for each action:

// reducer.js

// a reducer receives the current state, and an action
export const reducer = (state, action) => {
  // we check the type of each action and return an updated state object accordingly
  switch (action.type) {
    case REQUEST_STARTED:
      return {
        ...state,
        isLoading: true,
      };
    case REQUEST_SUCCESSFUL:
      return {
        ...state,
        isLoading: false,
        error: null,
        data: action.data,
      };
    case REQUEST_FAILED:
      return {
        ...state,
        isLoading: false,
        error: action.error,
      };

    // usually I ignore the action if its `type` is not matched here, some people prefer throwing errors here - it's really up to you.
    default:
      return state;
  }
};

Enter fullscreen mode Exit fullscreen mode

Putting it together, our hook now looks like this:

// import our action creators
import {
  requestStarted,
  requestSuccessful,
  requestFailed,
} from './actions.js';
import { reducer } from './reducer.js';

export const useGet = ({ url }) => {
  const [state, dispatch] = useReducer(reducer, {
    isLoading: true,
    data: null,
    error: null,
  });

  useEffect(() => {
    const fetchData = async () => {
      dispatch(requestStarted());

      try {
        const response = await fetch(url);

        if (!response.ok) {
          throw new Error(
            `${response.status} ${response.statusText}`
          );
        }

        const data = await response.json();

        dispatch(requestSuccessful({ data }));
      } catch (e) {
        dispatch(requestFailed({ error: e.message }));
      }
    };

    fetchData();
  }, [url]);

  return state;
};
Enter fullscreen mode Exit fullscreen mode

dispatch is guaranteed to be stable and won't be changed between renders, so it doesn't need to be a dependency of useEffect. Now our hook is much cleaner and easier to be reasoned with.

Now we can start refactoring other components that uses data from a remote source with our new hook!

But There Is More

We are not done yet! However this post is getting a bit too long. Here's the list of things I'd like to cover in a separate article:

  • Clean up our effect
  • Use hooks in class-components.
  • Testing our hooks.
  • A "re-try" option. Let's give the user an option to retry when a request fails - how do we do that with our new hook?

Stay tuned!

Discussion (2)

Collapse
lostrogoth profile image
Lostrogoth

case REQUEST_STARTED:
// Avoid updating state unnecessarily
// By returning unchanged state React won't re-render

if (state.isLoading) {
return state;
}
return {
...state,
isLoading: true,
};

Collapse
pallymore profile image
Yurui Zhang Author

Hi - thanks for the comment.

personally i don't think that's necessary - if REQUEST_STARTED is fired multiple times when it is already loading, I would think there are more serious problems in other parts of the app, not just in the reducer.

Forem Open with the Forem app