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

AndryAndry Dina
159
React 19 use hook data fetching

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:

javascript
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:

javascript
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:

bash
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:

bash
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:

javascript
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}
javascript
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:

javascript
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:

javascript
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:

javascript
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}
javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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

javascript
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

javascript
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

javascript
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:

javascript
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:

  1. Batch related data fetches instead of making multiple use calls
  2. Use React.memo for components that don't need frequent re-renders
  3. Implement proper caching to avoid redundant network requests
  4. Consider using React Query or SWR for more advanced caching and synchronization
javascript
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.

Similar articles

Never miss an update

Subscribe to receive news and special offers.

By subscribing you agree to our Privacy Policy.