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);
// highlight-start
}, []);
// highlight-end
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');
// highlight-start
setLoading(true);
setError(null);
setCharacters([]);
// highlight-end
fetch(`${endpoint}/characters`)
.then(response => response.json())
.then(response => {
setCharacters(Object.values(response.characters));
// highlight-start
setLoading(false);
// highlight-end
})
.catch(error => {
// highlight-start
setError(error);
setLoading(false);
// highlight-end
});
}, []);
...
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 => {
// highlight-start
setResponse(formatData(response));
// highlight-end
setLoading(false);
})
.catch(error => {
setError(error);
setLoading(false);
});
// highlight-start
}, [url, formatData]);
// highlight-end
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);
// highlight-start
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();
// highlight-end
}, [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:
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.