Back to blog

Monday, December 9, 2024

Integrating React Query with Server Actions in Next.js 15

cover

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?