L7: Fetching Listing Details
Fetch individual listing data using the ID from URL parameters
Now that we can extract the ID from the URL, it's time to use it! In this lesson, we'll fetch the specific listing data from the API based on the ID parameter.
This combines concepts from Module 3 (data fetching, useEffect, loading/error states) with the routing knowledge from Module 4.
What You'll Learn
- Fetch single listing by ID from API
- Use useParams with useEffect
- Handle loading and error states
- Deal with invalid listing IDs
- Implement proper cleanup with AbortController
The Goal
Transform this:
// ❌ Hardcoded data
<h1>Cozy Beach House</h1>
<p>$250 / night</p>Into this:
// ✅ Dynamic data from API
<h1>{listing.title}</h1>
<p>${listing.price} / night</p>Each URL (/listing/1, /listing/2, etc.) will fetch and display different data!
API Endpoint
We'll use this API endpoint to fetch a single listing:
GET /api/listings/:idExamples:
GET /api/listings/1 → Returns listing with ID 1
GET /api/listings/2 → Returns listing with ID 2
GET /api/listings/42 → Returns listing with ID 42Response format:
{
"id": 1,
"title": "Cozy Beach House",
"location": "Malibu, California",
"price": 250,
"rating": 4.8,
"reviews": 124,
"description": "Wake up to the sound of waves...",
"bedrooms": 2,
"bathrooms": 2,
"guests": 4,
"images": [...]
}Step 1: Set Up State and Imports
Let's add the necessary imports and state management:
Import useState and useEffect
Update the imports in ListingDetailsPage.jsx:
import { useParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
export default function ListingDetailsPage() {
const { id } = useParams();
// Add state for listing data
const [listing, setListing] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
// Rest of component...
}What we added:
- listing - Stores the fetched listing data
- isLoading - Tracks loading state
- error - Stores any error messages
Step 2: Implement Data Fetching
Now let's fetch the listing data using the ID:
Add useEffect with Fetch Logic
Add this useEffect after your state declarations:
export default function ListingDetailsPage() {
const { id } = useParams();
const [listing, setListing] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Reset states when ID changes
setIsLoading(true);
setError(null);
// Create AbortController for cleanup
const controller = new AbortController();
// Fetch listing by ID
fetch(`/api/listings/${id}`, {
signal: controller.signal
})
.then(response => {
// Check if listing exists
if (!response.ok) {
throw new Error('Listing not found');
}
return response.json();
})
.then(data => {
setListing(data);
setIsLoading(false);
})
.catch(err => {
// Ignore abort errors
if (err.name === 'AbortError') return;
setError(err.message);
setIsLoading(false);
});
// Cleanup: abort fetch on unmount or ID change
return () => controller.abort();
}, [id]); // Re-run when ID changes
// Rest of component...
}What this does:
- Reset states - Clear previous data when ID changes
- Create AbortController - For canceling requests
- Fetch listing - Use ID in API URL
- Check response - Throw error if listing not found
- Update state - Set listing data or error
- Cleanup - Cancel request if component unmounts
- Dependency array - Re-fetch when
idchanges
Understanding the Flow
Let's trace what happens when you visit /listing/42:
// 1. URL: /listing/42
const { id } = useParams(); // id = "42"
// 2. Initial state
useState(null); // listing = null
useState(true); // isLoading = true
useState(null); // error = null
// 3. useEffect runs
fetch('/api/listings/42') // Fetch specific listing
// 4. Response received (success)
setListing(data); // listing = { id: 42, title: "...", ... }
setIsLoading(false); // isLoading = false
// 5. Component re-renders with data
return <h1>{listing.title}</h1>Step 3: Handle Loading State
Show a loading spinner while fetching:
Add Loading UI
Before the return statement, add loading check:
export default function ListingDetailsPage() {
const { id } = useParams();
const [listing, setListing] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// ... fetch logic
}, [id]);
// Show loading spinner
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen"> // [!code ++]
<div className="text-center"> // [!code ++]
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-pink-600"></div> // [!code ++]
<p className="mt-4 text-gray-600">Loading listing...</p> // [!code ++]
</div> // [!code ++]
</div>
);
}
return (
// ... main content
);
}Why this matters:
- Provides visual feedback to users
- Prevents showing empty/broken UI
- Indicates data is being fetched
Step 4: Handle Error State
Show error message if listing not found:
Add Error UI
After the loading check, add error handling:
export default function ListingDetailsPage() {
const { id } = useParams();
const [listing, setListing] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// ... fetch logic
}, [id]);
// Show loading spinner
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-pink-600"></div>
<p className="mt-4 text-gray-600">Loading listing...</p>
</div>
</div>
);
}
// Show error message
if (error) {
return (
<div className="flex items-center justify-center min-h-screen"> // [!code ++]
<div className="text-center"> // [!code ++]
<div className="text-6xl mb-4">😞</div> // [!code ++]
<h1 className="text-3xl font-bold text-gray-900 mb-2"> // [!code ++]
Listing Not Found // [!code ++]
</h1> // [!code ++]
<p className="text-gray-600 mb-6"> // [!code ++]
Listing #{id} doesn't exist or has been removed. // [!code ++]
</p> // [!code ++]
<a
href="/"
className="inline-block bg-pink-600 hover:bg-pink-700 text-white font-semibold py-2 px-6 rounded-lg transition-colors"
> // [!code ++]
Back to Home // [!code ++]
</a> // [!code ++]
</div> // [!code ++]
</div>
);
}
return (
// ... main content
);
}What this handles:
- Invalid listing IDs (e.g.,
/listing/99999) - Network errors
- Server errors
- Provides way to return home
Step 5: Display Real Data
Now update the UI to use the fetched listing data:
Replace Hardcoded Values
Update the return statement to use listing state:
return (
<div className="container mx-auto px-4 py-8">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
</h1>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>📍 Malibu, California</span> {}
<span>⭐ 4.8</span> {}
<span>👥 124 reviews</span> {}
<span>📍 {listing.location}</span> {}
<span>⭐ {listing.rating}</span> {}
<span>👥 {listing.reviews} reviews</span> {}
</div>
</div>
{/* Image Placeholder */}
<div className="bg-gray-200 rounded-lg h-96 mb-6 flex items-center justify-center">
<p className="text-gray-500 text-lg">Image Gallery Coming Soon</p>
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column */}
<div className="lg:col-span-2 space-y-6">
<div className="border-b border-gray-200 pb-6">
<h2 className="text-xl font-semibold mb-2">
Entire place hosted by {listing.host?.name || 'Host'} {}
</h2>
<div className="text-gray-600">
</div>
</div>
<div className="border-b border-gray-200 pb-6">
<h2 className="text-xl font-semibold mb-3">About this place</h2>
<p className="text-gray-600 leading-relaxed">
</p>
</div>
<div className="border-b border-gray-200 pb-6">
<h2 className="text-xl font-semibold mb-3">What this place offers</h2>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-3"> {}
<span>🏖️</span> {}
<span>Beach access</span> {}
</div> {}
{/* More hardcoded amenities... */} {}
{listing.amenities?.map((amenity, index) => ( {}
<div key={index} className="flex items-center gap-3"> {}
<span>{amenity.icon}</span> {}
<span>{amenity.name}</span> {}
</div> {}
</div>
</div>
</div>
{/* Right Column - Booking Card */}
<div className="lg:col-span-1">
<div className="border border-gray-200 rounded-lg p-6 shadow-md sticky top-4">
<div className="mb-4">
<span className="text-2xl font-bold">$250</span> {}
<span className="text-2xl font-bold">${listing.price}</span> {}
<span className="text-gray-600"> / night</span>
</div>
<div className="mb-4">
<div className="text-sm text-gray-600 mb-2">
</div>
</div>
<button className="w-full bg-pink-600 hover:bg-pink-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors">
Check Availability
</button>
<p className="text-center text-sm text-gray-600 mt-4">
You won't be charged yet
</p>
</div>
</div>
</div>
</div>
);What changed:
- Used
listing.titleinstead of hardcoded "Cozy Beach House" - Dynamic location, rating, reviews from
listingstate - Host name from
listing.host?.name(with fallback) - Guest/bedroom/bathroom counts from data
- Description from
listing.description - Amenities mapped from array
- Price from
listing.price
Complete ListingDetailsPage Code
Here's the full component with all changes:
import { useParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
export default function ListingDetailsPage() {
const { id } = useParams();
const [listing, setListing] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
setError(null);
const controller = new AbortController();
fetch(`/api/listings/${id}`, {
signal: controller.signal
})
.then(response => {
if (!response.ok) {
throw new Error('Listing not found');
}
return response.json();
})
.then(data => {
setListing(data);
setIsLoading(false);
})
.catch(err => {
if (err.name === 'AbortError') return;
setError(err.message);
setIsLoading(false);
});
return () => controller.abort();
}, [id]);
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-pink-600"></div>
<p className="mt-4 text-gray-600">Loading listing...</p>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="text-6xl mb-4">😞</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Listing Not Found
</h1>
<p className="text-gray-600 mb-6">
Listing #{id} doesn't exist or has been removed.
</p>
<a
href="/"
className="inline-block bg-pink-600 hover:bg-pink-700 text-white font-semibold py-2 px-6 rounded-lg transition-colors"
>
Back to Home
</a>
</div>
</div>
);
}
// Success state - display listing
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
{listing.title}
</h1>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>📍 {listing.location}</span>
<span>⭐ {listing.rating}</span>
<span>👥 {listing.reviews} reviews</span>
</div>
</div>
<div className="bg-gray-200 rounded-lg h-96 mb-6 flex items-center justify-center">
<p className="text-gray-500 text-lg">Image Gallery Coming Soon</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="border-b border-gray-200 pb-6">
<h2 className="text-xl font-semibold mb-2">
Entire place hosted by {listing.host?.name || 'Host'}
</h2>
<div className="text-gray-600">
{listing.guests} guests · {listing.bedrooms} bedrooms · {listing.bathrooms} bathrooms
</div>
</div>
<div className="border-b border-gray-200 pb-6">
<h2 className="text-xl font-semibold mb-3">About this place</h2>
<p className="text-gray-600 leading-relaxed">
{listing.description}
</p>
</div>
<div className="border-b border-gray-200 pb-6">
<h2 className="text-xl font-semibold mb-3">What this place offers</h2>
<div className="grid grid-cols-2 gap-4">
{listing.amenities?.map((amenity, index) => (
<div key={index} className="flex items-center gap-3">
<span>{amenity.icon}</span>
<span>{amenity.name}</span>
</div>
))}
</div>
</div>
</div>
<div className="lg:col-span-1">
<div className="border border-gray-200 rounded-lg p-6 shadow-md sticky top-4">
<div className="mb-4">
<span className="text-2xl font-bold">${listing.price}</span>
<span className="text-gray-600"> / night</span>
</div>
<div className="mb-4">
<div className="text-sm text-gray-600 mb-2">
⭐ {listing.rating} · {listing.reviews} reviews
</div>
</div>
<button className="w-full bg-pink-600 hover:bg-pink-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors">
Check Availability
</button>
<p className="text-center text-sm text-gray-600 mt-4">
You won't be charged yet
</p>
</div>
</div>
</div>
</div>
);
}Testing the Feature
Try these URLs to see different listings:
URL: http://localhost:5173/listing/1
What you should see:
- Loading spinner appears briefly
- Listing #1 data loads
- Title, location, price all show real data
- All dynamic fields populated
URL: http://localhost:5173/listing/2
What you should see:
- Loading spinner appears
- Different listing data loads
- All fields show different values
- URL ID matches displayed listing
Try more:
/listing/3/listing/4/listing/5
URL: http://localhost:5173/listing/99999
What you should see:
- Loading spinner appears
- Error message displays
- "Listing Not Found" heading
- Listing ID shown in message
- "Back to Home" button
Why useEffect Depends on ID
The dependency array [id] is crucial:
AbortController Cleanup
Why we use AbortController:
Preventing Race Conditions
If user navigates quickly between listings, multiple fetch requests could be in flight. AbortController cancels old requests when a new one starts!
Scenario without cleanup:
1. Visit /listing/1 → Fetch listing 1 (slow)
2. Visit /listing/2 → Fetch listing 2 (fast)
3. Listing 2 loads → Shows listing 2 ✅
4. Listing 1 finishes → Shows listing 1 ❌ (WRONG!)With AbortController:
1. Visit /listing/1 → Fetch listing 1
2. Visit /listing/2 → Abort fetch 1, Fetch listing 2
3. Listing 2 loads → Shows listing 2 ✅
4. Listing 1 aborted → Ignored ✅Common Data Fetching Patterns
Dynamic Listing Pages Complete!
You now have fully functional listing detail pages! Each URL fetches and displays different listing data from the API with proper loading and error states.
Quick Recap
What we accomplished:
- ✅ Set up state for listing, loading, and errors
- ✅ Implemented data fetching with useParams ID
- ✅ Added loading spinner UI
- ✅ Created error handling with helpful message
- ✅ Displayed real data dynamically
- ✅ Implemented AbortController cleanup
- ✅ Made useEffect depend on ID parameter
Key concepts:
- useParams + useEffect - Fetch data based on URL
- Dependency array -
[id]triggers re-fetch on navigation - Loading states - Show spinner during fetch
- Error handling - Graceful failure with user feedback
- AbortController - Prevent race conditions
- Conditional rendering - if loading/error/success
What's Next?
In Lesson 8, we'll extract the listing display logic into a reusable ListingDetailsCard component. This will clean up our code and make the details section more maintainable! 🎨