Skip to main content

Next.js React Query State

Introduction

Managing server state in Next.js applications can quickly become complex. While React's built-in state management works well for local UI state, handling data fetching, caching, synchronization, and updates from servers requires more specialized tools. This is where React Query (now part of TanStack Query) shines.

React Query is a powerful library that simplifies server state management in React and Next.js applications. It provides tools for fetching, caching, synchronizing, and updating server state while maintaining a smooth user experience. In this guide, we'll explore how to integrate and use React Query effectively in a Next.js application.

What is React Query?

React Query is a data-fetching and state management library that handles the complex tasks of:

  • Fetching data from APIs
  • Caching responses
  • Automatically refetching when needed
  • Managing loading and error states
  • Background updates
  • Pagination and infinite scrolling

It allows you to separate your server state management from your UI state, leading to cleaner code and better performance.

Setting Up React Query in a Next.js Project

Installation

First, let's install React Query in our Next.js project:

bash
npm install @tanstack/react-query
# or
yarn add @tanstack/react-query

Configuring the QueryClient

In a Next.js application, you'll want to set up React Query at the application level. Create a provider component:

jsx
// app/providers.jsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

export default function Providers({ children }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
},
},
}));

return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}

Then use this provider in your layout:

jsx
// app/layout.jsx
import Providers from './providers';

export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}

Basic Data Fetching with React Query

Let's start with a simple example of fetching data from an API:

jsx
'use client';

import { useQuery } from '@tanstack/react-query';

async function fetchTodos() {
const res = await fetch('https://jsonplaceholder.typicode.com/todos');
if (!res.ok) {
throw new Error('Failed to fetch todos');
}
return res.json();
}

export default function TodosList() {
const { data, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});

if (isLoading) return <div>Loading todos...</div>;
if (error) return <div>Error: {error.message}</div>;

return (
<div>
<h1>Todos</h1>
<ul>
{data.map(todo => (
<li key={todo.id}>
{todo.completed ? '✅' : '❌'} {todo.title}
</li>
))}
</ul>
</div>
);
}

The output of this component would be:

  • A loading message while fetching data
  • An error message if the fetch fails
  • A list of todos with checkmarks for completed items once data is loaded

Key Concepts in the Example

  1. QueryKey: The unique identifier (['todos']) for this query that React Query uses for caching
  2. QueryFn: The function that fetches the data
  3. Query State: React Query provides isLoading, error, and data states automatically

Mutations with React Query

When you need to update, create, or delete data, you'll use React Query's mutation hooks:

jsx
'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';

async function addTodo(newTodo) {
const res = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
});

if (!res.ok) {
throw new Error('Failed to add todo');
}

return res.json();
}

export default function AddTodo() {
const queryClient = useQueryClient();
const [title, setTitle] = useState('');

const mutation = useMutation({
mutationFn: addTodo,
onSuccess: () => {
// Invalidate and refetch the todos list
queryClient.invalidateQueries({ queryKey: ['todos'] });
setTitle('');
},
});

const handleSubmit = (e) => {
e.preventDefault();
mutation.mutate({ title, completed: false });
};

return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a new todo"
required
/>
<button
type="submit"
disabled={mutation.isPending}
>
{mutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
{mutation.isError && (
<p style={{ color: 'red' }}>Error: {mutation.error.message}</p>
)}
</form>
);
}

Advanced React Query Patterns in Next.js

Prefetching Data

Next.js and React Query can work together to prefetch data for optimal performance:

jsx
// app/todos/page.jsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import TodosList from './TodosList';

async function fetchTodos() {
const res = await fetch('https://jsonplaceholder.typicode.com/todos');
if (!res.ok) {
throw new Error('Failed to fetch todos');
}
return res.json();
}

export default async function TodosPage() {
const queryClient = new QueryClient();

await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodosList />
</HydrationBoundary>
);
}

Dependent Queries

Sometimes queries depend on the data from other queries:

jsx
'use client';

import { useQuery } from '@tanstack/react-query';

function UserDetails({ userId }) {
// First query to fetch the user
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
});

// Second query depends on the result of the first
const postsQuery = useQuery({
queryKey: ['posts', userQuery.data?.id],
queryFn: () => fetch(`/api/users/${userQuery.data.id}/posts`).then(res => res.json()),
// Only execute this query when the user data is available
enabled: !!userQuery.data?.id,
});

if (userQuery.isLoading) return <div>Loading user...</div>;
if (userQuery.error) return <div>Error loading user</div>;

return (
<div>
<h1>{userQuery.data.name}'s Posts</h1>
{postsQuery.isLoading ? (
<p>Loading posts...</p>
) : postsQuery.error ? (
<p>Error loading posts</p>
) : (
<ul>
{postsQuery.data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
</div>
);
}

Infinite Queries

For pagination or infinite scroll interfaces:

jsx
'use client';

import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';

const fetchPosts = async ({ pageParam = 1 }) => {
const res = await fetch(`/api/posts?page=${pageParam}&limit=10`);
if (!res.ok) throw new Error('Network response was not ok');
return res.json();
};

export default function PostsList() {
const { ref, inView } = useInView();

const {
data,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextPage || undefined,
});

useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, fetchNextPage, hasNextPage, isFetchingNextPage]);

if (status === 'pending') return <p>Loading...</p>;
if (status === 'error') return <p>Error: {error.message}</p>;

return (
<>
<h1>Posts</h1>
<div className="posts-list">
{data.pages.map((page) =>
page.posts.map((post) => (
<div key={post.id} className="post-item">
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</div>
))
)}
</div>

<div ref={ref} className="loading-more">
{isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load more' : 'No more posts'}
</div>
</>
);
}

Real-World Application: Building a Dashboard

Let's put everything together in a real-world example of a dashboard component that fetches multiple related data points:

jsx
'use client';

import { useQueries, useQuery } from '@tanstack/react-query';

function DashboardPage() {
// Fetch user profile
const userQuery = useQuery({
queryKey: ['user-profile'],
queryFn: () => fetch('/api/profile').then(res => res.json()),
});

// Fetch multiple stats in parallel
const statsQueries = useQueries({
queries: [
{
queryKey: ['stats', 'revenue'],
queryFn: () => fetch('/api/stats/revenue').then(res => res.json()),
},
{
queryKey: ['stats', 'users'],
queryFn: () => fetch('/api/stats/users').then(res => res.json()),
},
{
queryKey: ['stats', 'orders'],
queryFn: () => fetch('/api/stats/orders').then(res => res.json()),
},
],
});

const isLoading = userQuery.isLoading || statsQueries.some(query => query.isLoading);
const isError = userQuery.isError || statsQueries.some(query => query.isError);

if (isLoading) return <div className="dashboard-loading">Loading dashboard data...</div>;
if (isError) return <div className="dashboard-error">Error loading dashboard data</div>;

// Extract data from queries
const { name, role } = userQuery.data;
const [revenueData, usersData, ordersData] = statsQueries.map(query => query.data);

return (
<div className="dashboard">
<header className="dashboard-header">
<h1>Welcome back, {name}</h1>
<p>Role: {role}</p>
</header>

<div className="stats-grid">
<div className="stat-card">
<h2>Revenue</h2>
<div className="stat-value">${revenueData.total.toLocaleString()}</div>
<div className="stat-trend">
{revenueData.trend > 0 ? '↑' : '↓'} {Math.abs(revenueData.trend)}%
</div>
</div>

<div className="stat-card">
<h2>Users</h2>
<div className="stat-value">{usersData.count.toLocaleString()}</div>
<div className="stat-trend">
{usersData.trend > 0 ? '↑' : '↓'} {Math.abs(usersData.trend)}%
</div>
</div>

<div className="stat-card">
<h2>Orders</h2>
<div className="stat-value">{ordersData.count.toLocaleString()}</div>
<div className="stat-trend">
{ordersData.trend > 0 ? '↑' : '↓'} {Math.abs(ordersData.trend)}%
</div>
</div>
</div>

{/* More dashboard sections could go here */}
</div>
);
}

Best Practices for React Query in Next.js

  1. Organize Query Keys: Use a consistent pattern for query keys to make invalidation and updates easier.

    jsx
    // Good pattern for query keys
    const userKey = ['users', userId];
    const userPostsKey = ['users', userId, 'posts'];
    const userPostKey = ['users', userId, 'posts', postId];
  2. Custom Hooks: Extract query logic into custom hooks for reusability:

    jsx
    // hooks/usePosts.js
    import { useQuery } from '@tanstack/react-query';

    export function usePosts() {
    return useQuery({
    queryKey: ['posts'],
    queryFn: async () => {
    const res = await fetch('/api/posts');
    if (!res.ok) throw new Error('Failed to fetch posts');
    return res.json();
    }
    });
    }
  3. Optimistic Updates: Improve UX by updating the UI before the server confirms:

    jsx
    const queryClient = useQueryClient();

    const mutation = useMutation({
    mutationFn: updateTodo,
    // Optimistically update the todo
    onMutate: async (newTodo) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });

    // Snapshot the previous value
    const previousTodo = queryClient.getQueryData(['todos', newTodo.id]);

    // Optimistically update to the new value
    queryClient.setQueryData(['todos', newTodo.id], newTodo);

    // Return context for potential rollback
    return { previousTodo };
    },
    // If the mutation fails, use the context we returned above
    onError: (err, newTodo, context) => {
    queryClient.setQueryData(
    ['todos', newTodo.id],
    context.previousTodo
    );
    },
    // Always refetch after error or success
    onSettled: (newTodo) => {
    queryClient.invalidateQueries({ queryKey: ['todos', newTodo.id] });
    },
    });
  4. Configure Default Options: Set sensible defaults globally:

    jsx
    const queryClient = new QueryClient({
    defaultOptions: {
    queries: {
    staleTime: 60000, // 1 minute
    cacheTime: 900000, // 15 minutes
    refetchOnWindowFocus: false,
    retry: 1,
    },
    },
    });

Summary

React Query transforms how you handle server state in Next.js applications by providing:

  1. Declarative Data Fetching: Simplify API calls with hooks that handle the complex logic
  2. Smart Caching: Reduce unnecessary network requests and improve app performance
  3. Background Updates: Keep data fresh with automatic refetching
  4. Loading and Error States: Handle all data states elegantly
  5. Mutation Support: Create, update, and delete data with optimistic updates
  6. Prefetching: Improve user experience with data preloading

By separating server state from UI state, React Query leads to cleaner code, better performance, and ultimately a better user experience. Its integration with Next.js makes it an ideal choice for modern, data-rich applications.

Additional Resources

Exercises

  1. Create a basic Next.js app that fetches and displays a list of products using React Query
  2. Add functionality to create, update, and delete products with mutations
  3. Implement infinite scrolling for a large list of items
  4. Create a data prefetching strategy for your main pages
  5. Build a dashboard that fetches data from multiple endpoints in parallel

With these exercises, you'll gain practical experience with React Query and understand how it can greatly simplify server state management in your Next.js applications.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)