Mastering Data Fetching in Next.js 15, React 19 with the use Hook

Mastering Data Fetching in Next.js 15 and React 19 with the use
Hook: A Complete Developer's Guide
Data fetching in React has always been a bit of a puzzle. You've probably written countless useEffect
hooks, dealt with loading states, and wrestled with race conditions. I've been there too. But React 19 and Next.js 15 just changed the game with the new use
hook, and I want to share what I learned building my latest project with it.
The use
hook isn't just another way to fetch data – it's a fundamental shift in how we think about asynchronous operations in React. After spending the last month refactoring my e-commerce dashboard to use this new approach, I can tell you it's worth understanding.
What Makes the use
Hook Different?
Before we dive into code, let me explain why this matters. Traditional data fetching in React looks like this:
1function UserProfile({ userId }) {
2 const [user, setUser] = useState(null);
3 const [loading, setLoading] = useState(true);
4 const [error, setError] = useState(null);
5
6 useEffect(() => {
7 fetchUser(userId)
8 .then(setUser)
9 .catch(setError)
10 .finally(() => setLoading(false));
11 }, [userId]);
12
13 if (loading) return <div>Loading...</div>;
14 if (error) return <div>Error: {error.message}</div>;
15 return <div>Hello, {user.name}</div>;
16}
It works, but it's verbose and error-prone. The use
hook simplifies this dramatically:
1function UserProfile({ userId }) {
2 const user = use(fetchUser(userId));
3 return <div>Hello, {user.name}</div>;
4}
That's it. No loading states, no error handling boilerplate, no useEffect
. React handles all of that for you.
Setting Up Your Environment
Before we start building, make sure you have the right versions:
1npm install next@latest react@latest react-dom@latest
2# or
3yarn add next@latest react@latest react-dom@latest
You'll need:
- Next.js 15.0 or later
- React 19.0 or later
- Node.js 18 or later
Create a new Next.js project if you're starting fresh:
1npx create-next-app@latest my-use-hook-demo
2cd my-use-hook-demo
Understanding How use
Works
The use
hook is different from other hooks. It can only be called inside components and other hooks, but it can also be called conditionally – something that breaks the rules for other hooks.
Here's the key insight: use
doesn't just fetch data, it integrates with React's Suspense system. When you call use
with a Promise, React automatically shows the nearest Suspense boundary's fallback until the Promise resolves.
Let me show you a basic example:
1// utils/api.js
2export async function fetchUser(id) {
3 const user = await db.user.findUnique({ where: { id } });
4 if (!user) {
5 throw new Error('User not found');
6 }
7 return user;
8}
1// components/UserCard.js
2"use client"
3
4import { use } from 'react';
5import { fetchUser } from '../utils/api';
6
7export function UserCard({ userPromise }) {
8 const user = use(userPromise);
9
10 return (
11 <div className="user-card">
12 <h2>{user.name}</h2>
13 <p>{user.email}</p>
14 <p>Joined: {new Date(user.createdAt).toLocaleDateString()}</p>
15 </div>
16 );
17}
To use this component, wrap it in a Suspense boundary:
1// pages/profile.js
2import { Suspense } from 'react';
3import { UserCard } from '../components/UserCard';
4import { fetchUser } from '../utils/api';
5
6export default function ProfilePage() {
7 // Don't await - pass the promise directly
8 const userPromise = fetchUser("123");
9
10 return (
11 <div>
12 <h1>User Profile</h1>
13 <Suspense fallback={<div>Loading user...</div>}>
14 <UserCard userPromise={userPromise} />
15 </Suspense>
16 </div>
17 );
18}
Building a Real-World Example: Product Dashboard
Let me walk you through building something more practical. I'll create a product dashboard that fetches multiple types of data and shows you how to handle different scenarios.
First, let's set up our API functions:
1// utils/productApi.js
2const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
3
4export async function fetchProducts() {
5 const response = await fetch(`${API_BASE}/products`);
6 if (!response.ok) {
7 throw new Error(`Failed to fetch products: ${response.statusText}`);
8 }
9 return response.json();
10}
11
12export async function fetchProductDetails(id) {
13 const response = await fetch(`${API_BASE}/products/${id}`);
14 if (!response.ok) {
15 throw new Error(`Failed to fetch product ${id}: ${response.statusText}`);
16 }
17 return response.json();
18}
19
20export async function fetchProductReviews(id) {
21 const response = await fetch(`${API_BASE}/products/${id}/reviews`);
22 if (!response.ok) {
23 throw new Error(`Failed to fetch reviews: ${response.statusText}`);
24 }
25 return response.json();
26}
Now let's create components that use these APIs:
1// components/ProductList.js
2"use client"
3
4import { use } from 'react';
5import { fetchProducts } from '../utils/productApi';
6
7export function ProductList() {
8 const products = use(fetchProducts());
9
10 return (
11 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
12 {products.map(product => (
13 <div key={product.id} className="border rounded-lg p-4">
14 <h3 className="font-bold">{product.name}</h3>
15 <p className="text-gray-600">${product.price}</p>
16 <p className="text-sm">{product.description}</p>
17 </div>
18 ))}
19 </div>
20 );
21}
1// components/ProductDetails.js
2"use client"
3
4import { use } from 'react';
5import { fetchProductDetails, fetchProductReviews } from '../utils/productApi';
6
7export function ProductDetails({ productId }) {
8 const product = use(fetchProductDetails(productId));
9 const reviews = use(fetchProductReviews(productId));
10
11 const averageRating = reviews.length > 0
12 ? reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length
13 : 0;
14
15 return (
16 <div className="max-w-2xl mx-auto">
17 <div className="border rounded-lg p-6">
18 <h1 className="text-2xl font-bold mb-4">{product.name}</h1>
19 <p className="text-3xl font-bold text-green-600 mb-4">${product.price}</p>
20 <p className="text-gray-700 mb-6">{product.description}</p>
21
22 <div className="border-t pt-4">
23 <h2 className="text-xl font-semibold mb-2">Reviews</h2>
24 <p className="text-sm text-gray-600 mb-4">
25 Average rating: {averageRating.toFixed(1)}/5 ({reviews.length} reviews)
26 </p>
27
28 <div className="space-y-3">
29 {reviews.slice(0, 3).map(review => (
30 <div key={review.id} className="border-l-4 border-blue-500 pl-4">
31 <div className="flex items-center gap-2 mb-1">
32 <span className="font-medium">{review.userName}</span>
33 <span className="text-yellow-500">{'★'.repeat(review.rating)}</span>
34 </div>
35 <p className="text-sm text-gray-700">{review.comment}</p>
36 </div>
37 ))}
38 </div>
39 </div>
40 </div>
41 </div>
42 );
43}
Now let's put it all together in a dashboard:
1// pages/dashboard.js
2import { Suspense } from 'react';
3import { ProductList } from '../components/ProductList';
4import { ProductDetails } from '../components/ProductDetails';
5
6function LoadingSpinner() {
7 return (
8 <div className="flex items-center justify-center p-8">
9 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
10 </div>
11 );
12}
13
14export default function Dashboard() {
15 return (
16 <div className="container mx-auto px-4 py-8">
17 <h1 className="text-3xl font-bold mb-8">Product Dashboard</h1>
18
19 <div className="mb-12">
20 <h2 className="text-2xl font-semibold mb-4">All Products</h2>
21 <Suspense fallback={<LoadingSpinner />}>
22 <ProductList />
23 </Suspense>
24 </div>
25
26 <div>
27 <h2 className="text-2xl font-semibold mb-4">Featured Product</h2>
28 <Suspense fallback={<LoadingSpinner />}>
29 <ProductDetails productId="featured-123" />
30 </Suspense>
31 </div>
32 </div>
33 );
34}
Handling Errors Gracefully
One thing I learned quickly is that error handling with the use
hook requires a different approach. You can't just wrap your use
call in a try-catch – you need to use Error Boundaries.
Here's how I handle errors in my projects:
1// components/ErrorBoundary.js
2import { Component } from 'react';
3
4export class ErrorBoundary extends Component {
5 constructor(props) {
6 super(props);
7 this.state = { hasError: false, error: null };
8 }
9
10 static getDerivedStateFromError(error) {
11 return { hasError: true, error };
12 }
13
14 componentDidCatch(error, errorInfo) {
15 console.error('Error caught by boundary:', error, errorInfo);
16 // You could log this to an error reporting service here
17 }
18
19 render() {
20 if (this.state.hasError) {
21 return (
22 <div className="border border-red-200 rounded-lg p-4 bg-red-50">
23 <h2 className="text-red-800 font-semibold mb-2">Something went wrong</h2>
24 <p className="text-red-600 text-sm mb-3">
25 {this.state.error?.message || 'An unexpected error occurred'}
26 </p>
27 <button
28 onClick={() => this.setState({ hasError: false, error: null })}
29 className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
30 >
31 Try Again
32 </button>
33 </div>
34 );
35 }
36
37 return this.props.children;
38 }
39}
Use it to wrap components that might fail:
1// pages/dashboard.js (updated)
2import { Suspense } from 'react';
3import { ErrorBoundary } from '../components/ErrorBoundary';
4import { ProductList } from '../components/ProductList';
5
6export default function Dashboard() {
7 return (
8 <div className="container mx-auto px-4 py-8">
9 <h1 className="text-3xl font-bold mb-8">Product Dashboard</h1>
10
11 <ErrorBoundary>
12 <Suspense fallback={<LoadingSpinner />}>
13 <ProductList />
14 </Suspense>
15 </ErrorBoundary>
16 </div>
17 );
18}
Advanced Patterns: Conditional Data Fetching
Here's where the use
hook really shines – you can call it conditionally, which breaks the rules for other hooks but works perfectly here:
1// components/ConditionalUserData.js
2"use client"
3
4import { use } from 'react';
5import { fetchUser, fetchUserPreferences } from '../utils/api';
6
7export function ConditionalUserData({ userId, includePreferences = false }) {
8 const user = use(fetchUser(userId));
9
10 // This is allowed with the use hook!
11 const preferences = includePreferences ? use(fetchUserPreferences(userId)) : null;
12
13 return (
14 <div>
15 <h2>{user.name}</h2>
16 <p>{user.email}</p>
17
18 {preferences && (
19 <div className="mt-4">
20 <h3>Preferences</h3>
21 <p>Theme: {preferences.theme}</p>
22 <p>Language: {preferences.language}</p>
23 </div>
24 )}
25 </div>
26 );
27}
Optimizing Performance with Caching
One challenge I ran into was making the same API calls multiple times. The use
hook doesn't automatically cache results, so you'll want to implement some caching yourself.
Here's a simple cache implementation:
1// utils/cache.js
2const cache = new Map();
3
4export function createCachedFetcher(fetcher, cacheKey) {
5 return async (...args) => {
6 const key = `${cacheKey}-${JSON.stringify(args)}`;
7
8 if (cache.has(key)) {
9 return cache.get(key);
10 }
11
12 const promise = fetcher(...args);
13 cache.set(key, promise);
14
15 try {
16 const result = await promise;
17 cache.set(key, Promise.resolve(result));
18 return result;
19 } catch (error) {
20 cache.delete(key);
21 throw error;
22 }
23 };
24}
25
26// Usage
27export const fetchUserCached = createCachedFetcher(fetchUser, 'user');
28export const fetchProductsCached = createCachedFetcher(fetchProducts, 'products');
Or use a more sophisticated solution like SWR or React Query, which work great with the use
hook:
1// Using with SWR (install with npm install swr)
2"use client"
3
4import { use } from 'react';
5import useSWR from 'swr';
6
7export function ProductListWithSWR() {
8 const { data } = useSWR('/api/products', fetch);
9 const products = use(data);
10
11 return (
12 <div>
13 {products.map(product => (
14 <div key={product.id}>{product.name}</div>
15 ))}
16 </div>
17 );
18}
Integrating with Next.js 15 Features
Next.js 15 pairs beautifully with the use
hook. Here's how I typically structure my API routes:
1// pages/api/products/index.js
2export default async function handler(req, res) {
3 if (req.method !== 'GET') {
4 return res.status(405).json({ message: 'Method not allowed' });
5 }
6
7 try {
8 // Simulate database fetch
9 const products = await getProductsFromDatabase();
10
11 res.status(200).json(products);
12 } catch (error) {
13 console.error('API Error:', error);
14 res.status(500).json({ message: 'Internal server error' });
15 }
16}
17
18async function getProductsFromDatabase() {
19 // Your database logic here
20 return [
21 { id: 1, name: 'Laptop', price: 999, description: 'High-performance laptop' },
22 { id: 2, name: 'Mouse', price: 29, description: 'Wireless mouse' },
23 // ... more products
24 ];
25}
For server-side rendering with the use
hook, you can prefetch data:
1// pages/products/[id].js
2import { Suspense } from 'react';
3import { ProductDetails } from '../../components/ProductDetails';
4
5export async function getServerSideProps({ params }) {
6 // Prefetch the product data
7 const product = await fetchProductDetails(params.id);
8
9 return {
10 props: {
11 productId: params.id,
12 initialProduct: product
13 }
14 };
15}
16
17export default function ProductPage({ productId, initialProduct }) {
18 return (
19 <div>
20 <Suspense fallback={<div>Loading...</div>}>
21 <ProductDetails
22 productId={productId}
23 initialData={initialProduct}
24 />
25 </Suspense>
26 </div>
27 );
28}
Common Pitfalls and How to Avoid Them
After working with the use
hook for a while, here are the mistakes I see developers make:
1. Forgetting Suspense Boundaries
1// ❌ This will cause errors
2function App() {
3 return <UserProfile userId="123" />;
4}
5
6// ✅ Always wrap with Suspense
7function App() {
8 return (
9 <Suspense fallback={<div>Loading...</div>}>
10 <UserProfile userId="123" />
11 </Suspense>
12 );
13}
2. Not Handling Errors
1// ❌ Errors will crash your app
2function Dashboard() {
3 return (
4 <Suspense fallback={<Loading />}>
5 <ProductList />
6 </Suspense>
7 );
8}
9
10// ✅ Use Error Boundaries
11function Dashboard() {
12 return (
13 <ErrorBoundary>
14 <Suspense fallback={<Loading />}>
15 <ProductList />
16 </Suspense>
17 </ErrorBoundary>
18 );
19}
3. Creating New Promises on Every Render
1// ❌ This creates a new promise each time
2function UserProfile({ userId }) {
3 const user = use(fetch(`/api/users/${userId}`).then(r => r.json()));
4 return <div>{user.name}</div>;
5}
6
7// ✅ Move the promise creation outside or use a stable reference
8const fetchUser = (id) => fetch(`/api/users/${id}`).then(r => r.json());
9
10function UserProfile({ userId }) {
11 const user = use(fetchUser(userId));
12 return <div>{user.name}</div>;
13}
Testing Components with the use
Hook
Testing these components requires a slightly different approach. Here's how I test them:
1// __tests__/ProductList.test.js
2import { render, screen } from '@testing-library/react';
3import { Suspense } from 'react';
4import { ProductList } from '../components/ProductList';
5
6// Mock the API
7jest.mock('../utils/productApi', () => ({
8 fetchProducts: jest.fn()
9}));
10
11const { fetchProducts } = require('../utils/productApi');
12
13test('renders product list', async () => {
14 fetchProducts.mockResolvedValue([
15 { id: 1, name: 'Test Product', price: 99, description: 'Test description' }
16 ]);
17
18 render(
19 <Suspense fallback={<div>Loading...</div>}>
20 <ProductList />
21 </Suspense>
22 );
23
24 expect(screen.getByText('Loading...')).toBeInTheDocument();
25
26 await screen.findByText('Test Product');
27 expect(screen.getByText('$99')).toBeInTheDocument();
28});
Performance Considerations
The use
hook is powerful, but here are some performance tips I've learned:
- Batch related data fetches instead of making multiple
use
calls - Use React.memo for components that don't need frequent re-renders
- Implement proper caching to avoid redundant network requests
- Consider using React Query or SWR for more advanced caching and synchronization
1// Good: Batch related data
2async function fetchUserWithPreferences(userId) {
3 const [user, preferences] = await Promise.all([
4 fetchUser(userId),
5 fetchUserPreferences(userId)
6 ]);
7 return { user, preferences };
8}
9
10function UserProfile({ userId }) {
11 const { user, preferences } = use(fetchUserWithPreferences(userId));
12 // ...
13}
What's Next?
The use
hook is still evolving, and the React team is working on more features. Keep an eye on:
- Better integration with concurrent features
- Enhanced error handling patterns
- Performance optimizations
- New caching strategies
Wrapping Up
The use
hook represents a significant shift in how we handle asynchronous operations in React. It's cleaner, more intuitive, and integrates seamlessly with React's Suspense system. While there's a learning curve, especially around error handling and caching, the benefits are worth it.
I've been using this pattern in production for a few months now, and my code is cleaner and easier to maintain. The key is starting simple, understanding how Suspense works, and gradually adding more advanced patterns as you get comfortable.
Want to see more React 19 and Next.js 15 content? I'm building a complete course on modern React patterns. Sign up for my newsletter to get notified when it launches, and follow me for more practical tutorials like this one.
Have questions about implementing the use
hook in your project? Reach out on Twitter. I'd love to help you get started with this powerful new feature.