snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
autofix.ci / autofix (push) Failing after 4s
Release / Release (push) Has been skipped
GitHub Actions Security Analysis / Run zizmor (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:13:08 +05:00
commit 3a013ccdfe
3570 changed files with 490194 additions and 0 deletions

View 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

View 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)

View File

@@ -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
}

View File

@@ -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 })
}

View File

@@ -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
}
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,6 @@
// @ts-check
/** @type {import('next').NextConfig} */
const nextConfig = {}
export default nextConfig

View 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"
}
}

View 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"]
}