Skip to content

Loading, Error, and Success States

Every data fetch has three possible states. Building your components to handle all three produces a polished, reliable UI.

Loading → "Fetching data..."
Error → "Failed to load posts. Try again."
Success → Posts displayed
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.

src/hooks/useApi.ts
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 }
}
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>
)
}

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)
}
}
// ...
}
  1. Create src/hooks/useApi.ts.
  2. Refactor PostFeed to use the useApi hook.
  3. Add a refetch button that appears on error.
  4. 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 useApi hook reduces boilerplate across data-fetching components.
  • Write operations (create, delete) use useState for local submitting/error state.
  • Always show feedback to the user: loading indicators, error messages, and success confirmation.