From 00a465254990507a05970854dcf2b71f3582940b Mon Sep 17 00:00:00 2001 From: Guilherme Rabelo Date: Sat, 27 Jun 2026 21:24:42 -0300 Subject: [PATCH 1/3] feat(examples): add Next.js App Router optimistic updates example --- .../.eslintrc.cjs | 9 ++ .../nextjs-app-optimistic-updates/.gitignore | 34 +++++ .../nextjs-app-optimistic-updates/README.md | 58 +++++++++ .../app/api/todos/route.ts | 45 +++++++ .../app/get-query-client.ts | 31 +++++ .../app/layout.tsx | 22 ++++ .../app/page.tsx | 27 ++++ .../app/providers.tsx | 16 +++ .../components/ApproachTabs.tsx | 36 ++++++ .../components/TodoListCache.tsx | 121 ++++++++++++++++++ .../components/TodoListUI.tsx | 96 ++++++++++++++ .../next.config.js | 6 + .../package.json | 23 ++++ .../tsconfig.json | 35 +++++ 14 files changed, 559 insertions(+) create mode 100644 examples/react/nextjs-app-optimistic-updates/.eslintrc.cjs create mode 100644 examples/react/nextjs-app-optimistic-updates/.gitignore create mode 100644 examples/react/nextjs-app-optimistic-updates/README.md create mode 100644 examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts create mode 100644 examples/react/nextjs-app-optimistic-updates/app/get-query-client.ts create mode 100644 examples/react/nextjs-app-optimistic-updates/app/layout.tsx create mode 100644 examples/react/nextjs-app-optimistic-updates/app/page.tsx create mode 100644 examples/react/nextjs-app-optimistic-updates/app/providers.tsx create mode 100644 examples/react/nextjs-app-optimistic-updates/components/ApproachTabs.tsx create mode 100644 examples/react/nextjs-app-optimistic-updates/components/TodoListCache.tsx create mode 100644 examples/react/nextjs-app-optimistic-updates/components/TodoListUI.tsx create mode 100644 examples/react/nextjs-app-optimistic-updates/next.config.js create mode 100644 examples/react/nextjs-app-optimistic-updates/package.json create mode 100644 examples/react/nextjs-app-optimistic-updates/tsconfig.json diff --git a/examples/react/nextjs-app-optimistic-updates/.eslintrc.cjs b/examples/react/nextjs-app-optimistic-updates/.eslintrc.cjs new file mode 100644 index 00000000000..cb40aee1b47 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/.eslintrc.cjs @@ -0,0 +1,9 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ['plugin:react/jsx-runtime', 'plugin:react-hooks/recommended'], + settings: { + react: { + version: 'detect', + }, + }, +} diff --git a/examples/react/nextjs-app-optimistic-updates/.gitignore b/examples/react/nextjs-app-optimistic-updates/.gitignore new file mode 100644 index 00000000000..b988ee9758f --- /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 00000000000..89b24ce9dad --- /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/route.ts b/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts new file mode 100644 index 00000000000..da625480c16 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server' + +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 +} + +export async function GET() { + return NextResponse.json(await getTodos()) +} + +export async function POST(request: Request) { + const body = (await request.json()) as { text: string } + + 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: body.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 00000000000..1666beba77a --- /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 00000000000..055162043cf --- /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 00000000000..52310a3e325 --- /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/route' +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 00000000000..f5098b4d0a6 --- /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 00000000000..a9ed26e5a08 --- /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 00000000000..90ffb462661 --- /dev/null +++ b/examples/react/nextjs-app-optimistic-updates/components/TodoListCache.tsx @@ -0,0 +1,121 @@ +'use client' + +import React, { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import type { Todo } from '@/app/api/todos/route' + +interface MutationContext { + previousTodos: Array | undefined +} + +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 optimisticTodo: Todo = { + id: `optimistic-${Date.now()}`, + text, + createdAt: Date.now(), + } + + queryClient.setQueryData>(['todos'], (old = []) => [ + ...old, + optimisticTodo, + ]) + + return { previousTodos } + }, + onError: (_err, _text, context) => { + setLastError(_err.message) + if (context?.previousTodos !== undefined) { + queryClient.setQueryData(['todos'], context.previousTodos) + } + }, + 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 00000000000..516ec04a44f --- /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/route' + +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 00000000000..296d026f634 --- /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 00000000000..ba5252255de --- /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 00000000000..25e2693bc37 --- /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"] +} From 7ad24c16497c88d97951c7e25924c9d8e372b5f1 Mon Sep 17 00:00:00 2001 From: Guilherme Rabelo Date: Sat, 27 Jun 2026 22:37:51 -0300 Subject: [PATCH 2/3] refactor(examples/nextjs-app-optimistic-updates): apply post-review corrections --- .../app/api/todos/data.ts | 16 +++++++++ .../app/api/todos/route.ts | 34 ++++++++----------- .../app/page.tsx | 2 +- .../components/TodoListCache.tsx | 12 +++++-- .../components/TodoListUI.tsx | 2 +- pnpm-lock.yaml | 31 +++++++++++++++++ 6 files changed, 73 insertions(+), 24 deletions(-) create mode 100644 examples/react/nextjs-app-optimistic-updates/app/api/todos/data.ts 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 00000000000..32acd79356f --- /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 index da625480c16..09867467b60 100644 --- a/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts +++ b/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts @@ -1,28 +1,24 @@ import { NextResponse } from 'next/server' - -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 -} +import { todos, getTodos, type Todo } from './data' export async function GET() { return NextResponse.json(await getTodos()) } export async function POST(request: Request) { - const body = (await request.json()) as { text: string } + const body = (await request.json()) as unknown + + 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)) @@ -35,7 +31,7 @@ export async function POST(request: Request) { const newTodo: Todo = { id: crypto.randomUUID(), - text: body.text, + text, createdAt: Date.now(), } diff --git a/examples/react/nextjs-app-optimistic-updates/app/page.tsx b/examples/react/nextjs-app-optimistic-updates/app/page.tsx index 52310a3e325..9b0ffe691fb 100644 --- a/examples/react/nextjs-app-optimistic-updates/app/page.tsx +++ b/examples/react/nextjs-app-optimistic-updates/app/page.tsx @@ -1,7 +1,7 @@ import React from 'react' import { HydrationBoundary, dehydrate } from '@tanstack/react-query' import { getQueryClient } from '@/app/get-query-client' -import { getTodos } from '@/app/api/todos/route' +import { getTodos } from '@/app/api/todos/data' import ApproachTabs from '@/components/ApproachTabs' export default function Home() { diff --git a/examples/react/nextjs-app-optimistic-updates/components/TodoListCache.tsx b/examples/react/nextjs-app-optimistic-updates/components/TodoListCache.tsx index 90ffb462661..674c7a0f577 100644 --- a/examples/react/nextjs-app-optimistic-updates/components/TodoListCache.tsx +++ b/examples/react/nextjs-app-optimistic-updates/components/TodoListCache.tsx @@ -2,10 +2,11 @@ import React, { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import type { Todo } from '@/app/api/todos/route' +import type { Todo } from '@/app/api/todos/data' interface MutationContext { previousTodos: Array | undefined + optimisticId: string } async function fetchTodos(): Promise> { @@ -45,8 +46,9 @@ export default function TodoListCache() { const previousTodos = queryClient.getQueryData>(['todos']) + const optimisticId = `optimistic-${Date.now()}` const optimisticTodo: Todo = { - id: `optimistic-${Date.now()}`, + id: optimisticId, text, createdAt: Date.now(), } @@ -56,12 +58,16 @@ export default function TodoListCache() { optimisticTodo, ]) - return { previousTodos } + 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: () => { diff --git a/examples/react/nextjs-app-optimistic-updates/components/TodoListUI.tsx b/examples/react/nextjs-app-optimistic-updates/components/TodoListUI.tsx index 516ec04a44f..be867ced180 100644 --- a/examples/react/nextjs-app-optimistic-updates/components/TodoListUI.tsx +++ b/examples/react/nextjs-app-optimistic-updates/components/TodoListUI.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import type { Todo } from '@/app/api/todos/route' +import type { Todo } from '@/app/api/todos/data' async function fetchTodos(): Promise> { const res = await fetch('/api/todos') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24698d4fd93..c47960c1904 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': From 083bcdada748675e9c231cad0bf298fbea81df91 Mon Sep 17 00:00:00 2001 From: Guilherme Rabelo Date: Sat, 27 Jun 2026 23:02:13 -0300 Subject: [PATCH 3/3] refactor(examples/nextjs-app-optimistic-updates): apply post-review corrections --- .../react/nextjs-app-optimistic-updates/.eslintrc.cjs | 9 --------- .../nextjs-app-optimistic-updates/app/api/todos/route.ts | 7 ++++++- 2 files changed, 6 insertions(+), 10 deletions(-) delete mode 100644 examples/react/nextjs-app-optimistic-updates/.eslintrc.cjs diff --git a/examples/react/nextjs-app-optimistic-updates/.eslintrc.cjs b/examples/react/nextjs-app-optimistic-updates/.eslintrc.cjs deleted file mode 100644 index cb40aee1b47..00000000000 --- a/examples/react/nextjs-app-optimistic-updates/.eslintrc.cjs +++ /dev/null @@ -1,9 +0,0 @@ -/** @type {import('eslint').Linter.Config} */ -module.exports = { - extends: ['plugin:react/jsx-runtime', 'plugin:react-hooks/recommended'], - settings: { - react: { - version: 'detect', - }, - }, -} 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 index 09867467b60..8a91e4e7207 100644 --- a/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts +++ b/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts @@ -6,7 +6,12 @@ export async function GET() { } export async function POST(request: Request) { - const body = (await request.json()) as unknown + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'text is required' }, { status: 400 }) + } const text = body !== null &&