Next.js Server Actions & Tanstack Query

Das Dream-Team für dein Datenmanagement?

06.04.2025

TanStackQuerydenext.jsreact
Gregor Wedlich
Gregor Wedlich
Life, the Universe and Everything.
Donate with:
Lightning
Alby

Inhaltsverzeichnis

    Aktuell – oder besser gesagt schon seit einiger Zeit – stelle ich meinen Blog von der alten Next.js Pages Router-Variante auf den neuen App Router um, der seit Next.js 13 verfügbar ist. Dabei experimentiere ich mit modernen Techniken und lasse diese bewusst in meinen Blog einfließen. Ursprünglich entstand der Blog in einer Zeit, in der ich noch wenig Erfahrung mit Next.js und React hatte. In den letzten drei Jahren habe ich jedoch enorm dazugelernt. Damit ich meinen Fortschritt und mein Wissen nicht nur für mich behalte, habe ich mich an einen sehr ausführlichen Beitrag gesetzt, der unser heutiges Thema aufgreift. Ich musste einige Anläufe nehmen, bis alles so feinjustiert war, wie es jetzt ist. Falls ihr Fragen, Probleme oder Anregungen habt, nutzt gern die Kommentarfunktion.

    Next.js Server Actions & Tanstack Query: Das Dream-Team für dein Datenmanagement?

    Stell dir vor, du baust eine interaktive Webanwendung mit Next.js. Du holst Daten, zeigst sie an, und natürlich sollen deine Benutzer auch Daten ändern können – sei es durch das Absenden eines Formulars, das Klicken auf einen "Löschen"-Button oder das Umschalten eines Status. Schnell stehst du vor klassischen Fragen: Wie organisiere ich den Datenabruf effizient? Wie halte ich die angezeigten Daten aktuell, ohne ständig alles neu zu laden? Und vor allem: Wie implementiere ich diese Änderungen (Mutationen) sicher und ohne mich in einem Wust aus API-Endpunkten und Client-seitiger State-Logik zu verlieren?

    Hier kommen zwei moderne Werkzeuge ins Spiel, die im Next.js-Ökosystem immer beliebter werden und zusammen ein echtes Power-Duo bilden: Next.js Server Actions und Tanstack React Query (v5).

    Server Actions revolutionieren die Art, wie wir Mutationen handhaben. Statt separate API-Routen zu bauen, schreiben wir einfach serverseitige Funktionen, die wir direkt und typsicher aus unseren React-Komponenten aufrufen können. Das vereinfacht das Schreiben von Daten enorm.

    Auf der anderen Seite ist Tanstack Query der unangefochtene Champion, wenn es um das Management von Server-Status auf dem Client geht. Es kümmert sich meisterhaft um das Abrufen, Cachen, Synchronisieren und Aktualisieren von Daten im Hintergrund. Mit Hooks wie useQuery wird das Anzeigen von Server-Daten zum Kinderspiel.

    Warum also die Kombination? Weil sie sich perfekt ergänzen! Tanstack Query übernimmt das intelligente Lesen und Cachen der Daten auf dem Client, während Server Actions das Schreiben der Daten auf dem Server vereinfachen. Stell dir unsere Beispiel-Anwendung vor, eine einfache Todo-Liste:

    • Die Liste der Todos anzeigen? Ein klarer Fall für useQuery.
    • Ein neues Todo über ein Formular hinzufügen oder ein bestehendes löschen? Hier glänzt eine Server Action, idealerweise orchestriert durch React Query's useMutation-Hook für eine nahtlose Integration mit dem Client-Status und Features wie Ladezuständen oder sogar Optimistic Updates.

    In dieser Anleitung bauen wir Schritt für Schritt genau so eine Todo-App. Wir starten mit einem einfachen Next.js-Setup und integrieren nach und nach Tanstack Query und Server Actions, um zu zeigen, wie dieses Duo dir helfen kann, robustere, effizientere und entwicklerfreundlichere Webanwendungen zu erstellen. Folge den einzelnen Git-Branches, um jeden Schritt nachzuvollziehen!

    Schritt 1: Das Fundament – Prisma, Tanstack Query einrichten

    ➡️ Hier geht zum Source bei Github

    Jedes Bauprojekt beginnt mit einem soliden Fundament, und unsere Todo-App ist da keine Ausnahme. Bevor wir uns dem eigentlichen Datenmanagement mit Tanstack Query und Server Actions widmen, müssen wir unser Next.js Projekt mit den notwendigen Werkzeugen ausstatten. Los geht's!

    Zuerst installieren wir die benötigten Abhängigkeiten über die Kommandozeile mit pnpm:

    1# Prisma für die Datenbank und Tanstack Query für den Client-State 2pnpm add prisma @prisma/client @tanstack/react-query 3 4# Die DevTools für Tanstack Query (passend zu v5) 5pnpm add @tanstack/react-query-devtools@latest --save-dev

    Mit den Paketen an Bord initialisieren wir Prisma:

    1pnpm dlx prisma init

    Dieser Befehl erstellt das prisma-Verzeichnis mit einer schema.prisma-Datei und eine .env-Datei. In die .env-Datei tragen wir unsere PostgreSQL-Datenbank-URL ein (ersetze die Platzhalter):

    1# .env 2DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public"

    Anschließend definieren wir unser Datenmodell in prisma/schema.prisma:

    1// prisma/schema.prisma 2generator client { 3 provider = "prisma-client-js" 4} 5 6datasource db { 7 provider = "postgresql" 8 url = env("DATABASE_URL") 9} 10 11model Todo { 12 id String @id @default(cuid()) 13 title String 14 completed Boolean @default(false) 15 createdAt DateTime @default(now()) 16 updatedAt DateTime @updatedAt 17}

    Um diese Struktur in der Datenbank anzulegen, führen wir die erste Migration durch:

    1pnpm dlx prisma migrate dev --name init

    Damit wir später etwas zum Anzeigen haben, fügen wir einige Beispieldaten hinzu. Wir erstellen die Datei prisma/seed.ts:

    1// prisma/seed.ts 2import { PrismaClient } from '@prisma/client'; 3const prisma = new PrismaClient(); 4 5async function main() { 6 console.log(`Start seeding ...`); 7 8 // Vorhandene Todos löschen (optional, aber oft nützlich für wiederholtes Seeding) 9 await prisma.todo.deleteMany(); 10 console.log('Deleted existing todos.'); 11 12 // Beispiel-Todos erstellen 13 const todo1 = await prisma.todo.create({ 14 data: { title: 'Learn Tanstack Query' }, 15 }); 16 const todo2 = await prisma.todo.create({ 17 data: { title: 'Master Server Actions', completed: true }, 18 }); 19 const todo3 = await prisma.todo.create({ 20 data: { title: 'Write Blog Post' }, 21 }); 22 23 console.log({ todo1, todo2, todo3 }); 24 console.log(`Seeding finished.`); 25} 26 27main() 28 .catch((e) => { 29 console.error(e); 30 process.exit(1); 31 }) 32 .finally(async () => { 33 await prisma.$disconnect(); 34 });

    Um dieses Skript einfach auszuführen, fügen wir es zur package.json hinzu:

    1// package.json 2{ 3 "scripts": { 4 // ... andere Skripte ... 5 "seed": "ts-node --compiler-options {\\\"module\\\":\\\"CommonJS\\\"} prisma/seed.ts" 6 } 7}

    Wir brauchen noch das Paket ts-node:

    1pnpm add ts-node

    Und jetzt können wir den Seed auf die DB ausrollen:

    1pnpm run seed 2# oder: pnpm prisma db seed

    Für eine effiziente Nutzung des Prisma Clients erstellen wir die Datei lib/prisma.ts, die eine global wiederverwendbare Instanz exportiert:

    1// lib/prisma.ts 2import { PrismaClient } from "@prisma/client"; 3 4// Declare a global variable to hold the PrismaClient instance. 5// This is necessary to retain the instance across hot reloads during development. 6declare global { 7 // eslint-disable-next-line no-var 8 var prisma: PrismaClient | undefined; 9} 10 11// Create the PrismaClient instance or use the existing global instance. 12// In production mode, a new instance is always created. 13// In development, the global instance is reused if it exists, 14// to prevent creating a new connection on every hot reload. 15export const prisma = 16 global.prisma || 17 new PrismaClient({ 18 // Optional: Log configuration for debugging 19 // log: ['query', 'info', 'warn', 'error'], 20 }); 21 22// If we are in development mode, assign the created instance 23// to the global variable. 24if (process.env.NODE_ENV !== "production") { 25 global.prisma = prisma; 26}

    Beim Styling mit TailwindCSS passen wir das Root-Layout in app/layout.tsx an, um einen grundlegenden Rahmen zu schaffen:

    1// app/layout.tsx 2export default function RootLayout({ children }: /* ... */) { 3 return ( 4 <html lang="en"> 5 <body className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-50 text-gray-900`}> 6 <main className="flex min-h-screen flex-col items-center p-8"> 7 {children} 8 </main> 9 </body> 10 </html> 11 ); 12}

    Zuletzt bereiten wir Tanstack React Query vor. Wir erstellen die Client-Komponente app/QueryProvider.tsx, die den QueryClient initialisiert und bereitstellt:

    1// app/QueryProvider.tsx 2"use client"; 3 4import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 6import { useState } from "react"; 7 8// Define the interface for the props 9interface QueryProviderProps { 10 children: React.ReactNode; 11} 12 13// The provider component 14export function QueryProvider({ children }: QueryProviderProps) { 15 // Create a stable QueryClient instance with useState 16 // to ensure it is not recreated on every render. 17 const [queryClient] = useState( 18 () => 19 new QueryClient({ 20 defaultOptions: { 21 queries: { 22 // Default options for all queries, e.g., staleTime 23 staleTime: 1000 * 60 * 5, // 5 minutes 24 // refetchOnWindowFocus: false, // Optional: Disable refetch on window focus 25 }, 26 }, 27 }) 28 ); 29 30 return ( 31 // Provide the client via the provider 32 <QueryClientProvider client={queryClient}> 33 {children} 34 {/* The React Query DevTools are very useful for development */} 35 <ReactQueryDevtools initialIsOpen={false} /> 36 </QueryClientProvider> 37 ); 38}

    Um diesen Provider global verfügbar zu machen, importieren wir ihn in app/layout.tsx und umschließen damit unsere <main>-Inhalte:

    1// app/layout.tsx 2import { QueryProvider } from "./QueryProvider"; // Importieren 3 4export default function RootLayout({ children }: /* ... */) { 5 return ( 6 <html lang="en"> 7 <body className={/* ... */}> 8 <QueryProvider> {/* Provider hinzugefügt */} 9 <main className="flex min-h-screen flex-col items-center p-8"> 10 {children} 11 </main> 12 </QueryProvider> 13 </body> 14 </html> 15 ); 16}

    Jetzt noch schnell die page.tsx bereinigen und einen Platzhalter einfügen.

    1// app/page.tsx 2export default function Home() { 3 return <div className="text-center text-gray-500">Todo App</div>; 4}

    Puh, das Fundament steht! Wir haben jetzt eine funktionierende Prisma-Anbindung, ein grundlegendes Tailwind-Layout und einen bereitgestellten Tanstack Query Client. Damit sind wir perfekt vorbereitet, um im nächsten Schritt die ersten Daten anzuzeigen!

    Schritt 2: Daten zum Leben erwecken – Todos anzeigen mit useQuery

    Das Fundament steht, jetzt wollen wir endlich Daten sehen! In diesem Schritt nutzen wir die Kernfunktionalität von Tanstack Query, den useQuery-Hook, um unsere Todos aus der Datenbank zu lesen und auf der Startseite anzuzeigen.

    Zuerst definieren wir die Logik zum Abrufen der Daten serverseitig. Wir erstellen die Datei lib/queries/getTodos.ts. Diese enthält die Funktion getTodos, die mithilfe unseres Prisma Clients alle Todo-Einträge (sortiert nach Erstellungsdatum) aus der Datenbank holt. Wichtig ist hier auch das Todo-Interface, das die Struktur unserer Daten beschreibt:

    1// lib/queries/getTodos.ts 2import { prisma } from "@/lib/prisma"; 3 4export interface Todo { 5 id: string; 6 title: string; 7 completed: boolean; 8 createdAt: Date; 9 updatedAt: Date; 10} 11 12/** 13 * Fetches all todos from the database. 14 * This function is intended to be called server-side 15 * (e.g., by the queryFn of useQuery, which can be executed server-side in Next.js). 16 * @returns A promise that resolves to an array of Todo objects. 17 */ 18export async function getTodos(): Promise<Todo[]> { 19 try { 20 const todos = await prisma.todo.findMany({ 21 orderBy: { createdAt: "desc" }, 22 }); 23 return todos; 24 } catch (error) { 25 console.error("Error fetching todos:", error); 26 throw new Error("Failed to fetch todos from database."); 27 } 28}

    Da unsere TodoList-Komponente eine Client Component sein wird ("use client"), kann sie die getTodos-Funktion (die Prisma nutzt) nicht direkt sicher aufrufen, ohne einen Fehler im Browser zu riskieren. Deshalb erstellen wir eine API Route als Brücke.

    Warum eine API Route und keine Server Action oder RSC?

    Jetzt kommt ein wichtiger Punkt im Kontext des Next.js App Routers: Unsere TodoList-Komponente, die wir gleich erstellen, wird useQuery von Tanstack Query verwenden, um die Vorteile des clientseitigen Cachings und State Managements zu nutzen. Das bedeutet, sie muss eine Client Component ("use client") sein.

    Eine Client Component kann jedoch nicht direkt auf server-exklusive Ressourcen wie unseren Prisma Client zugreifen (das würde zu einem Fehler im Browser führen).

    • Könnten wir eine React Server Component (RSC) verwenden? Ja, eine RSC könnte getTodos() direkt aufrufen. Aber dann könnten wir useQuery nicht für das clientseitige Management nutzen.
    • Könnten wir eine Server Action für das Lesen verwenden? Technisch möglich, aber Server Actions sind primär für Mutationen (Schreibvorgänge) gedacht und nicht das empfohlene Muster für reine Lesezugriffe mit useQuery, da es Caching-Mechanismen umgehen kann.

    Daher ist der sauberste und empfohlene Weg, wenn eine Client Component Daten benötigt, die Server-Logik erfordern: Wir erstellen eine API Route (Route Handler) als Brücke.

    Wir legen also die Datei app/api/todos/route.ts an:

    1// app/api/todos/route.ts 2import { NextResponse } from "next/server"; 3import { getTodos } from "@/lib/queries/getTodos"; 4/** 5 * API Route Handler for fetching all todos. 6 * Called by client-side useQuery. 7 */ 8export async function GET() { 9 try { 10 const todos = await getTodos(); 11 12 return NextResponse.json(todos); 13 } catch (error) { 14 console.error("API Error fetching todos:", error); 15 16 const errorMessage = 17 error instanceof Error ? error.message : "Internal Server Error"; 18 return NextResponse.json( 19 { error: "Failed to fetch todos", details: errorMessage }, 20 { status: 500 } 21 ); 22 } 23}

    Dieser Route Handler läuft sicher auf dem Server und stellt unsere Todos unter dem Pfad /api/todos bereit.

    Jetzt kommt der Kern dieses Schrittes: die Client-Komponente components/TodoList.tsx. Hier nutzen wir useQuery:

    1// components/TodoList.tsx 2"use client"; 3import { useQuery } from "@tanstack/react-query"; 4import { type Todo } from "@/lib/queries/getTodos"; 5 6async function fetchTodos(): Promise<Todo[]> { 7 const response = await fetch("/api/todos"); 8 if (!response.ok) { 9 // Attempt to extract error details from the JSON response 10 const errorData = await response.json().catch(() => ({})); // Fallback in case of parsing errors 11 throw new Error( 12 errorData?.details || errorData?.error || "Failed to fetch todos" 13 ); 14 } 15 return response.json(); 16} 17 18export function TodoList() { 19 const { 20 data: todos, 21 isLoading, 22 isError, 23 error, 24 } = useQuery<Todo[], Error>({ 25 // queryKey remains the same 26 queryKey: ["todos"], 27 // queryFn now calls our fetchTodos function 28 queryFn: fetchTodos, 29 }); 30 31 if (isLoading) 32 return ( 33 <div className="text-center text-gray-500"> 34 <p>Loading todos...</p> 35 </div> 36 ); 37 if (isError) 38 return ( 39 <div className="text-center text-red-600 bg-red-100 p-4 rounded-md"> 40 <p>Error: {error?.message}</p> 41 </div> 42 ); 43 44 return ( 45 <div className="w-full max-w-md bg-white shadow-md rounded-lg p-6"> 46 <h1 className="text-2xl font-semibold mb-4 text-center text-gray-800"> 47 My Todos 48 </h1> 49 {todos && todos.length > 0 ? ( 50 <ul className="space-y-3"> 51 {todos.map((todo) => ( 52 <li key={todo.id} className="p-3 bg-gray-100 rounded-md text-gray-800"> 53 {todo.title} 54 </li> 55 ))} 56 </ul> 57 ) : ( 58 <p className="text-center text-gray-500">No todos yet!</p> 59 )} 60 </div> 61 ); 62}

    Wir definieren eine fetchTodos-Funktion, die unseren /api/todos-Endpunkt aufruft. Diese Funktion übergeben wir als queryFn an useQuery. Der Hook kümmert sich um den Rest: Daten abrufen, Caching, Lade- (isLoading) und Fehlerzustände (isError, error) verwalten. Wir nutzen diese Zustände, um dem Benutzer entsprechendes Feedback zu geben. Die eigentliche Darstellung der Todos erfolgt durch Mappen über das todos-Array (die TodoItem-Komponente fügen wir erst später hinzu).

    Zuletzt binden wir die TodoList in unsere app/page.tsx ein:

    1// app/page.tsx 2import { TodoList } from "@/components/TodoList"; 3 4export default function Home() { 5 return <TodoList />; 6}

    Das Ergebnis: Unsere Anwendung zeigt nun dynamisch die Todos aus der Datenbank an, holt sie über eine saubere API-Route und behandelt Lade- sowie Fehlerzustände elegant – alles dank useQuery! Im nächsten Schritt fügen wir die Möglichkeit hinzu, neue Todos über ein Formular und eine Server Action zu erstellen.

    Schritt 3: Die erste Server Action

    Nachdem wir unsere Todos erfolgreich anzeigen können, wird es Zeit, interaktiv zu werden! In diesem Schritt implementieren wir die Funktionalität zum Hinzufügen neuer Todos mithilfe einer Next.js Server Action.

    Zuerst erstellen wir unsere Server Action selbst. Wir legen die Datei app/actions/todoActions.ts an und markieren sie mit "use server";. Darin definieren wir unsere addTodo-Funktion und exportieren das ActionResult-Interface (das wir auch für spätere Aktionen wiederverwenden):

    1// app/actions/todoActions.ts 2"use server"; 3import { prisma } from "@/lib/prisma"; 4 5export interface ActionResult { 6 success: boolean; 7 error?: string; 8} 9 10/** 11 * Server action for adding a new todo. 12 * Accepts FormData from a form. 13 * @param formData The data submitted by the form. 14 * @returns A promise that resolves to an ActionResult object. 15 */ 16export async function addTodo(formData: FormData): Promise<ActionResult> { 17 // Extract the title from the form data 18 const title = formData.get("title") as string; 19 20 // Simple validation: Check if the title exists and is not just whitespace 21 if (!title || title.trim().length === 0) { 22 return { success: false, error: "Title cannot be empty." }; 23 } 24 25 try { 26 await prisma.todo.create({ 27 data: { 28 title: title.trim(), 29 }, 30 }); 31 32 console.log(`Todo added: ${title.trim()}`); 33 return { success: true }; 34 } catch (error) { 35 console.error("Error adding todo:", error); 36 return { 37 success: false, 38 error: "Failed to add todo to the database. Please try again.", 39 }; 40 } 41}

    Diese Funktion nimmt FormData entgegen, validiert den title, versucht, das Todo über Prisma zu erstellen und gibt unser ActionResult-Objekt zurück. Kein separater API-Endpunkt nötig!

    Als Nächstes brauchen wir das Frontend dafür. Wir erstellen die Client-Komponente components/AddTodoForm.tsx:

    1// components/AddTodoForm.tsx 2"use client"; 3import { addTodo } from "@/app/actions/todoActions"; 4import { useRef } from "react"; 5 6export function AddTodoForm() { 7 const formRef = useRef<HTMLFormElement>(null); 8 9 // This function is executed on the client side but calls the server action. 10 async function formAction(formData: FormData) { 11 const result = await addTodo(formData); // Call the server action 12 if (result.success) { 13 formRef.current?.reset(); // Reset the form on success 14 console.log("Form submitted successfully, list update pending..."); 15 } else { 16 // Temporary error display 17 alert(`Error: ${result.error}`); 18 } 19 } 20 21 return ( 22 <form 23 ref={formRef} 24 action={formAction} // Bind the action directly to the form 25 className="flex items-center gap-2 p-4 bg-white shadow-md rounded-lg mt-8 w-full max-w-md" 26 > 27 <input 28 type="text" 29 name="title" 30 placeholder="Add a new todo..." 31 required 32 className="flex-grow p-2 border border-gray-300 rounded-md text-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-500" 33 /> 34 <button 35 type="submit" 36 className="px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2" 37 > 38 Add 39 </button> 40 </form> 41 ); 42}

    Wir verwenden hier das action-Attribut des Formulars, um unsere formAction-Wrapper-Funktion aufzurufen. Diese ruft dann die addTodo Server Action auf und behandelt das Ergebnis (vorerst nur Reset oder Alert).

    Zuletzt aktualisieren wir die Startseite app/page.tsx, um das neue Formular unterhalb der Liste anzuzeigen:

    1// app/page.tsx 2import { AddTodoForm } from "@/components/AddTodoForm"; 3import { TodoList } from "@/components/TodoList"; 4 5export default function Home() { 6 return ( 7 <> 8 <TodoList /> 9 <AddTodoForm /> {/* Formular hinzugefügt */} 10 </> 11 ); 12}

    Das Ergebnis: Wir können jetzt neue Todos eingeben und sie werden erfolgreich in der Datenbank gespeichert! Allerdings sehen wir sie noch nicht sofort in der Liste – die UI aktualisiert sich nicht automatisch. Dieses Problem lösen wir im nächsten Schritt, indem wir Tanstack Query (useMutation) ins Spiel bringen.

    Schritt 4: Die Brücke bauen – useMutation trifft Server Action

    Im letzten Schritt konnten wir zwar Todos hinzufügen, aber unsere Liste hat davon nichts mitbekommen. Das ändern wir jetzt! Hier kommt die wahre Stärke der Kombination aus Tanstack Query und Server Actions zum Vorschein. Wir nutzen den useMutation-Hook von Tanstack Query, um unsere addTodo-Action aufzurufen und die UI nach einem erfolgreichen Vorgang nahtlos zu aktualisieren.

    Dazu überarbeiten wir unsere components/AddTodoForm.tsx. Statt die Server Action direkt im action-Attribut des Formulars zu verwenden, binden wir sie nun an useMutation:

    1// components/AddTodoForm.tsx 2"use client"; 3 4import { addTodo, type ActionResult } from "@/app/actions/todoActions"; 5import { useMutation, useQueryClient } from "@tanstack/react-query"; 6import { useRef, useState } from "react"; 7 8export function AddTodoForm() { 9 const formRef = useRef<HTMLFormElement>(null); 10 const queryClient = useQueryClient(); 11 const [errorMessage, setErrorMessage] = useState<string | null>(null); // State for UI errors 12 13 // Initialize useMutation 14 const { mutate, isPending } = useMutation<ActionResult, Error, FormData>({ 15 mutationFn: addTodo, // Here we pass our server action! 16 onSuccess: (data) => { 17 // This callback is executed when mutationFn succeeds 18 // (i.e., does not throw an error) 19 if (data.success) { 20 // If the action itself was successful ({ success: true }) 21 console.log("Todo added successfully via useMutation!"); 22 // *** Here's where the magic happens! *** 23 // Invalidate the query with the key 'todos'. 24 // React Query will now automatically refetch the data for 'todos'. 25 queryClient.invalidateQueries({ queryKey: ["todos"] }); 26 formRef.current?.reset(); // Reset the form 27 setErrorMessage(null); // Clear old error messages 28 } else { 29 // If the action ran but returned { success: false } 30 setErrorMessage(data.error || "An unknown error occurred."); 31 } 32 }, 33 onError: (error) => { 34 // This callback is executed when mutationFn throws an error 35 // (e.g., network error, or if the action itself crashes) 36 console.error("Mutation error:", error); 37 setErrorMessage(error.message || "Failed to submit the form."); 38 }, 39 }); 40 41 // Custom submit handler for the form 42 function handleSubmit(event: React.FormEvent<HTMLFormElement>) { 43 event.preventDefault(); // Prevent default behavior 44 const formData = new FormData(event.currentTarget); 45 const title = formData.get("title") as string; 46 47 // Client-side validation (optional, but good for UX) 48 if (!title || title.trim().length === 0) { 49 setErrorMessage("Title cannot be empty."); 50 return; 51 } 52 53 setErrorMessage(null); // Reset errors before sending 54 // Call the mutate function from useMutation to trigger the action 55 mutate(formData); 56 } 57 58 return ( 59 // Bind the handler to onSubmit 60 <form 61 ref={formRef} 62 onSubmit={handleSubmit} 63 className="flex flex-col gap-4 p-4 bg-white shadow-md rounded-lg mt-8 w-full max-w-md" 64 > 65 <div className="flex items-center gap-2"> 66 <input 67 type="text" 68 name="title" 69 placeholder="Add a new todo..." 70 required 71 disabled={isPending} // Disable while the mutation is running 72 className="flex-grow p-2 border border-gray-300 rounded-md text-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-500 disabled:bg-gray-100" 73 /> 74 <button 75 type="submit" 76 disabled={isPending} // Disable while the mutation is running 77 className="px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 disabled:opacity-50" 78 > 79 {/* Show different text depending on loading state */} 80 {isPending ? "Adding..." : "Add"} 81 </button> 82 </div> 83 {/* Display the error message from the state */} 84 {errorMessage && ( 85 <p className="text-sm text-red-700 bg-red-50 p-2 rounded-md"> 86 Error: {errorMessage} 87 </p> 88 )} 89 </form> 90 ); 91}

    Was ist hier passiert?

    1. useMutation: Wir initialisieren den Hook und übergeben unsere addTodo Server Action als mutationFn.
    2. mutate Funktion: Statt action im Formular nutzen wir nun onSubmit und rufen die mutate-Funktion auf, die uns useMutation gibt. Diese löst die Server Action aus.
    3. isPending State: Wir erhalten einen Ladezustand, mit dem wir den Button und das Inputfeld während des Hinzufügens deaktivieren und den Button-Text ändern können – wichtiges UI-Feedback!
    4. onSuccess Callback: Hier geschieht die Aktualisierung! Bei Erfolg (data.success === true) rufen wir queryClient.invalidateQueries({ queryKey: ['todos'] }) auf. Das ist das Signal für React Query: "Die Daten für 'todos' sind veraltet!". React Query fetcht daraufhin die Daten im Hintergrund neu (über unsere /api/todos-Route), und die TodoList-Komponente rendert sich mit den frischen Daten neu. Wir setzen auch das Formular zurück.
    5. Fehlerbehandlung: Fehler, die von der Action zurückgegeben werden (data.error) oder während der Ausführung auftreten (onError), speichern wir jetzt in einem useState, um sie sauber unter dem Formular anzuzeigen.

    Das Ergebnis: Das Hinzufügen von Todos funktioniert jetzt nahtlos! Die Liste aktualisiert sich automatisch, wir haben Ladezustände und eine saubere Fehleranzeige. Im nächsten Schritt wenden wir dieses useMutation-Muster auf das Aktualisieren und Löschen von Todos an.

    Schritt 5: Interaktion pur – Todos bearbeiten und löschen

    Unsere Todo-Liste lebt! Wir können Todos anzeigen und neue hinzufügen. Jetzt gehen wir einen Schritt weiter und implementieren die Kerninteraktionen: das Abhaken (Status ändern) und das Löschen von Todos. Das Muster bleibt dabei erfreulich konsistent.

    Zuerst erweitern wir unsere Server Actions in app/actions/todoActions.ts. Wir fügen zwei neue Funktionen hinzu: toggleTodoStatus und deleteTodo. Beide nehmen FormData entgegen, führen die entsprechende Prisma-Operation (update bzw. delete) aus und geben unser bekanntes ActionResult-Objekt zurück. Wir bauen auch eine spezifischere Fehlerbehandlung für nicht gefundene Todos ein:

    1// app/actions/todoActions.ts 2"use server"; 3import { prisma } from "@/lib/prisma"; 4import { Prisma } from "@prisma/client"; // New 5 6export interface ActionResult { /* ... */ } 7export async function addTodo(formData: FormData): Promise<ActionResult> { /* ... */ } 8 9/** 10 * Server action for toggling the 'completed' status of a todo. 11 * @param formData Must contain 'id' and 'completed' (as a string 'true'/'false'). 12 * @returns A promise that resolves to an ActionResult object. 13 */ 14export async function toggleTodoStatus( 15 formData: FormData 16): Promise<ActionResult> { 17 const id = formData.get("id") as string; 18 const completed = formData.get("completed") === "true"; // Convert string to boolean 19 20 if (!id) { 21 return { success: false, error: "Todo ID is required." }; 22 } 23 24 try { 25 await prisma.todo.update({ 26 where: { id }, 27 data: { completed }, 28 }); 29 console.log(`Todo ${id} status toggled to ${completed}`); 30 return { success: true }; 31 } catch (error) { 32 console.error(`Error toggling todo ${id}:`, error); 33 34 if ( 35 error instanceof Prisma.PrismaClientKnownRequestError && 36 error.code === "P2025" // Prisma code for "Record to update not found." 37 ) { 38 return { success: false, error: "Todo not found." }; 39 } 40 return { 41 success: false, 42 error: "Failed to update todo status. Please try again.", 43 }; 44 } 45} 46 47/** 48 * Server action for deleting a todo. 49 * @param formData Must contain 'id'. 50 * @returns A promise that resolves to an ActionResult object. 51 */ 52export async function deleteTodo(formData: FormData): Promise<ActionResult> { 53 const id = formData.get("id") as string; 54 55 if (!id) { 56 return { success: false, error: "Todo ID is required." }; 57 } 58 59 try { 60 await prisma.todo.delete({ 61 where: { id }, 62 }); 63 console.log(`Todo ${id} deleted`); 64 return { success: true }; 65 } catch (error) { 66 console.error(`Error deleting todo ${id}:`, error); 67 68 if ( 69 error instanceof Prisma.PrismaClientKnownRequestError && 70 error.code === "P2025" // Prisma code for "Record to delete not found." 71 ) { 72 return { success: false, error: "Todo not found." }; 73 } 74 return { 75 success: false, 76 error: "Failed to delete todo. Please try again.", 77 }; 78 } 79}

    Um diese Aktionen im Frontend auszulösen, erstellen wir die Komponente components/TodoItem.tsx. Sie ist für die Darstellung eines einzelnen Todos verantwortlich und enthält die Logik für die Interaktionen:

    1// components/TodoItem.tsx 2"use client"; 3import { useState } from "react"; 4import { useMutation, useQueryClient } from "@tanstack/react-query"; 5import { type Todo } from "@/lib/queries/getTodos"; 6import { toggleTodoStatus, deleteTodo, type ActionResult } from "@/app/actions/todoActions"; 7 8interface TodoItemProps { todo: Todo; } 9interface ToggleTodoVariables { id: string; completed: boolean; } 10interface DeleteTodoVariables { id: string; } 11interface TodoItemContext { previousTodos?: Todo[]; } 12 13export function TodoItem({ todo }: TodoItemProps) { 14 const queryClient = useQueryClient(); 15 const [itemError, setItemError] = useState<string | null>(null); 16 17 // Mutation für Toggle 18 const { mutate: mutateToggle, isPending: isToggling } = useMutation<ActionResult, Error, ToggleTodoVariables, TodoItemContext>({ 19 mutationFn: async ({ id, completed }) => { 20 const formData = new FormData(); 21 formData.append("id", id); 22 formData.append("completed", String(completed)); 23 return toggleTodoStatus(formData); 24 }, 25 onSuccess: (data) => { 26 if (data.success) { 27 queryClient.invalidateQueries({ queryKey: ["todos"] }); 28 setItemError(null); 29 } else { 30 setItemError(data.error || "Failed to toggle status."); 31 } 32 }, 33 onError: (error) => setItemError(error.message || "An error occurred."), 34 }); 35 36 // Mutation für Delete 37 const { mutate: mutateDelete, isPending: isDeleting } = useMutation<ActionResult, Error, DeleteTodoVariables, TodoItemContext>({ 38 mutationFn: async ({ id }) => { 39 const formData = new FormData(); 40 formData.append("id", id); 41 return deleteTodo(formData); 42 }, 43 onSuccess: (data) => { 44 if (data.success) { 45 queryClient.invalidateQueries({ queryKey: ["todos"] }); 46 setItemError(null); 47 } else { 48 setItemError(data.error || "Failed to delete todo."); 49 } 50 }, 51 onError: (error) => setItemError(error.message || "An error occurred."), 52 }); 53 54 function handleToggle() { 55 setItemError(null); 56 mutateToggle({ id: todo.id, completed: !todo.completed }); 57 } 58 59 function handleDelete() { 60 setItemError(null); 61 mutateDelete({ id: todo.id }); 62 } 63 64 const isPending = isToggling || isDeleting; 65 66 return ( 67 <li className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-opacity ${isPending ? "opacity-50" : "opacity-100"} ${todo.completed ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-700"}`}> 68 <div className="flex items-center gap-3 flex-grow"> 69 <input 70 type="checkbox" 71 checked={todo.completed} 72 onChange={handleToggle} 73 disabled={isPending} 74 id={`todo-check-${todo.id}`} 75 className="h-5 w-5 rounded border-gray-300 text-gray-600 focus:ring-gray-500 disabled:opacity-70" 76 /> 77 <label htmlFor={`todo-check-${todo.id}`} className={`flex-grow ${todo.completed ? "line-through text-green-700" : ""}`}> 78 {todo.title} 79 </label> 80 </div> 81 <div className="flex items-center gap-2 self-end sm:self-center"> 82 {itemError && ( <span className="text-xs text-red-700 bg-red-50 px-2 py-1 rounded-md mr-2">Error: {itemError}</span> )} 83 <button 84 onClick={handleDelete} 85 disabled={isPending} 86 className="px-2 py-1 bg-gray-600 text-white text-xs rounded hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-1 disabled:opacity-50" 87 aria-label={`Delete todo: ${todo.title}`} 88 > 89 Delete 90 </button> 91 </div> 92 </li> 93 ); 94}

    Innerhalb von TodoItem verwenden wir zwei useMutation-Hooks. Beide folgen dem bekannten Muster: Sie rufen die Server Action auf, invalidieren bei Erfolg die ['todos']-Query und speichern Fehler in itemError, um sie direkt beim betroffenen Todo anzuzeigen. Der kombinierte isPending-Status deaktiviert die Bedienelemente während einer Aktion.

    Schließlich passen wir components/TodoList.tsx an, um die neue TodoItem-Komponente zu verwenden:

    1// components/TodoList.tsx 2import { TodoItem } from "./TodoItem"; // Importieren 3 4// ... Rest der Komponente ... 5 6export function TodoList() { 7 // ... useQuery Hook ... 8 9 if (isLoading) /* ... */; 10 if (isError) /* ... */; 11 12 return ( 13 <div className="w-full max-w-md ..."> 14 <h1 className="...">My Todos</h1> 15 {todos && todos.length > 0 ? ( 16 <ul className="space-y-3"> 17 {/* Render a TodoItem component for each todo */} 18 {todos.map((todo) => ( 19 <TodoItem key={todo.id} todo={todo} /> 20 ))} 21 </ul> 22 ) : ( 23 <p className="...">No todos yet!</p> 24 )} 25 </div> 26 ); 27}

    Statt nur den Titel anzuzeigen, rendert die Liste nun für jedes Todo eine TodoItem-Instanz.

    Das Ergebnis ist eine voll funktionsfähige Todo-Liste! Wir können Todos hinzufügen, abhaken und löschen. Die UI reagiert dank useMutation und Cache-Invalidierung prompt, und Fehler werden sinnvoll behandelt. Als optionalen nächsten Schritt könnten wir uns noch Optimistic Updates ansehen.

    Schritt 6 (Optional): Gefühlte Lichtgeschwindigkeit – Optimistic Updates

    Unsere Todo-App ist funktional, aber wir können die Benutzererfahrung noch weiter verbessern! Wenn ein Benutzer eine Aktion ausführt (Todo hinzufügen, abhaken, löschen), muss er momentan kurz warten, bis die Bestätigung vom Server kommt und die Liste aktualisiert wird. Zugegeben, bei einer einfachen Todo-App mit geringer Latenz ist dieser Unterschied oft kaum spürbar. Dennoch ist die Technik der Optimistic Updates äußerst wertvoll für Anwendungen mit längeren Antwortzeiten oder komplexeren Operationen, da wir hier die UI sofort aktualisieren, als wäre die Aktion bereits erfolgreich gewesen, und so die gefühlte Wartezeit eliminieren.

    Das Prinzip:

    1. Vor der Aktion (onMutate): Wir speichern den aktuellen Zustand der Todo-Liste (Snapshot) und ändern den Cache von React Query sofort so, wie wir das Endergebnis erwarten (z.B. das neue Todo hinzufügen, den Status ändern, das Todo entfernen).
    2. Aktion ausführen (mutationFn): Parallel dazu wird die Server Action wie gewohnt ausgeführt.
    3. Nach der Aktion:
      • Bei Erfolg (onSuccess): Alles gut! Die UI zeigte bereits den korrekten Zustand. Wir invalidieren die Query trotzdem (invalidateQueries), um sicherzustellen, dass wir die exakten Daten vom Server erhalten und der Cache synchronisiert wird.
      • Bei Fehler (onError oder onSuccess mit data.success: false): Hoppla, unsere optimistische Annahme war falsch! Wir nutzen den gespeicherten Snapshot aus onMutate, um den Cache auf den ursprünglichen Zustand zurückzusetzen (Rollback) und zeigen eine Fehlermeldung an.

    Wichtige Überlegung: Optimistic Updates eignen sich hervorragend für schnelle, relativ sichere CRUD-Operationen wie unsere Todos, bei denen das Ergebnis meist vorhersehbar ist und ein Rollback einfach umzusetzen ist. Bei komplexeren, potenziell fehleranfälligen oder langlaufenden Aktionen (stell dir z.B. das Starten eines Docker-Containers oder einen mehrstufigen Bestellprozess vor) sind sie jedoch oft nicht empfehlenswert. Hier ist die Annahme eines sicheren Erfolgs zu gewagt, ein Rollback kann schwierig sein, und es ist meist wichtiger, dem Benutzer den tatsächlichen, vom Server bestätigten Status anzuzeigen, anstatt einen optimistischen Zustand vorzugaukeln. Wäge also immer ab, ob der Gewinn an gefühlter Performance das Risiko und die Komplexität rechtfertigt!

    Für unsere Todo-App integrieren wir diese Logik nun in die useMutation-Hooks.

    1. Optimistic Update für addTodo in components/AddTodoForm.tsx:

    Wir erweitern den useMutation-Hook wie folgt:

    1// components/AddTodoForm.tsx (Änderungen im useMutation Hook) 2 3// Definiere den Kontext-Typ für den Snapshot 4interface AddTodoContext { previousTodos?: Todo[]; } 5 6const { mutate, isPending } = useMutation<ActionResult, Error, FormData, AddTodoContext>({ 7 mutationFn: addTodo, 8 // highlight-start 9 // NEU: onMutate für optimistische Aktualisierung 10 onMutate: async (newTodoFormData) => { 11 setErrorMessage(null); 12 await queryClient.cancelQueries({ queryKey: ["todos"] }); 13 const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]); // Snapshot 14 15 // Neues Todo optimistisch erstellen (mit temporärer ID) 16 const title = newTodoFormData.get("title") as string; 17 const optimisticTodo: Todo = { /* ... erstellen mit temp ID ... */ }; 18 19 // Cache aktualisieren 20 queryClient.setQueryData<Todo[]>(["todos"], (old = []) => [optimisticTodo, ...old]); 21 22 console.log("Optimistic update applied for:", optimisticTodo.title); 23 return { previousTodos }; // Snapshot zurückgeben für Rollback 24 }, 25 // NEU: Rollback-Logik in onError 26 onError: (error, _variables, context) => { 27 console.error("Mutation error, rolling back:", error); 28 setErrorMessage(error.message || "Failed to submit."); 29 if (context?.previousTodos) { // Rollback durchführen 30 queryClient.setQueryData(["todos"], context.previousTodos); 31 console.log("Optimistic update rolled back."); 32 } 33 }, 34 // Geändert: Rollback-Logik bei Action-Fehler in onSuccess 35 onSuccess: (data, variables, context) => { 36 if (data.success) { 37 console.log("Server confirmed todo addition."); 38 formRef.current?.reset(); 39 setErrorMessage(null); 40 // Query invalidieren, um temp. ID etc. zu ersetzen 41 queryClient.invalidateQueries({ queryKey: ["todos"] }); 42 } else { 43 // Rollback bei Action-Fehler 44 console.error("Server action failed:", data.error); 45 setErrorMessage(data.error || "Unknown server error."); 46 if (context?.previousTodos) { // Rollback durchführen 47 queryClient.setQueryData(["todos"], context.previousTodos); 48 console.log("Optimistic update rolled back due to server failure."); 49 } 50 } 51 }, 52 // Geändert: Parameter in onSettled entfernt (optional) 53 onSettled: () => { 54 console.log("Add mutation settled."); 55 }, 56 // highlight-end 57}); 58 59// ... Rest der Komponente bleibt gleich ...

    2. Optimistic Updates für toggleTodoStatus und deleteTodo in components/TodoItem.tsx:

    Wir wenden dasselbe Muster auf die Mutationen in der TodoItem-Komponente an:

    1// components/TodoItem.tsx (Änderungen in den useMutation Hooks) 2 3// Kontext-Typ definieren (kann derselbe wie oben sein) 4interface TodoItemContext { previousTodos?: Todo[]; } 5 6// --- Mutation für Toggle (mit Optimistic Update) --- 7const { mutate: mutateToggle, ... } = useMutation<ActionResult, Error, ToggleTodoVariables, TodoItemContext>({ 8 mutationFn: /* ... */, 9 // highlight-start 10 onMutate: async (variables) => { 11 setItemError(null); 12 await queryClient.cancelQueries({ queryKey: ["todos"] }); 13 const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]); 14 // Status optimistisch ändern 15 queryClient.setQueryData<Todo[]>(["todos"], (old = []) => 16 old.map((t) => t.id === variables.id ? { ...t, completed: variables.completed } : t) 17 ); 18 console.log(`Optimistic toggle for ${variables.id}`); 19 return { previousTodos }; 20 }, 21 onError: (error, variables, context) => { /* ... Rollback ... */ }, 22 onSuccess: (data, variables, context) => { /* ... Rollback bei Action-Fehler, sonst invalidate ... */ }, 23 onSettled: () => { /* ... */ }, 24 // highlight-end 25}); 26 27// --- Mutation für Delete (mit Optimistic Update) --- 28const { mutate: mutateDelete, ... } = useMutation<ActionResult, Error, DeleteTodoVariables, TodoItemContext>({ 29 mutationFn: /* ... */, 30 // highlight-start 31 onMutate: async (variables) => { 32 setItemError(null); 33 await queryClient.cancelQueries({ queryKey: ["todos"] }); 34 const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]); 35 // Todo optimistisch entfernen 36 queryClient.setQueryData<Todo[]>(["todos"], (old = []) => 37 old.filter((t) => t.id !== variables.id) 38 ); 39 console.log(`Optimistic delete for ${variables.id}`); 40 return { previousTodos }; 41 }, 42 onError: (error, variables, context) => { /* ... Rollback ... */ }, 43 onSuccess: (data, variables, context) => { /* ... Rollback bei Action-Fehler, sonst invalidate ... */ }, 44 onSettled: () => { /* ... */ }, 45 // highlight-end 46}); 47 48// ... Rest der Komponente bleibt gleich ...

    Das Ergebnis ist eine spürbar flüssigere Interaktion. Das Hinzufügen, Abhaken und Löschen von Todos fühlt sich jetzt direkter an.

    Fazit: Tanstack Query & Server Actions – Ein starkes Team

    Unsere Reise durch die Todo-App hat gezeigt: Tanstack React Query und Next.js Server Actions sind ein effektives Duo für das Datenmanagement in modernen Next.js-Anwendungen.

    Tanstack Query (useQuery) vereinfacht das Lesen und Cachen von Server-Daten auf dem Client enorm, inklusive Lade- und Fehlerzuständen. Server Actions wiederum bieten einen direkten, typsicheren Weg für Mutationen (Schreiben, Ändern, Löschen), ohne dass separate API-Endpunkte nötig sind.

    Die Kombination überzeugt:

    • Klare Rollen: Query fürs Lesen, Actions fürs Schreiben.
    • Verbesserte Developer Experience: Weniger Boilerplate, mehr Fokus auf die Logik.
    • Performante UI: Dank Caching und gezielter Invalidierung (useMutation + invalidateQueries). Optimistic Updates können die gefühlte Performance weiter steigern.

    Wenn du nach einem robusten, entwicklerfreundlichen Weg suchst, Daten in deiner Next.js-App zu managen und gleichzeitig eine performante UI zu gewährleisten, ist die hier gezeigte Kombination aus Tanstack Query und Server Actions definitiv eine Empfehlung wert!

    Comments: