React - API and Data Management
Hi! Now that we've mastered the basics of React, we're going to see how to fetch, manage, and synchronize data with external APIs!
The Problem with APIs
Before, we did whatever
// ❌ The classic code that stinks (but you see everywhere)
function ProductsList() {
const [produits, setProduits] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('/api/produits')
.then(res => res.json())
.then(data => {
setProduits(data)
setLoading(false)
})
.catch(err => {
setError(err)
setLoading(false)
})
}, [])
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{produits.map(produit => (
<li key={produit.id}>{produit.nom}</li>
))}
</ul>
)
}
The problems with this approach
- Repetitive code: loading, error, data everywhere
- No cache: we refetch on every render
- No synchronization: if the data changes elsewhere, we don't know
- Basic error handling: retry? background refetch? forgotten!
- Poor performance: no optimizations
Modern Solutions
1. Fetch with useState/useEffect (Basic but OK)
// ✅ Slightly better organized version
function useApi(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true)
const response = await fetch(url)
if (!response.ok) throw new Error('Network error')
const result = await response.json()
setData(result)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchData()
}, [url])
return { data, loading, error }
}
// Usage
function ProductsList() {
const { data: produits, loading, error } = useApi('/api/produits')
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error}</div>
return (
<ul>
{produits?.map(produit => (
<li key={produit.id}>{produit.nom}</li>
))}
</ul>
)
}
2. TanStack Query (Ex-React Query) - THE BOSS! 🔥
npm install @tanstack/react-query
Setup in your app:
// main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<MonApp />
</QueryClientProvider>
)
}
Super simple usage:
import { useQuery } from '@tanstack/react-query'
function ProductsList() {
const { data: produits, isLoading, error } = useQuery({
queryKey: ['produits'],
queryFn: () => fetch('/api/produits').then(res => res.json()),
staleTime: 5 * 60 * 1000, // 5 minutes
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{produits?.map(produit => (
<li key={produit.id}>{produit.nom}</li>
))}
</ul>
)
}
3. Mutations with TanStack Query
import { useMutation, useQueryClient } from '@tanstack/react-query'
function AjouterProduit() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (nouveauProduit) =>
fetch('/api/produits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nouveauProduit)
}),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries(['produits'])
},
})
const handleSubmit = (e) => {
e.preventDefault()
const formData = new FormData(e.target)
mutation.mutate({
nom: formData.get('nom'),
prix: formData.get('prix')
})
}
return (
<form onSubmit={handleSubmit}>
<input name="nom" placeholder="Product Name" />
<input name="prix" type="number" placeholder="Price" />
<button type="submit" disabled={mutation.isLoading}>
{mutation.isLoading ? 'Adding...' : 'Add'}
</button>
</form>
)
}
Advanced Practical Examples
Custom Hook for API with Auth
function useAuthenticatedApi(url) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['api', url],
queryFn: async () => {
const token = localStorage.getItem('authToken')
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
if (response.status === 401) {
// Redirect to login
window.location.href = '/login'
return
}
if (!response.ok) {
throw new Error(`Error ${response.status}: ${response.statusText}`)
}
return response.json()
},
retry: (failureCount, error) => {
// No retry for errors 401, 403, 404
if ([401, 403, 404].includes(error.status)) return false
return failureCount < 3
},
staleTime: 2 * 60 * 1000, // 2 minutes
})
return { data, isLoading, error, refetch }
}
Global State Management with API
// hooks/useUser.js
function useUser() {
return useQuery({
queryKey: ['user'],
queryFn: () => fetch('/api/user').then(res => res.json()),
staleTime: 10 * 60 * 1000, // 10 minutes
cacheTime: 15 * 60 * 1000, // 15 minutes
})
}
// hooks/useUpdateUser.js
function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (userData) =>
fetch('/api/user', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
}),
onSuccess: (data) => {
// Update the cache directly
queryClient.setQueryData(['user'], data)
},
})
}
// Usage in a component
function ProfilUtilisateur() {
const { data: user, isLoading } = useUser()
const updateUser = useUpdateUser()
const handleSave = async (formData) => {
try {
await updateUser.mutateAsync(formData)
alert('Profile updated!')
} catch (error) {
alert('Error updating profile')
}
}
if (isLoading) return <div>Loading profile...</div>
return (
<div>
<h2>{user?.name}'s Profile</h2>
{/* Form here */}
</div>
)
}
Optimistic Updates
function useLikeProduit() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ produitId, liked }) =>
fetch(`/api/produits/${produitId}/like`, {
method: 'POST',
body: JSON.stringify({ liked })
}),
onMutate: async ({ produitId, liked }) => {
// Cancel ongoing requests
await queryClient.cancelQueries(['produits'])
// Snapshot of the current state
const previousProduits = queryClient.getQueryData(['produits'])
// Optimistic update
queryClient.setQueryData(['produits'], old =>
old?.map(p =>
p.id === produitId
? { ...p, liked, likes: p.likes + (liked ? 1 : -1) }
: p
)
)
return { previousProduits }
},
onError: (err, variables, context) => {
// Rollback in case of error
queryClient.setQueryData(['produits'], context.previousProduits)
},
onSettled: () => {
// Refetch to be sure
queryClient.invalidateQueries(['produits'])
},
})
}
Advanced TanStack Query Configuration
// queryClient.js
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 5 minutes of stale time by default
staleTime: 5 * 60 * 1000,
// 10 minutes of cache time
cacheTime: 10 * 60 * 1000,
// 3 retry by default
retry: 3,
// Refetch when the window refocuses
refetchOnWindowFocus: true,
// Refetch when reconnecting
refetchOnReconnect: true,
},
mutations: {
// 3 retry for mutations too
retry: 3,
},
},
})
DevTools for Debugging
// In development only
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function App() {
return (
<QueryClientProvider client={queryClient}>
<MonApp />
{process.env.NODE_ENV === 'development' && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
)
}
Alternatives to TanStack Query
SWR (Simple but effective)
import useSWR from 'swr'
const fetcher = (url) => fetch(url).then(res => res.json())
function Profile() {
const { data, error, isLoading } = useSWR('/api/user', fetcher)
if (error) return <div>Failed to load</div>
if (isLoading) return <div>Loading...</div>
return <div>Hello {data.name}!</div>
}
Apollo Client (For GraphQL)
If you're doing GraphQL, Apollo Client is THE thing to use.
import { useQuery, gql } from '@apollo/client'
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`
function Users() {
const { loading, error, data } = useQuery(GET_USERS)
if (loading) return 'Loading...'
if (error) return `Error! ${error.message}`
return (
<ul>
{data.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}