What you'll build: Replace the three hardcoded animals with real data from the Animals API. Add loading skeletons, graceful error handling, and React Router so each animal has its own URL and detail page.
Time: 60 minutes
Reset command: npm run section:4:start
React components are pure functions of their state and props — they have no concept of time. But fetching data from an API is inherently asynchronous: it takes time, it can fail, and you need to handle the time between "request sent" and "response received."
React handles this with side effects. A side effect is anything that reaches outside the component: network requests, timers, subscriptions. You manage side effects with the useEffect hook.
useEffect(() => {
// This runs after the component renders
fetch('/api/animals')
.then(r => r.json())
.then(data => console.log(data))
}, []) // The [] means "run once, when the component mounts"The dependency array controls when the effect re-runs:
[]— run once on mount[userId]— re-run wheneveruserIdchanges- no array — re-run on every render (almost never what you want)
In a terminal:
cd services/animals-api
npm startThen visit http://localhost:3001/animals in your browser. You should see a JSON object with total: 34 and an array of animals. If you don't see this, sort it before continuing.
Try filtering: http://localhost:3001/animals?species=dog — only dogs should appear.
Before building the custom hook, do the fetch directly in App.tsx so you understand what the hook will encapsulate.
Replace sampleAnimals with state, and add a useEffect to fetch:
import { useState, useEffect } from 'react'
import type { Animal } from '@/types/animal'
import { getAnimals } from '@/services/animalsApi'
const [animals, setAnimals] = useState<Animal[]>([])
useEffect(() => {
getAnimals().then(({ animals: data }) => {
setAnimals(data)
})
}, [])
getAnimalsfrom@/services/animalsApi— The service module is already written for you. It handlesfetch, error checking, and TypeScript types. In a real project you'd often use a library like React Query here, but understanding the raw pattern first is valuable.
Save and check the browser — you should now see 28 animals instead of 3.
There's a brief moment between component mount and data arriving where animals is an empty array. We want to show skeletons rather than a confusing empty state.
Add loading state:
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
setIsLoading(true)
getAnimals().then(({ animals: data }) => {
setAnimals(data)
setIsLoading(false)
})
}, [])Pass isLoading to PetList:
<PetList animals={filteredAnimals} isLoading={isLoading} />Update PetList to accept and use it — when isLoading is true, render skeleton cards instead of real ones:
interface Props {
animals: Animal[]
isLoading?: boolean
}
function PetCardSkeleton() {
return (
<div className="bg-white rounded-lg border border-gray-100 overflow-hidden animate-pulse">
<div className="w-full h-48 bg-gray-200" />
<div className="p-4 space-y-3">
<div className="h-5 bg-gray-200 rounded w-1/3" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-full" />
</div>
</div>
)
}
export function PetList({ animals, isLoading = false }: Props) {
if (isLoading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<PetCardSkeleton key={i} />
))}
</div>
)
}
// ... rest of the component
}
animate-pulse— This Tailwind class applies a gentle fade-in/out animation to convey that content is loading. It's a simple, accessible loading pattern.
To see the skeleton in action, throttle your network in browser DevTools (Network tab → change "No throttling" to "Slow 3G"), then refresh.
What happens if the API is down? Add error state and a user-friendly message:
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setIsLoading(true)
setError(null)
getAnimals()
.then(({ animals: data }) => {
setAnimals(data)
setIsLoading(false)
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Something went wrong')
setIsLoading(false)
})
}, [])Render the error in App.tsx:
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-700 text-sm">
Could not load animals — is the API running? ({error})
</p>
</div>
)}Test it: stop the API server and refresh. You should see the error message instead of a broken page.
The fetch logic is getting complex. Custom hooks let you extract stateful logic into a reusable function. Hooks are just functions whose names start with use.
Create src/hooks/useAnimals.ts:
import { useState, useEffect } from 'react'
import { getAnimals } from '@/services/animalsApi'
import type { AnimalFilters } from '@/services/animalsApi'
import type { Animal } from '@/types/animal'
interface UseAnimalsResult {
animals: Animal[]
isLoading: boolean
error: string | null
}
export function useAnimals(filters: AnimalFilters = {}): UseAnimalsResult {
const [animals, setAnimals] = useState<Animal[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setIsLoading(true)
setError(null)
getAnimals(filters)
.then(({ animals: data }) => {
if (!cancelled) {
setAnimals(data)
setIsLoading(false)
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Something went wrong')
setIsLoading(false)
}
})
return () => { cancelled = true }
}, [filters.species, filters.search])
return { animals, isLoading, error }
}The
cancelledflag — This prevents a race condition: if the component unmounts (or a new request fires) before the first response arrives, we must not callsetStateon an unmounted component. The cleanup function returned fromuseEffectruns when the effect is re-triggered or the component unmounts.
Now simplify App.tsx to use the hook instead of the raw useState/useEffect:
import { useAnimals } from '@/hooks/useAnimals'
const { animals, isLoading, error } = useAnimals({
species: selectedSpecies ?? undefined,
search: search || undefined,
})
// filteredAnimals is now just `animals` — the API filters for usNotice that you can remove the client-side filtering logic — we're now passing the filter params to the API. The useEffect dependency array includes filters.species and filters.search, so it re-fetches whenever either changes.
The app currently has one URL. We want each animal to have its own page at /animals/:id.
Update src/main.tsx to wrap the app in a router:
import { BrowserRouter } from 'react-router'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)Why wrap at the root? The router provides routing context to the entire app. Any component that uses
useNavigate,useParams, orLinkmust be inside a router. Putting it at the root means everything has access.
Extract the home page content into src/pages/HomePage.tsx (move the search panel + PetList rendering there), then update App.tsx to use routes:
import { Routes, Route } from 'react-router'
import { Link } from 'react-router'
import { HomePage } from '@/pages/HomePage'
import { AnimalDetailPage } from '@/pages/AnimalDetailPage'
export default function App() {
return (
<div className="min-h-screen bg-gray-50">
<header>
<Link to="/" className="text-2xl font-bold text-green-600">PawFinder</Link>
{/* nav */}
</header>
<main>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/animals/:id" element={<AnimalDetailPage />} />
</Routes>
</main>
</div>
)
}Create src/pages/AnimalDetailPage.tsx. Use useParams to get the animal ID from the URL, then fetch that specific animal:
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router'
import { getAnimal } from '@/services/animalsApi'
import type { Animal } from '@/types/animal'
export function AnimalDetailPage() {
const { id } = useParams<{ id: string }>()
const [animal, setAnimal] = useState<Animal | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!id) return
getAnimal(id)
.then((data) => { setAnimal(data); setIsLoading(false) })
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Not found')
setIsLoading(false)
})
}, [id])
if (isLoading) return <div className="p-8">Loading...</div>
if (error || !animal) return (
<div className="p-8">
<p className="text-red-600">{error}</p>
<Link to="/" className="text-green-600 text-sm">Back to all animals</Link>
</div>
)
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<Link to="/" className="text-sm text-green-600 hover:underline mb-6 inline-block">
Back to all animals
</Link>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{animal.name}</h1>
{/* render the full animal detail — story, rehomingAdvice, etc. */}
</div>
)
}In src/components/organisms/PetList.tsx, wrap each card in a Link:
import { Link } from 'react-router'
// Inside the map:
<Link key={animal.id} to={`/animals/${animal.id}`} className="block">
<PetCard animal={animal} />
</Link>- The home page loads 28 available animals from the API
- Searching "golden" shows only Max
- Clicking "Cats" shows only cats
- Clicking a card navigates to
/animals/max-001 - The detail page shows Max's name, description, story, and rehoming advice
- The "Back to all animals" link works
- Stopping the API and refreshing shows the error message (not a blank page or console errors)
- Throttle to Slow 3G — you should see the skeleton cards before the real ones appear
- Adoption enquiry form — on the detail page, add a form with name, email, and message fields. On submit, call
submitEnquiryfrom@/services/animalsApi. Show a success message. - Preserve filters in the URL — use
useSearchParamsfrom react-router to store the search and species filter in the URL query string. Share the URL with a neighbour — do their filters match? - Infinite scroll — the API supports pagination (sort by
dateArrived). Add a "Load more" button that appends the next page of results - Optimistic navigation — preload the animal data on card hover using
useEffectin PetCard so the detail page appears instantly
The app is fully functional with real data. In Section 5 you'll write tests that verify it keeps working — without needing the API to be running.
Reference solution:
npm run section:4:complete— or opensections/section-4/complete/to read without affecting your files.