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>
  )
}

Resources For Further Learning