Loading, Error, and Success States
Every data fetch has three possible states. Building your components to handle all three produces a polished, reliable UI.
The three states
Section titled “The three states”Loading → "Fetching data..."Error → "Failed to load posts. Try again."Success → Posts displayedManual state management
Section titled “Manual state management”const [data, setData] = useState<Post[] | null>(null)const [loading, setLoading] = useState(true)const [error, setError] = useState<string | null>(null)
useEffect(() => { setLoading(true) setError(null)
postsApi.list() .then(({ data: response }) => { setData(response.posts) }) .catch((err) => { setError(axios.isAxiosError(err) ? err.response?.data?.error || 'Failed to load' : 'Unexpected error' ) }) .finally(() => setLoading(false))}, [])This works, but repeating it across every data-fetching component is tedious.
A reusable useApi hook
Section titled “A reusable useApi hook”import { useState, useEffect, useCallback } from 'react'import axios from 'axios'
interface ApiState<T> { data: T | null loading: boolean error: string | null refetch: () => void}
export function useApi<T>(fetcher: () => Promise<{ data: T }>): ApiState<T> { const [data, setData] = useState<T | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState<string | null>(null)
const fetch = useCallback(() => { setLoading(true) setError(null)
fetcher() .then(({ data: result }) => setData(result)) .catch((err) => { const message = axios.isAxiosError(err) ? err.response?.data?.error || 'Failed to load' : 'Unexpected error' setError(message) }) .finally(() => setLoading(false)) }, [fetcher])
useEffect(() => { fetch() }, [fetch])
return { data, loading, error, refetch: fetch }}Using the hook
Section titled “Using the hook”import { useApi } from '../hooks/useApi'import { postsApi } from '../api/posts'import type { PostsResponse } from '../types/api'
function PostFeed() { const { data, loading, error, refetch } = useApi<PostsResponse>( () => postsApi.list({ sort: 'newest' }) )
if (loading) return <div className="loading-skeleton" /> if (error) return ( <div className="error-state"> <p>{error}</p> <button onClick={refetch}>Try again</button> </div> )
return ( <ul> {data?.posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> )}Mutations (write operations)
Section titled “Mutations (write operations)”Data fetches use useApi. Write operations (create, delete, upvote) are triggered by user actions — use local state:
function CreatePostForm() { const [submitting, setSubmitting] = useState(false) const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) { e.preventDefault() setSubmitting(true) setError(null)
try { await postsApi.create({ title, body }) // on success: navigate, refresh feed, etc. } catch (err) { setError(axios.isAxiosError(err) ? err.response?.data?.error || 'Failed to create post' : 'Unexpected error' ) } finally { setSubmitting(false) } } // ...}Exercise
Section titled “Exercise”- Create
src/hooks/useApi.ts. - Refactor
PostFeedto use theuseApihook. - Add a
refetchbutton that appears on error. - What happens if the component unmounts while a fetch is in progress? (This is a React 18 concern — modern React handles it with
AbortController.)
- Every data fetch has three states: loading, success, error — handle all three in the UI.
- A
useApihook reduces boilerplate across data-fetching components. - Write operations (create, delete) use
useStatefor local submitting/error state. - Always show feedback to the user: loading indicators, error messages, and success confirmation.