Skip to main content

Next.js React Query

Introduction

React Query, now part of the TanStack collection as TanStack Query, is a powerful data fetching and state management library that works excellently with Next.js applications. It provides a robust solution for handling server state, offering features like caching, background updates, and automatic refetching.

In this tutorial, we'll explore how to integrate React Query with Next.js to create efficient, responsive applications with optimized data fetching capabilities.

What is React Query?

React Query solves a fundamental challenge in React and Next.js applications: managing server state. While tools like Redux or Context API are great for managing client state, server state has unique characteristics:

  • Server data is stored remotely
  • Requires asynchronous APIs for fetching
  • Can become stale and need refreshing
  • Can be updated by multiple users simultaneously

React Query provides a comprehensive solution to these challenges, offering:

  • Automatic caching and stale data management
  • Background data refetching
  • Loading and error states
  • Pagination and infinite scrolling support
  • Data prefetching
  • Mutation capabilities (updating server data)

Getting Started with React Query in Next.js

Installation

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

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

Setting Up the React Query Provider

For React Query to work across your application, you need to set up a provider at the root level. In Next.js, this is typically done in your _app.js or app.js file:

jsx
// pages/_app.js (for Pages Router)
// or app/providers.jsx (for App Router)

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

export default function App({ Component, pageProps }) {
const [queryClient] = useState(() => new QueryClient());

return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
);
}

For App Router, you'd create a client component provider:

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

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

export function Providers({ children }) {
const [queryClient] = useState(() => new QueryClient());

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

And then use it 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 explore the most basic use case: fetching data from an API.

The useQuery Hook

The main hook from React Query for fetching data is useQuery. Here's a simple example:

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

function fetchPosts() {
return fetch('https://jsonplaceholder.typicode.com/posts')
.then(response => response.json());
}

function PostsList() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});

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

return (
<div>
<h1>Posts</h1>
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}

Understanding Query Keys

The queryKey is a unique identifier for your query. React Query uses this key to:

  • Cache query results
  • Share queries across components
  • Refetch or invalidate queries

Query keys can be simple strings or arrays for more complex queries:

jsx
// Simple key
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });

// Key with parameters
useQuery({
queryKey: ['todo', { id: 5 }],
queryFn: () => fetchTodoById(5)
});

Advanced React Query Features

Caching and Stale Time

React Query automatically caches query results. You can control caching behavior with options:

jsx
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes
cacheTime: 1000 * 60 * 30, // Data remains in cache for 30 minutes
});

Dependent Queries

Sometimes, you need one query to complete before running another:

jsx
function UserPosts() {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});

const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPostsByUser(user.id),
// Only run this query when user.id exists
enabled: !!user?.id,
});

// Render user posts
}

Mutations with useMutation

To modify server data, use the useMutation hook:

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

function CreatePost() {
const queryClient = useQueryClient();

const mutation = useMutation({
mutationFn: (newPost) => {
return fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: JSON.stringify(newPost),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
}).then(response => response.json());
},
onSuccess: (data) => {
// Invalidate and refetch the posts list
queryClient.invalidateQueries({ queryKey: ['posts'] });

// Or update the cache directly
queryClient.setQueryData(['posts'], (oldData) => {
return [...oldData, data];
});
},
});

const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
mutation.mutate({
title: formData.get('title'),
body: formData.get('body'),
userId: 1,
});
};

return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Post title" />
<textarea name="body" placeholder="Post content" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}

React Query with Next.js Server Components

With Next.js App Router and Server Components, we need to be careful about where React Query is used since it's a client-side library.

Keeping React Query in Client Components

Create a client component that uses React Query:

jsx
// app/posts/posts-list.jsx
'use client'

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

export default function PostsList() {
const { data, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(res => res.json()),
});

if (isLoading) return <div>Loading...</div>;

return (
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}

Then import it into your server component:

jsx
// app/posts/page.jsx
import PostsList from './posts-list';

export default function PostsPage() {
return (
<div>
<h1>Posts</h1>
<PostsList />
</div>
);
}

Hydration Strategy with Next.js

A common pattern is to prefetch data on the server and hydrate React Query on the client:

jsx
// app/api/posts/route.js
export async function GET() {
const posts = await fetchPosts(); // Your data fetching logic
return Response.json(posts);
}
jsx
// app/posts/page.jsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import PostsList from './posts-list';

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

await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: () => fetch('http://localhost:3000/api/posts').then(res => res.json()),
});

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div>
<h1>Posts</h1>
<PostsList />
</div>
</HydrationBoundary>
);
}

Real-World Application: Building a Blog

Let's build a simple blog application using React Query and Next.js:

jsx
// app/blog/post-list.jsx
'use client'

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

export default function PostList() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(res => res.json()),
});

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

return (
<div className="grid gap-4">
{data.map(post => (
<div key={post.id} className="border p-4 rounded-md">
<h2 className="text-xl font-bold">{post.title}</h2>
<p className="text-gray-600">{post.excerpt}</p>
<Link href={`/blog/${post.slug}`} className="text-blue-500">
Read more
</Link>
</div>
))}
</div>
);
}

For the single post page:

jsx
// app/blog/[slug]/post.jsx
'use client'

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

export default function Post({ slug }) {
const { data: post, isLoading, error } = useQuery({
queryKey: ['post', slug],
queryFn: () => fetch(`/api/posts/${slug}`).then(res => res.json()),
});

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

return (
<article className="prose max-w-prose mx-auto">
<h1>{post.title}</h1>
<div className="text-gray-500">Published on {post.date}</div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}

And the route handler:

jsx
// app/blog/[slug]/page.jsx
import Post from './post';

export default function PostPage({ params }) {
return <Post slug={params.slug} />;
}

Optimizing React Query Performance

Query Options for Performance

jsx
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
// Refetch options
refetchOnWindowFocus: false, // Don't refetch when window regains focus
refetchOnMount: false, // Don't refetch when component mounts
staleTime: Infinity, // Data is never considered stale
});

Pagination

jsx
function PaginatedPosts() {
const [page, setPage] = useState(1);

const { data, isLoading } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
keepPreviousData: true, // Keep showing previous data while loading next page
});

return (
<div>
{/* Post list */}
<div className="flex gap-2">
<button
onClick={() => setPage(old => Math.max(old - 1, 1))}
disabled={page === 1}
>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage(old => old + 1)}
disabled={!data?.hasMore}
>
Next
</button>
</div>
</div>
);
}

Infinite Queries

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

function InfinitePosts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['infinitePosts'],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage) => lastPage.nextPage,
});

return (
<div>
{data?.pages.map((group, i) => (
<div key={i}>
{group.posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
))}

<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load more'
: 'Nothing more to load'}
</button>
</div>
);
}

Summary

React Query is an excellent tool for managing server state in Next.js applications, offering:

  • Efficient data fetching with automatic caching
  • Easy loading and error states
  • Background updates and refetching
  • Powerful mutation capabilities
  • Pagination and infinite scrolling support

When using React Query with Next.js, particularly with the App Router and server components, remember to:

  1. Keep React Query in client components
  2. Use the 'use client' directive when working with React Query hooks
  3. Consider hydration strategies for prefetched data

By integrating React Query into your Next.js application, you can create a more responsive, efficient user experience with optimized data fetching.

Additional Resources

Exercises

  1. Create a simple Next.js application that displays a list of users fetched from an API using React Query
  2. Add pagination or infinite scrolling to your user list
  3. Implement a form to create or update user data using useMutation
  4. Build a data-dependent view where one query depends on the result of another
  5. Optimize your React Query implementation by configuring appropriate staleTime and cacheTime settings


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