State Management in Pure React: Data Fetching

In the previous post, we discuss about managing state in React's class component and Hooks. In this third part of the series, we are going to explore a data fetching strategy.

Let's take a look in this implementation that fetch all characters in the Star Wars movies.

const Application = () => {
...

  useEffect(() => {
    console.log('Fetching');
    fetch(`${endpoint}/characters`)
      .then(response => response.json())
      .then(response => {
        console.log({ response });
        setCharacters(Object.values(response.characters));
      })
      .catch(console.error);
  });

...
};

A careful eye will see that this is actually triggering an infinite loop since we're calling this effect on every single render. Adding a bracket will make this less bad.

useEffect(() => {
  console.log('Fetching');
  fetch(`${endpoint}/characters`)
    .then(response => response.json())
    .then(response => {
      console.log({ response });
      setCharacters(Object.values(response.characters));
    })
    .catch(console.error);
}, []);

This has its own catch since this will only run once when the component mounts and will never run again. But we can burn that bridge later.

But now, we want this to run every time the search term changes. How? useEffect is your friend for this.

Let's try add some loading and error states that also return response, loading and error.

const [loading, setLoading] = useState(true);
const [error, setError] = useState(error);

Updating the fetch effect.

...
useEffect(() => {
  console.log('Fetching');

  setLoading(true);  setError(null);  setCharacters([]);
  fetch(`${endpoint}/characters`)
    .then(response => response.json())
    .then(response => {
      setCharacters(Object.values(response.characters));
      setLoading(false);    })
    .catch(error => {
      setError(error);      setLoading(false);    });
}, []);
...

Then, displaying it in the component:

const Application = () => {
  const [characters, setCharacters] = useState(dummyData);

  return (
    <div>
      <header>
        <h1>Star Wars Characters</h1>
      </header>
      <main>
        <section>
          <CharacterList characters={characters} />
        </section>
      </main>
    </div>
  );
};

I don't like this. if only there was a way to just announce that things happened and set up a bunch of rules for what the resulting state should be. You don't want to write setLoading every time you do this. How about we factor this our intro a useFetch hook.

Creating a Custom Hook

If you are not fetching public api in your code, it's better make your own custom hook.

const useFetch = url => {
  const [response, setResponse] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    console.log('Fetching');

    setLoading(true);
    setError(null);
    setResponse(null);

    fetch(url)
      .then(response => response.json())
      .then(response => {
        setResponse(response);
        setLoading(false);
      })
      .catch(error => {
        setError(error);
        setLoading(false);
      });
  }, [url]);

  return [response, loading, error];
};

Adding the Formatting in There

Let's break that out into a function.

const formatData = response => (response && response.characters) || [];

Then we can use it like this.

const [characters, loading, error] = useFetch(
  endpoint + '/characters',
  formatData,
);

We can add that to our useEffect.

useEffect(() => {
  console.log('Fetching');

  setLoading(true);
  setError(null);
  setResponse(null);

  fetch(url)
    .then(response => response.json())
    .then(response => {
      setResponse(formatData(response));      setLoading(false);
    })
    .catch(error => {
      setError(error);
      setLoading(false);
    });
}, [url, formatData]);

Using an Async Function

You can't pass an async function directly to useEffect as useEffect(async() => ...). You can do it like this though:

useEffect(() => {
  console.log('Fetching');

  setLoading(true);
  setError(null);
  setResponse(null);

  const get = async () => {    try {      const response = await fetch(url);      const data = await response.json();      setResponse(formatData(data));    } catch (error) {      setError(error);    } finally {      setLoading(false);    }  };  get();}, [url, formatData]);

return [response, loading, error];

Refactoring to a Reducer

const fetchReducer = (state, action) => {
  if (action.type === 'FETCHING') {
    return {
      result: null,
      loading: true,
      error: null,
    };
  }

  if (action.type === 'RESPONSE_COMPLETE') {
    return {
      result: action.payload.result,
      loading: false,
      error: null,
    };
  }

  if (action.type === 'ERROR') {
    return {
      result: null,
      loading: false,
      error: action.payload.error,
    };
  }

  return state;
};

Now, we can just dispatch actions.

const useFetch = (url, dependencies = [], formatResponse = () => {}) => {
  const [state, dispatch] = useReducer(fetchReducer, initialState);

  useEffect(() => {
    dispatch({ type: 'FETCHING' });
    fetch(url)
      .then(response => response.json())
      .then(response => {
        dispatch({
          type: 'RESPONSE_COMPLETE',
          payload: { result: formatResponse(response) },
        });
      })
      .catch(error => {
        dispatch({ type: 'ERROR', payload: { error } });
      });
  }, [url, formatResponse]);

  const { result, loading, error } = state;

  return [result, loading, error];
};

The is the full code that we have refactored:

src/index.js
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import CharacterList from './CharacterList'; // just a component helper to render list

import dummyData from './dummy-data'; // JSON Dummy Data
import endpoint from './endpoint'; // string url endpoint

const initialState = {
  result: null,
  loading: true,
  error: null,
};

const fetchReducer = (state, action) => {
  if (action.type === 'LOADING') {
    return {
      result: null,
      loading: true,
      error: null,
    };
  }

  if (action.type === 'RESPONSE_COMPLETE') {
    return {
      result: action.payload.response,
      loading: false,
      error: null,
    };
  }

  if (action.type === 'ERROR') {
    return {
      result: null,
      loading: false,
      error: action.payload.error,
    };
  }

  return state;
};

const useFetch = url => {
  const [state, dispatch] = React.useReducer(fetchReducer, initialState);

  React.useEffect(() => {
    dispatch({ type: 'LOADING' });

    const fetchUrl = async () => {
      try {
        const response = await fetch(url);
        const data = await response.json();
        dispatch({ type: 'RESPONSE_COMPLETE', payload: { response: data } });
      } catch (error) {
        dispatch({ type: 'ERROR', payload: { error } });
      }
    };

    fetchUrl();
  }, []);

  return [state.result, state.loading, state.error];
};

const Application = () => {
  const [response, loading, error] = useFetch(endpoint + '/characters');
  const characters = (response && response.characters) || [];

  return (
    <div>
      <header>
        <h1>Star Wars Characters</h1>
      </header>
      <main>
        <section>
          {loading ? (
            <p>Loading…</p>
          ) : (
            <CharacterList characters={characters} />
          )}
          {error && <p>{error.message}</p>}
        </section>
      </main>
    </div>
  );
};

const rootElement = document.getElementById('root');

ReactDOM.render(
  <Router>
    <Application />
  </Router>,
  rootElement,
);

Using reducer is better especially if you have to manage a lot of variety of states (eg: date, address, zip code, in one component). This way, it's super easy to test because you don't have to mock out AJAX and you don't have to mount a component.

Share article