snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
34
examples/react/nextjs-app-optimistic-updates/.gitignore
vendored
Normal file
34
examples/react/nextjs-app-optimistic-updates/.gitignore
vendored
Normal file
@@ -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
|
||||
61
examples/react/nextjs-app-optimistic-updates/README.md
Normal file
61
examples/react/nextjs-app-optimistic-updates/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 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 && <li style={{ opacity: 0.5 }}>{mutation.variables}</li>}
|
||||
```
|
||||
|
||||
**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<Todo[]>(['todos'])
|
||||
queryClient.setQueryData<Todo[]>(['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)
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface Todo {
|
||||
id: string
|
||||
text: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export const todos: Array<Todo> = [
|
||||
{
|
||||
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<Array<Todo>> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
return todos
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
22
examples/react/nextjs-app-optimistic-updates/app/layout.tsx
Normal file
22
examples/react/nextjs-app-optimistic-updates/app/layout.tsx
Normal file
@@ -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 (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
29
examples/react/nextjs-app-optimistic-updates/app/page.tsx
Normal file
29
examples/react/nextjs-app-optimistic-updates/app/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import { HydrationBoundary, dehydrate, noop } 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
|
||||
.query({
|
||||
queryKey: ['todos'],
|
||||
queryFn: getTodos,
|
||||
})
|
||||
.catch(noop)
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 600, margin: '0 auto', padding: '2rem 1rem' }}>
|
||||
<h1>Optimistic Updates with TanStack Query</h1>
|
||||
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
|
||||
Add todos to see optimistic updates in action. The server randomly fails
|
||||
~30% of the time so you can observe automatic rollback.
|
||||
</p>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<ApproachTabs />
|
||||
</HydrationBoundary>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'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<Tab>('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 (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #ddd',
|
||||
marginBottom: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={tabStyle('ui-variables')}
|
||||
onClick={() => setActiveTab('ui-variables')}
|
||||
>
|
||||
Via UI Variables
|
||||
</button>
|
||||
<button style={tabStyle('cache')} onClick={() => setActiveTab('cache')}>
|
||||
Via Cache Manipulation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'ui-variables' ? <TodoListUI /> : <TodoListCache />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
'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<Todo> | undefined
|
||||
optimisticId: string
|
||||
}
|
||||
|
||||
async function fetchTodos(): Promise<Array<Todo>> {
|
||||
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<Todo> {
|
||||
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<string | null>(null)
|
||||
|
||||
const { data: todos = [] } = useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos,
|
||||
})
|
||||
|
||||
const addTodoMutation = useMutation<Todo, Error, string, MutationContext>({
|
||||
mutationFn: addTodo,
|
||||
onMutate: async (text) => {
|
||||
setLastError(null)
|
||||
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||
|
||||
const previousTodos = queryClient.getQueryData<Array<Todo>>(['todos'])
|
||||
|
||||
const optimisticId = `optimistic-${Date.now()}`
|
||||
const optimisticTodo: Todo = {
|
||||
id: optimisticId,
|
||||
text,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
queryClient.setQueryData<Array<Todo>>(['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<Array<Todo>>(['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 (
|
||||
<div>
|
||||
<p style={{ fontSize: '0.875rem', color: '#555', marginBottom: '1rem' }}>
|
||||
<strong>Approach 2 — via cache manipulation:</strong>{' '}
|
||||
<code>onMutate</code> snapshots the cache and writes the optimistic item
|
||||
in. <code>onError</code> restores the snapshot on failure.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: 'flex', gap: 8, marginBottom: '1rem' }}
|
||||
>
|
||||
<input
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="New todo…"
|
||||
style={{ flex: 1, padding: '0.4rem 0.6rem', fontSize: '1rem' }}
|
||||
/>
|
||||
<button type="submit" disabled={addTodoMutation.isPending}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{lastError && (
|
||||
<p style={{ color: 'red', marginBottom: '0.5rem' }}>{lastError}</p>
|
||||
)}
|
||||
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{todos.map((todo) => (
|
||||
<li
|
||||
key={todo.id}
|
||||
style={{
|
||||
padding: '0.4rem 0',
|
||||
borderBottom: '1px solid #eee',
|
||||
opacity: todo.id.startsWith('optimistic-') ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{todo.text}
|
||||
{todo.id.startsWith('optimistic-') && <em> (saving…)</em>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'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<Array<Todo>> {
|
||||
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<Todo> {
|
||||
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 (
|
||||
<div>
|
||||
<p style={{ fontSize: '0.875rem', color: '#555', marginBottom: '1rem' }}>
|
||||
<strong>Approach 1 — via UI variables:</strong> The pending item is
|
||||
rendered directly from <code>mutation.variables</code>. No cache
|
||||
manipulation needed. On error the pending item simply disappears.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: 'flex', gap: 8, marginBottom: '1rem' }}
|
||||
>
|
||||
<input
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="New todo…"
|
||||
style={{ flex: 1, padding: '0.4rem 0.6rem', fontSize: '1rem' }}
|
||||
/>
|
||||
<button type="submit" disabled={addTodoMutation.isPending}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{addTodoMutation.isError && (
|
||||
<p style={{ color: 'red', marginBottom: '0.5rem' }}>
|
||||
{addTodoMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{todos.map((todo) => (
|
||||
<li
|
||||
key={todo.id}
|
||||
style={{ padding: '0.4rem 0', borderBottom: '1px solid #eee' }}
|
||||
>
|
||||
{todo.text}
|
||||
</li>
|
||||
))}
|
||||
{addTodoMutation.isPending && (
|
||||
<li
|
||||
style={{
|
||||
padding: '0.4rem 0',
|
||||
borderBottom: '1px solid #eee',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
{addTodoMutation.variables} <em>(saving…)</em>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
export default nextConfig
|
||||
23
examples/react/nextjs-app-optimistic-updates/package.json
Normal file
23
examples/react/nextjs-app-optimistic-updates/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@tanstack/query-example-react-nextjs-app-optimistic-updates",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"@tanstack/react-query-devtools": "^5.102.8",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
35
examples/react/nextjs-app-optimistic-updates/tsconfig.json
Normal file
35
examples/react/nextjs-app-optimistic-updates/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user