Loading & Error States
Track loading, success and error so the UI always has something to show.
Real data fetching has three outcomes: it is loading, it succeeded, or it failed. Track all three so the user is never staring at a blank screen.
A reliable shape
loading— show a spinner or skeleton.error— show a message and maybe a retry button.data— render the content.
Example
import { useState, useEffect } from 'react';
function Posts() {
const [state, setState] = useState({ loading: true, error: null, data: [] });
useEffect(() => {
fetch('/api/posts')
.then((r) => {
if (!r.ok) throw new Error('Failed to load');
return r.json();
})
.then((data) => setState({ loading: false, error: null, data }))
.catch((err) => setState({ loading: false, error: err.message, data: [] }));
}, []);
if (state.loading) return <p>Loading...</p>;
if (state.error) return <p>{state.error}</p>;
return <ul>{state.data.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}When to use it
- A product page shows a skeleton card while the product data loads and an error banner with a Retry button if the network request fails.
- A dashboard widget tracks three independent states — loading, data, and error — and renders the appropriate UI for each without nesting ternaries.
- A form submit handler enters a loading state, disables the submit button during the request, and switches to an error message if the server returns a non-200 status.
More examples
Three-state data fetch
Tracks loading, data, and error in separate state variables and renders the correct UI for each outcome.
import { useState, useEffect } from 'react';
function ArticleView({ id }) {
const [article, setArticle] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/articles/${id}`)
.then(r => { if (!r.ok) throw new Error('Not found'); return r.json(); })
.then(setArticle)
.catch(setError)
.finally(() => setLoading(false));
}, [id]);
if (loading) return <p>Loading...</p>;
if (error) return <p className="error">{error.message}</p>;
return <article><h1>{article.title}</h1></article>;
}Retry button on error
Provides a Retry button that re-triggers the fetch, resetting the error state before the new request starts.
import { useState, useCallback } from 'react';
function DataWidget({ url }) {
const [state, setState] = useState({ data: null, loading: false, error: null });
const load = useCallback(() => {
setState(s => ({ ...s, loading: true, error: null }));
fetch(url)
.then(r => r.json())
.then(data => setState({ data, loading: false, error: null }))
.catch(err => setState({ data: null, loading: false, error: err.message }));
}, [url]);
if (state.loading) return <p>Loading...</p>;
if (state.error) return <>
<p className="error">{state.error}</p>
<button onClick={load}>Retry</button>
</>;
return state.data ? <pre>{JSON.stringify(state.data)}</pre> : <button onClick={load}>Load</button>;
}Combined state object pattern
Replaces multiple useState calls with a reducer to manage status, data, and error as a single coherent state machine.
import { useReducer, useEffect } from 'react';
const init = { status: 'idle', data: null, error: null };
function reducer(state, action) {
switch (action.type) {
case 'LOADING': return { status: 'loading', data: null, error: null };
case 'SUCCESS': return { status: 'success', data: action.data, error: null };
case 'ERROR': return { status: 'error', data: null, error: action.error };
default: return state;
}
}
function RemoteData({ url }) {
const [state, dispatch] = useReducer(reducer, init);
useEffect(() => {
dispatch({ type: 'LOADING' });
fetch(url).then(r => r.json())
.then(data => dispatch({ type: 'SUCCESS', data }))
.catch(err => dispatch({ type: 'ERROR', error: err.message }));
}, [url]);
return <p>{state.status}</p>;
}
Discussion