Monday, December 9, 2024
Integrating React Query with Server Actions in Next.js 15
Posted by
Learn how to effectively combine React Query with Next.js Server Actions for efficient data fetching and state management. Includes complete setup guide and practical examples for CRUD operations.
Introduction
React Query is a powerful library that simplifies data fetching and state management in React applications. When combined with Next.js Server Actions, it creates a robust foundation for handling server-side operations. This guide will walk you through setting up and implementing this powerful combination.
Required Installations
First, let's set up the necessary dependencies:
# Clone the Starter Code
git clone https://github.com/MUKE-coder/react-query-starter-template.git rq-complete
cd rq-complete && pnpm install
# Create a neon database and Populate the DATABASE URL In the env
# Migrate the schema
npx prisma db push && pnpm run dev
# Install React Query and its peer dependencies
npm install @tanstack/react-query@latest @tanstack/react-query-devtools@latest
# If you haven't already, install Next.js
npm install next@latest react@latest react-dom@latest
Initializing React Query
Let's look at two approaches to initialize React Query in your Next.js application.
Basic Initialization
Create a new provider component in app/providers/ReactQueryProvider.tsx
:
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export default function ReactQueryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Advanced Initialization with Caching
Here's a version with 15-minute caching and additional configuration:
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export default function ReactQueryProvider({
children,
}: {
children: React.ReactNode;
}) {
// const [queryClient] = useState(() => new QueryClient());
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 15 * 60 * 1000, // 15 minutes
gcTime: 30 * 60 * 1000, // 30 minutes
refetchOnWindowFocus: false,
retry: 1,
},
},
})
);
// Note, typically gcTime should be longer than staleTime. Here's why:
// staleTime determines how long data is considered "fresh" before React Query will trigger a background refetch
// gcTime determines how long inactive data is kept in the cache before being removed entirely
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Add the provider to your root layout in app/layout.tsx
:
import ReactQueryProvider from "./providers/ReactQueryProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ReactQueryProvider>{children}</ReactQueryProvider>
</body>
</html>
);
}
Default Settings
Here's a comprehensive configuration of default query settings:
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 15 * 60 * 1000, // 15 minutes - how long data stays fresh
gcTime: 30 * 60 * 1000, // 30 minutes - how long inactive data is kept
refetchOnWindowFocus: false, // don't refetch when window regains focus
retry: 1, // number of retry attempts on failure
retryDelay: 3000, // 3 seconds between retry attempts
refetchOnMount: "always", // refetch on component mount
refetchOnReconnect: true, // refetch when reconnecting network
enabled: true, // query starts fetching immediately
networkMode: "online", // 'online' | 'always' | 'offlineFirst'
queryFn: undefined, // default query function (usually set per query)
throwOnError: false, // don't throw errors to error boundary
select: undefined, // transform function for the data
suspense: false, // don't use React Suspense
placeholderData: undefined, // temporary data while loading
structuralSharing: true, // enable structural sharing between query results
},
},
})
);
Each setting controls specific behavior:
Data freshness:
- staleTime: How long data is considered fresh
- gcTime: How long to keep inactive data in cache
Refetch behavior:
- refetchOnWindowFocus: Whether to refetch when window regains focus
- refetchOnMount: When to refetch when component mounts
- refetchOnReconnect: Whether to refetch when network reconnects
Error handling:
- retry: Number of retry attempts
- retryDelay: Delay between retries
- throwOnError: Whether to propagate errors to error boundary
Network and data:
- networkMode: How queries behave with network status
- select: Transform function for the returned data
- placeholderData: Temporary data while loading
- structuralSharing: Optimize re-renders through structural sharing
Type Definitions and Server Actions
Base Types
Let's start by defining our core types that will be used throughout the application:
// types.ts
type Contact = {
id: string;
name: string;
email: string;
phone: string;
imageUrl?: string;
notes?: string;
createdAt: Date;
updatedAt: Date;
};
// Server action return types
type QueriesResponse = {
data: Contact[];
error?: string;
};
// For single contact queries
type SingleQueryResponse = {
data: Contact | null;
error?: string;
};
// For mutation operations
type MutationResponse = {
success: boolean;
data?: Contact;
error?: string;
};
Server Actions Implementation
Create a new file for your server actions (app/actions/contacts.ts
):
// app/actions/contacts.ts
"use server";
import { db } from "@/lib/db";
export async function getContacts(): Promise<QueriesResponse> {
try {
const contacts = await db.contact.findMany({
orderBy: { createdAt: "desc" },
});
return { data: contacts };
} catch (error) {
return { data: [], error: "Failed to fetch contacts" };
}
}
export async function getContact(id: string): Promise<SingleQueryResponse> {
try {
const contact = await db.contact.findUnique({
where: { id },
});
return { data: contact };
} catch (error) {
return { data: null, error: "Failed to fetch contact" };
}
}
export async function createContact(
data: Omit<Contact, "id" | "createdAt" | "updatedAt">
): Promise<MutationResponse> {
try {
const contact = await db.contact.create({ data });
return { success: true, data: contact };
} catch (error) {
return { success: false, error: "Failed to create contact" };
}
}
export async function updateContact(
id: string,
data: Partial<Contact>
): Promise<MutationResponse> {
try {
const contact = await db.contact.update({
where: { id },
data,
});
return { success: true, data: contact };
} catch (error) {
return { success: false, error: "Failed to update contact" };
}
}
export async function deleteContact(id: string): Promise<MutationResponse> {
try {
await db.contact.delete({
where: { id },
});
return { success: true };
} catch (error) {
return { success: false, error: "Failed to delete contact" };
}
}
React Query Hooks
Now let's create custom hooks to use these server actions with React Query:
// app/hooks/useContacts.ts
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getContacts,
getContact,
createContact,
updateContact,
deleteContact,
} from "@/app/actions/contacts";
export function useContacts() {
const queryClient = useQueryClient();
// Query for fetching all contacts
const contactsQuery = useQuery({
queryKey: ["contacts"],
queryFn: getContacts,
});
// Create contact mutation
const createContactMutation = useMutation({
mutationFn: createContact,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["contacts"] });
},
});
// Update contact mutation
const updateContactMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Contact> }) =>
updateContact(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["contacts"] });
},
});
// Delete contact mutation
const deleteContactMutation = useMutation({
mutationFn: deleteContact,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["contacts"] });
},
});
return {
// Queries
contacts: contactsQuery.data?.data ?? [],
isLoading: contactsQuery.isLoading,
error: contactsQuery.error || contactsQuery.data?.error,
// Mutations
createContact: createContactMutation.mutate,
updateContact: updateContactMutation.mutate,
deleteContact: deleteContactMutation.mutate,
// Mutation states
isCreating: createContactMutation.isPending,
isUpdating: updateContactMutation.isPending,
isDeleting: deleteContactMutation.isPending,
};
}
// Hook for fetching a single contact
export function useContact(id: string) {
const queryClient = useQueryClient();
const contactQuery = useQuery({
queryKey: ["contact", id],
queryFn: () => getContactById(id),
select: (response) => ({
contact: response.data,
error: response.error,
}),
});
return {
contact: contactQuery.data?.contact,
error: contactQuery.error || contactQuery.data?.error,
isLoading: contactQuery.isLoading,
};
}
Example Usage with Error Handling
"use client";
import { useContacts } from "@/app/hooks/useContacts";
export function ContactActions() {
const { createContact, isCreating, error: createError } = useContacts();
const handleCreate = async (formData: FormData) => {
try {
await createContact({
name: formData.get("name") as string,
email: formData.get("email") as string,
phone: formData.get("phone") as string,
});
} catch (error) {
console.error("Failed to create contact:", error);
}
};
return (
<div>
{createError && <div className="error">{createError}</div>}
<form action={handleCreate}>
{/* Form fields */}
<button disabled={isCreating}>
{isCreating ? "Creating..." : "Create Contact"}
</button>
</form>
</div>
);
}
// ## DeTAIL Page
export default function ContactDetailPage({ id }: { id: string }) {
const { contact, isLoading, error } = useContact(id);
console.log(contact, id);
if (isLoading) {
return (
<>
<Sidebar count={0} />
<main className="flex-1 ml-64 flex items-center justify-center min-h-96">
<div className="">
<p>Loading...</p>
</div>
</main>
</>
);
}
if (error) {
return <div>Error: {error as string}</div>;
}
if (!contact) {
return notFound();
}
return (
<>
<Sidebar count={0} />
<main className="flex-1 ml-64">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full bg-blue-500 flex items-center justify-center text-white text-2xl">
{contact.name[0]}
</div>
<h1 className="text-2xl font-semibold">{contact.name}</h1>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href={`/contacts/${contact.id}/edit`}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</Link>
</Button>
<DeleteButton id={contact.id} />
</div>
</div>
<div className="space-y-6">
<div>
<h2 className="text-sm font-medium text-muted-foreground mb-2">
Contact details
</h2>
<div className="space-y-4">
<div className="flex gap-2">
<div className="w-24 text-sm">Email</div>
<div>{contact.email}</div>
</div>
<div className="flex gap-2">
<div className="w-24 text-sm">Phone</div>
<div>{contact.phone}</div>
</div>
<div className="flex gap-2">
<div className="w-24 text-sm">Image URL</div>
<div>{contact.imageUrl || "N/A"}</div>
</div>
{contact.notes && (
<div className="flex gap-2">
<div className="w-24 text-sm">Notes</div>
<div>{contact.notes}</div>
</div>
)}
</div>
</div>
</div>
</div>
</main>
</>
);
}
This part of the blog post covers the core implementation details. Would you like me to continue with Part 3, which will cover advanced patterns like optimistic updates and error handling strategies?