diff --git a/examples/react/nextjs-app-optimistic-updates/.gitignore b/examples/react/nextjs-app-optimistic-updates/.gitignore new file mode 100644 index 0000000000..b988ee9758 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo diff --git a/examples/react/nextjs-app-optimistic-updates/README.md b/examples/react/nextjs-app-optimistic-updates/README.md new file mode 100644 index 0000000000..89b24ce9da --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/README.md @@ -0,0 +1,58 @@ +# TanStack Query — Next.js App Router Optimistic Updates + +This example demonstrates **optimistic updates** with TanStack Query v5 in a Next.js 14 App Router project. + +## What it shows + +A todo list where items appear in the UI immediately after submission — before the server confirms. The server randomly fails ~30% of the time so you can observe automatic rollback behaviour. + +Two approaches are shown side by side via a tab toggle: + +### Approach 1 — Via UI Variables (simpler) + +Render the pending item directly from `mutation.variables`. No cache touching required. On error, the pending item simply disappears and an error message is shown. + +```ts +const mutation = useMutation({ mutationFn: addTodo, onSettled: invalidate }) + +// In JSX: +{mutation.isPending &&
  • {mutation.variables}
  • } +``` + +**Best when:** the mutation input maps 1-to-1 to what you'd show while pending. + +### Approach 2 — Via Cache Manipulation (`onMutate` + rollback) + +`onMutate` cancels in-flight refetches, snapshots the current cache, and writes an optimistic item directly into the cache. `onError` restores the snapshot. + +```ts +const mutation = useMutation({ + mutationFn: addTodo, + onMutate: async (text) => { + await queryClient.cancelQueries({ queryKey: ['todos'] }) + const previousTodos = queryClient.getQueryData(['todos']) + queryClient.setQueryData(['todos'], (old = []) => [...old, optimistic]) + return { previousTodos } + }, + onError: (_err, _vars, context) => { + queryClient.setQueryData(['todos'], context?.previousTodos) + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }), +}) +``` + +**Best when:** you need fine-grained control or the optimistic shape differs from `mutation.variables`. + +## Running the example + +```bash +npm install +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +## Learn more + +- [TanStack Query — Optimistic Updates](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates) +- [Next.js App Router](https://nextjs.org/docs/app) diff --git a/examples/react/nextjs-app-optimistic-updates/app/api/todos/data.ts b/examples/react/nextjs-app-optimistic-updates/app/api/todos/data.ts new file mode 100644 index 0000000000..32acd79356 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/app/api/todos/data.ts @@ -0,0 +1,16 @@ +export interface Todo { + id: string + text: string + createdAt: number +} + +export const todos: Array = [ + { id: crypto.randomUUID(), text: 'Buy groceries', createdAt: Date.now() - 3000 }, + { id: crypto.randomUUID(), text: 'Walk the dog', createdAt: Date.now() - 2000 }, + { id: crypto.randomUUID(), text: 'Read a book', createdAt: Date.now() - 1000 }, +] + +export async function getTodos(): Promise> { + await new Promise((resolve) => setTimeout(resolve, 200)) + return todos +} diff --git a/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts b/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts new file mode 100644 index 0000000000..8a91e4e720 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server' +import { todos, getTodos, type Todo } from './data' + +export async function GET() { + return NextResponse.json(await getTodos()) +} + +export async function POST(request: Request) { + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'text is required' }, { status: 400 }) + } + + const text = + body !== null && + typeof body === 'object' && + 'text' in body && + typeof (body as { text: unknown }).text === 'string' + ? ((body as { text: string }).text.trim()) + : '' + + if (!text) { + return NextResponse.json({ error: 'text is required' }, { status: 400 }) + } + + await new Promise((resolve) => setTimeout(resolve, 500)) + + if (Math.random() < 0.3) { + return NextResponse.json( + { error: 'Server error — please try again' }, + { status: 500 }, + ) + } + + const newTodo: Todo = { + id: crypto.randomUUID(), + text, + createdAt: Date.now(), + } + + todos.push(newTodo) + + return NextResponse.json(newTodo, { status: 201 }) +} diff --git a/examples/react/nextjs-app-optimistic-updates/app/get-query-client.ts b/examples/react/nextjs-app-optimistic-updates/app/get-query-client.ts new file mode 100644 index 0000000000..1666beba77 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/app/get-query-client.ts @@ -0,0 +1,31 @@ +import { + QueryClient, + defaultShouldDehydrateQuery, + environmentManager, +} from '@tanstack/react-query' + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + }, + dehydrate: { + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || + query.state.status === 'pending', + }, + }, + }) +} + +let browserQueryClient: QueryClient | undefined = undefined + +export function getQueryClient() { + if (environmentManager.isServer()) { + return makeQueryClient() + } else { + if (!browserQueryClient) browserQueryClient = makeQueryClient() + return browserQueryClient + } +} diff --git a/examples/react/nextjs-app-optimistic-updates/app/layout.tsx b/examples/react/nextjs-app-optimistic-updates/app/layout.tsx new file mode 100644 index 0000000000..055162043c --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/app/layout.tsx @@ -0,0 +1,22 @@ +import Providers from './providers' +import type React from 'react' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'TanStack Query — Optimistic Updates', + description: 'Next.js App Router example with optimistic updates', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + {children} + + + ) +} diff --git a/examples/react/nextjs-app-optimistic-updates/app/page.tsx b/examples/react/nextjs-app-optimistic-updates/app/page.tsx new file mode 100644 index 0000000000..9b0ffe691f --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/app/page.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { HydrationBoundary, dehydrate } from '@tanstack/react-query' +import { getQueryClient } from '@/app/get-query-client' +import { getTodos } from '@/app/api/todos/data' +import ApproachTabs from '@/components/ApproachTabs' + +export default function Home() { + const queryClient = getQueryClient() + + void queryClient.prefetchQuery({ + queryKey: ['todos'], + queryFn: getTodos, + }) + + return ( +
    +

    Optimistic Updates with TanStack Query

    +

    + Add todos to see optimistic updates in action. The server randomly fails + ~30% of the time so you can observe automatic rollback. +

    + + + +
    + ) +} diff --git a/examples/react/nextjs-app-optimistic-updates/app/providers.tsx b/examples/react/nextjs-app-optimistic-updates/app/providers.tsx new file mode 100644 index 0000000000..f5098b4d0a --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/app/providers.tsx @@ -0,0 +1,16 @@ +'use client' +import { QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { getQueryClient } from '@/app/get-query-client' +import type * as React from 'react' + +export default function Providers({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient() + + return ( + + {children} + + + ) +} diff --git a/examples/react/nextjs-app-optimistic-updates/components/ApproachTabs.tsx b/examples/react/nextjs-app-optimistic-updates/components/ApproachTabs.tsx new file mode 100644 index 0000000000..a9ed26e5a0 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/components/ApproachTabs.tsx @@ -0,0 +1,36 @@ +'use client' + +import React, { useState } from 'react' +import TodoListUI from './TodoListUI' +import TodoListCache from './TodoListCache' + +type Tab = 'ui-variables' | 'cache' + +export default function ApproachTabs() { + const [activeTab, setActiveTab] = useState('ui-variables') + + const tabStyle = (tab: Tab): React.CSSProperties => ({ + padding: '0.5rem 1rem', + border: 'none', + borderBottom: activeTab === tab ? '2px solid #0070f3' : '2px solid transparent', + background: 'none', + cursor: 'pointer', + fontWeight: activeTab === tab ? 600 : 400, + color: activeTab === tab ? '#0070f3' : '#555', + }) + + return ( +
    +
    + + +
    + + {activeTab === 'ui-variables' ? : } +
    + ) +} diff --git a/examples/react/nextjs-app-optimistic-updates/components/TodoListCache.tsx b/examples/react/nextjs-app-optimistic-updates/components/TodoListCache.tsx new file mode 100644 index 0000000000..674c7a0f57 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/components/TodoListCache.tsx @@ -0,0 +1,127 @@ +'use client' + +import React, { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import type { Todo } from '@/app/api/todos/data' + +interface MutationContext { + previousTodos: Array | undefined + optimisticId: string +} + +async function fetchTodos(): Promise> { + const res = await fetch('/api/todos') + if (!res.ok) throw new Error('Failed to fetch todos') + return res.json() +} + +async function addTodo(text: string): Promise { + const res = await fetch('/api/todos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }) + if (!res.ok) { + const err = (await res.json()) as { error: string } + throw new Error(err.error) + } + return res.json() +} + +export default function TodoListCache() { + const queryClient = useQueryClient() + const [inputValue, setInputValue] = useState('') + const [lastError, setLastError] = useState(null) + + const { data: todos = [] } = useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }) + + const addTodoMutation = useMutation({ + mutationFn: addTodo, + onMutate: async (text) => { + setLastError(null) + await queryClient.cancelQueries({ queryKey: ['todos'] }) + + const previousTodos = queryClient.getQueryData>(['todos']) + + const optimisticId = `optimistic-${Date.now()}` + const optimisticTodo: Todo = { + id: optimisticId, + text, + createdAt: Date.now(), + } + + queryClient.setQueryData>(['todos'], (old = []) => [ + ...old, + optimisticTodo, + ]) + + return { previousTodos, optimisticId } + }, + onError: (_err, _text, context) => { + setLastError(_err.message) + if (context?.previousTodos !== undefined) { + queryClient.setQueryData(['todos'], context.previousTodos) + } else if (context?.optimisticId) { + queryClient.setQueryData>(['todos'], (old = []) => + old.filter((todo) => todo.id !== context.optimisticId), + ) + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, + }) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const text = inputValue.trim() + if (!text) return + addTodoMutation.mutate(text) + setInputValue('') + } + + return ( +
    +

    + Approach 2 — via cache manipulation: onMutate{' '} + snapshots the cache and writes the optimistic item in. onError{' '} + restores the snapshot on failure. +

    + +
    + setInputValue(e.target.value)} + placeholder="New todo…" + style={{ flex: 1, padding: '0.4rem 0.6rem', fontSize: '1rem' }} + /> + +
    + + {lastError && ( +

    {lastError}

    + )} + +
      + {todos.map((todo) => ( +
    • + {todo.text} + {todo.id.startsWith('optimistic-') && (saving…)} +
    • + ))} +
    +
    + ) +} diff --git a/examples/react/nextjs-app-optimistic-updates/components/TodoListUI.tsx b/examples/react/nextjs-app-optimistic-updates/components/TodoListUI.tsx new file mode 100644 index 0000000000..be867ced18 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/components/TodoListUI.tsx @@ -0,0 +1,96 @@ +'use client' + +import React, { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import type { Todo } from '@/app/api/todos/data' + +async function fetchTodos(): Promise> { + const res = await fetch('/api/todos') + if (!res.ok) throw new Error('Failed to fetch todos') + return res.json() +} + +async function addTodo(text: string): Promise { + const res = await fetch('/api/todos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }) + if (!res.ok) { + const err = (await res.json()) as { error: string } + throw new Error(err.error) + } + return res.json() +} + +export default function TodoListUI() { + const queryClient = useQueryClient() + const [inputValue, setInputValue] = useState('') + + const { data: todos = [] } = useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + }) + + const addTodoMutation = useMutation({ + mutationFn: addTodo, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, + }) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const text = inputValue.trim() + if (!text) return + addTodoMutation.mutate(text) + setInputValue('') + } + + return ( +
    +

    + Approach 1 — via UI variables: The pending item is + rendered directly from mutation.variables. No cache + manipulation needed. On error the pending item simply disappears. +

    + +
    + setInputValue(e.target.value)} + placeholder="New todo…" + style={{ flex: 1, padding: '0.4rem 0.6rem', fontSize: '1rem' }} + /> + +
    + + {addTodoMutation.isError && ( +

    + {addTodoMutation.error.message} +

    + )} + +
      + {todos.map((todo) => ( +
    • + {todo.text} +
    • + ))} + {addTodoMutation.isPending && ( +
    • + {addTodoMutation.variables} (saving…) +
    • + )} +
    +
    + ) +} diff --git a/examples/react/nextjs-app-optimistic-updates/next.config.js b/examples/react/nextjs-app-optimistic-updates/next.config.js new file mode 100644 index 0000000000..296d026f63 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/next.config.js @@ -0,0 +1,6 @@ +// @ts-check + +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +export default nextConfig diff --git a/examples/react/nextjs-app-optimistic-updates/package.json b/examples/react/nextjs-app-optimistic-updates/package.json new file mode 100644 index 0000000000..ba5252255d --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/package.json @@ -0,0 +1,23 @@ +{ + "name": "@tanstack/query-example-react-nextjs-app-optimistic-updates", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-devtools": "^5.101.2", + "next": "^16.0.7", + "react": "^19.2.1", + "react-dom": "^19.2.1" + }, + "devDependencies": { + "@types/node": "26.0.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "typescript": "5.8.3" + } +} diff --git a/examples/react/nextjs-app-optimistic-updates/tsconfig.json b/examples/react/nextjs-app-optimistic-updates/tsconfig.json new file mode 100644 index 0000000000..25e2693bc3 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ES5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".eslintrc.cjs", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24698d4fd9..c47960c190 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1070,6 +1070,37 @@ importers: specifier: 5.8.3 version: 5.8.3 + examples/react/nextjs-app-optimistic-updates: + dependencies: + '@tanstack/react-query': + specifier: ^5.101.2 + version: link:../../../packages/react-query + '@tanstack/react-query-devtools': + specifier: ^5.101.2 + version: link:../../../packages/react-query-devtools + next: + specifier: ^16.0.7 + version: 16.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.90.0) + react: + specifier: ^19.2.1 + version: 19.2.4 + react-dom: + specifier: ^19.2.1 + version: 19.2.4(react@19.2.4) + devDependencies: + '@types/node': + specifier: ^22.15.3 + version: 22.19.15 + '@types/react': + specifier: ^19.2.7 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + typescript: + specifier: 5.8.3 + version: 5.8.3 + examples/react/nextjs-app-prefetching: dependencies: '@tanstack/react-query':