TanStack Query Optimistic Updates: Rollback في
٢٤ يونيو ٢٠٢٦
تكتب تحديثات TanStack Query المتفائلة (optimistic updates) النتيجة المتوقعة في التخزين المؤقت (cache) في اللحظة التي يتفاعل فيها المستخدم، ثم تتم المطابقة مع الخادم عند استقرار الطفرة (mutation). تقوم بذلك في onMutate الخاص بـ useMutation: قم بإلغاء عمليات إعادة الجلب (refetches)، وأخذ لقطة (snapshot) للتخزين المؤقت، والكتابة باستخدام setQueryData، وإرجاع اللقطة حتى يتمكن onError من التراجع (roll back).
ملخص
سنقوم ببناء تطبيق مهام (todo) صغير ومكتوب بالكامل (fully typed) مع API غير متزامن وهمي، ونضيف تحديثات React Query المتفائلة باستخدام useMutation. سترى نهج التخزين المؤقت (onMutate ← setQueryData ← التراجع)، والنهج الأبسط "عبر واجهة المستخدم" باستخدام mutation.variables، ومتى تستخدم كل منهما. تم فحص كل كتلة برمجية في هذا البرنامج التعليمي مقابل @tanstack/React-query@5.101.1 وتم التحقق من سلوك التحديث المتفائل ثم التراجع في وقت التشغيل. الميزانية الزمنية حوالي 20 دقيقة.
ما ستتعلمه
- كيفية هيكلة تطبيق Vite + React + TypeScript مع
QueryClientProvider - كيفية قراءة قائمة باستخدام
useQueryو API غير متزامن وهمي - لماذا تجعل الطفرة الساذجة القائمة تومض، وكيف يصلح
cancelQueriesهذا السباق (race condition) - كيفية إجراء تحديث متفائل للتخزين المؤقت باستخدام
onMutateوsetQueryDataولقطة (snapshot) - كيفية التراجع تلقائيًا باستخدام
onErrorوإعادة المزامنة باستخدامonSettled - النهج الأبسط "عبر واجهة المستخدم" باستخدام
mutation.variablesوisPending - شكل رد النداء (callback) الجديد في الإصدار v5 الذي يمرر
context.client - متى تستخدم
onMutateالخاص بـ TanStack Query مقابلuseOptimisticالخاص بـ React 19
المتطلبات الأساسية
- Node 20.19+ أو 22.12+ (الإصدارات التي يعلن عنها Vite 8 في حقل
engines) - React 19.2 و React-dom 19.2 (يسرد TanStack Query v5 نطاق أقران
^18 || ^19)1 - @tanstack/React-query 5.101.1 (الأحدث وقت كتابة هذا التقرير)2
- TypeScript 6.0 ومعرفة أساسية بـ
useQuery
الخطوة 1 — هيكلة المشروع وتغليفه في QueryClientProvider
أنشئ مشروع Vite React + TypeScript وأضف TanStack Query مع إصدارات محددة:
# create-vite 9.1.0 scaffolds Vite 8, React 19.2, and TypeScript 6
npm create vite@9.1.0 rq-optimistic -- --template React-ts
cd rq-optimistic
npm install
npm install @tanstack/React-query@5.101.1
يجب أن يقع كل مكون يستدعي استعلامًا (query) أو طفرة (mutation) تحت QueryClientProvider. قم بتوصيله مرة واحدة في الجذر في src/main.tsx:
import { StrictMode } from 'React'
import { createRoot } from 'React-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/React-query'
import { App } from './App'
const queryClient = new QueryClient()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)
الخطوة 2 — API غير متزامن وهمي وقائمة مع useQuery
لا يكون للتحديثات المتفائلة معنى إلا في مواجهة زمن الانتقال (latency)، لذا فإن API الوهمي في src/API.ts ينتظر قبل التنفيذ ويكشف عن مفتاح setShouldFail حتى نتمكن من تجربة مسار التراجع عند الطلب:
export type Todo = { id: string; text: string; done: boolean }
let store: Todo[] = [{ id: '1', text: 'Learn TanStack Query', done: false }]
export let shouldFail = false
export function setShouldFail(v: boolean) { shouldFail = v }
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
export async function fetchTodos(): Promise<Todo[]> {
await sleep(300)
return structuredClone(store)
}
export async function addTodo(text: string): Promise<Todo> {
await sleep(600)
if (shouldFail) throw new Error('Server rejected the new todo')
const todo: Todo = { id: crypto.randomUUID(), text, done: false }
store = [...store, todo]
return todo
}
export async function toggleTodo(id: string): Promise<Todo> {
await sleep(600)
if (shouldFail) throw new Error('Server rejected the toggle')
store = store.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
const updated = store.find((t) => t.id === id)
if (!updated) throw new Error('Not found')
return updated
}
قراءة القائمة هي عملية useQuery عادية. لاحظ أنه في الإصدار v5 يتم الكشف عن حالة "لا توجد بيانات" للاستعلام كـ isPending (حالة pending)، ويتم تحديد نوع error بمجرد تضييق نطاق isError:3
const todosQuery = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
if (todosQuery.isPending) return <p>Loading…</p>
if (todosQuery.isError) return <p>Error: {todosQuery.error.message}</p>
الخطوة 3 — لماذا يأتي cancelQueries أولاً (السباق)
إليك الفخ الذي يجعل التحديث المتفائل "الشغال" يومض. في اللحظة التي تستدعي فيها mutate، قد تكون عملية إعادة جلب في الخلفية لـ ['todos'] جارية بالفعل. إذا اكتملت عملية إعادة الجلب هذه بعد استدعاء setQueryData المتفائل، فإن استجابة الخادم القديمة ستمسح قيمتك المتفائلة ويختفي العنصر الجديد لفترة وجيزة.
هذا هو بالضبط السبب في أن دليل التحديث المتفائل يخبرك بضرورة استدعاء await queryClient.cancelQueries({ queryKey }) أولاً: فهو يلغي عمليات إعادة الجلب الصادرة حتى لا تمسح كتابتك المتفائلة.4 تعامل مع cancelQueries كخطوة صفرية في كل تحديث متفائل يعتمد على التخزين المؤقت — تخطيها هو سبب شائع لعدم "ثبات" التحديث المتفائل.
الخطوة 4 — التحديث المتفائل عبر التخزين المؤقت
الآن إلى الشيء الحقيقي. في src/App.tsx، تقوم طفرة الإضافة بإلغاء عمليات إعادة الجلب، وأخذ لقطة للتخزين المؤقت، وكتابة مهمة متفائلة، وإرجاع اللقطة. يتم تسليم القيمة المرتجعة إلى onError و onSettled لاحقًا، وهو ما يجعل التراجع ممكنًا:4
import { useState } from 'React'
import { useMutation, useQuery, useQueryClient } from '@tanstack/React-query'
import { addTodo, fetchTodos, toggleTodo, type Todo } from './API'
const todosKey = ['todos'] as const
export function App() {
const queryClient = useQueryClient()
const [text, setText] = useState('')
const todosQuery = useQuery({ queryKey: todosKey, queryFn: fetchTodos })
const addMutation = useMutation({
mutationFn: addTodo,
onMutate: async (newText: string) => {
await queryClient.cancelQueries({ queryKey: todosKey })
const previousTodos = queryClient.getQueryData<Todo[]>(todosKey)
const optimistic: Todo = { id: `temp-${Date.now()}`, text: newText, done: false }
queryClient.setQueryData<Todo[]>(todosKey, (old) => [...(old ?? []), optimistic])
return { previousTodos }
},
onError: (_err, _newText, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(todosKey, context.previousTodos)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: todosKey })
},
})
// …render below
}
بعض التفاصيل التي يهتم بها فاحص النوع (type-checker). يعيد getQueryData<Todo[]> القيمة Todo[] | undefined، لذا فإن محدث setQueryData يحمي باستخدام old ?? []. يستخدم العنصر المتفائل id مؤقتًا (temp-…) لأن معرف الخادم الحقيقي غير موجود بعد — تقوم عملية الإبطال (invalidation) في onSettled بإعادة الجلب واستبداله بالسجل المعتمد.
الخطوة 5 — التراجع عند الخطأ، وإعادة المزامنة عند الاستقرار
تقسم ردود النداء الثلاثة العمل بوضوح:
onMutateيتم تشغيله قبل الطلب ويعيد سياق التراجع (لقطتك).onErrorيتلقى تلك اللقطة كوسيط ثالث ويستعيدها باستخدامsetQueryData. التسلسل الاختياري (context?.previousTodos) مطلوب لأنonMutateيمكن من الناحية النظرية ألا يعيد شيئًا.onSettledيتم تشغيله بعد النجاح أو الخطأ ويستدعيinvalidateQueries، بحيث تتم مطابقة التخزين المؤقت مع الخادم بعد كل طفرة — تخمينك المتفائل ليس الكلمة الأخيرة أبدًا.
تتعامل نفس ردود النداء الثلاثة مع تحديث عنصر واحد. أعلن عن طفرة تبديل (toggle) بجوار طفرة الإضافة مباشرة في المكون — فهي تأخذ لقطة، وتعيد كتابة الصف المتأثر فقط، وتتراجع عن القائمة بأكملها في حالة الفشل:
const toggleMutation = useMutation({
mutationFn: toggleTodo,
onMutate: async (id: string) => {
await queryClient.cancelQueries({ queryKey: todosKey })
const previousTodos = queryClient.getQueryData<Todo[]>(todosKey)
queryClient.setQueryData<Todo[]>(todosKey, (old) =>
(old ?? []).map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
)
return { previousTodos }
},
onError: (_err, _id, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(todosKey, context.previousTodos)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: todosKey })
},
})
أخيرًا، قم بتوصيل النموذج والقائمة لإنهاء App.tsx. يستدعي كل عنصر في القائمة التبديل عند النقر:
if (todosQuery.isPending) return <p>Loading…</p>
if (todosQuery.isError) return <p>Error: {todosQuery.error.message}</p>
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault()
if (!text.trim()) return
addMutation.mutate(text.trim())
setText('')
}}
>
<input value={textonChange={(e) => setText(e.target.value)} />
<button type="submit" disabled={addMutation.isPending}>Add</button>
</form>
<ul>
{todosQuery.data.map((todo) => (
<li key={todo.id} onClick={() => toggleMutation.mutate(todo.id)}>
{todo.done ? '✓ ' : '○ '}{todo.text}
</li>
))}
</ul>
</div>
)
الخطوة 6 — النهج الأبسط "عبر واجهة المستخدم"
إذا كان هناك مكون واحد فقط يحتاج إلى إظهار العنصر المعلق، فلست مضطرًا للمس التخزين المؤقت على الإطلاق. يحتفظ TanStack Query بالمتغيرات (variables) الجارية في كائن الطفرة، لذا يمكنك عرض صف مؤقت بينما تكون isPending صحيحة وتترك عملية الإبطال تنظفه. لا توجد setQueryData ولا تراجع للكتابة:4
import { useMutation, useQueryClient } from '@tanstack/React-query'
import { addTodo } from './API'
export function useAddTodoViaUI() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: addTodo,
// return the promise so the mutation stays pending until the refetch finishes
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
mutationKey: ['addTodo'],
})
const { isPending, submittedAt, variables, isError, mutate } = mutation
return { isPending, submittedAt, variables, isError, mutate }
}
هناك أمران يجعلان هذا النهج ممتعًا. أولاً، يجب عليك إرجاع وعد (promise) invalidateQueries من onSettled حتى تظل الطفرة في حالتها المعلقة حتى تكتمل عملية إعادة الجلب — وإلا سيختفي الصف المؤقت قبل وصول الصف الحقيقي. ثانيًا، لا يتم مسح variables عند حدوث خطأ في الطفرة، لذا يمكنك الاحتفاظ بالعنصر الفاشل على الشاشة مع زر إعادة المحاولة.4 إذا كانت الطفرة تعيش في مكون مختلف عن القائمة، فاقرأ المتغيرات الجارية في أي مكان باستخدام useMutationState (هذا هو سبب تعيين mutationKey):
import { useMutationState } from '@tanstack/React-query'
export function usePendingNewTodos() {
return useMutationState<string>({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables as string,
})
}
استخدم نهج واجهة المستخدم (UI approach) عندما يكون هناك موقع عرض واحد وتفضل عدم امتلاك منطق التراجع (rollback)؛ استخدم نهج ذاكرة التخزين المؤقت (cache approach) عندما تقرأ عدة مكونات نفس الاستعلام ويجب أن تعكس جميعها التغيير في وقت واحد.4
الخطوة 7 — صيغة استدعاء context.client الأحدث
تقوم إصدارات v5 الحديثة بتمرير كائن context كمعامل أخير لكل استدعاء طفرة (mutation callback)، حيث يكون context.client هو الـ QueryClient. يتيح لك ذلك كتابة منطق متفائل دون الحاجة للإغلاق على useQueryClient()، كما يعيد تسمية القيمة المرجعة من onMutate إلى onMutateResult في التوقيع البرمجي:3
import { useMutation } from '@tanstack/React-query'
import { addTodo, type Todo } from './API'
export function useAddTodoViaContext() {
return useMutation({
mutationFn: addTodo,
onMutate: async (newText: string, context) => {
await context.client.cancelQueries({ queryKey: ['todos'] })
const previousTodos = context.client.getQueryData<Todo[]>(['todos'])
context.client.setQueryData<Todo[]>(['todos'], (old) => [
...(old ?? []),
{ id: `temp-${Date.now()}`, text: newText, done: false },
])
return { previousTodos }
},
onError: (_err, _newText, onMutateResult, context) => {
if (onMutateResult?.previousTodos) {
context.client.setQueryData(['todos'], onMutateResult.previousTodos)
}
},
onSettled: (_d, _e, _v, _r, context) =>
context.client.invalidateQueries({ queryKey: ['todos'] }),
})
}
كلا الصيغتين يتم تجميعهما (compile) وتعملان بشكل متطابق. نمط الإغلاق (closure pattern) من الخطوة 4 (تسمية المعامل الثالث لـ onError بـ context وقراءة الـ QueryClient من نطاق المكون) يستمر في العمل لأن ذلك المعامل الموضعي الثالث لا يزال هو القيمة المرجعة من onMutate — لذا يمكنك اعتماد context.client عندما يكون ذلك مناسباً دون إعادة كتابة الطفرات الموجودة.
التحقق
فحص الأنواع والبناء:
npx tsc --noEmit # exits 0
npx vite build # exits 0
npm run dev # then open the printed localhost URL
ماذا تتوقع عند تشغيله:
- إضافة مهمة (todo): يظهر الصف الجديد فوراً (قبل اكتمال الطلب الذي يستغرق 600 مللي ثانية)، ثم يقوم إبطال
onSettledبإعادة جلب البيانات ويتم استبدال المعرف المؤقتtemp-…بالمعرف الحقيقي من الخادم. - فرض فشل: استدعِ
setShouldFail(true)منAPI.ts، أضف مهمة، وسيظهر الصف المتفائل ثم يختفي عندما يقومonErrorباستعادة اللقطة (snapshot). تعود القائمة تماماً إلى حالتها السابقة.
يمكنك إثبات التراجع دون متصفح عن طريق تشغيل دورة الحياة برمجياً باستخدام QueryClient و MutationObserver. في مسار النجاح، تنمو ذاكرة التخزين المؤقت من مدخل واحد إلى مدخلين؛ وفي مسار الفشل تعود إلى مدخل واحد — مما يؤكد استعادة اللقطة.
الأخطاء الشائعة
- العنصر المتفائل يومض ثم يختفي، ثم يظهر مرة أخرى. اكتملت عملية إعادة جلب في الخلفية بعد
setQueryDataالخاصة بك وقامت بمسحها. لقد نسيتawait queryClient.cancelQueries({ queryKey })في بدايةonMutate.4 setQueryDataيشتكي من أنoldقد يكون غير معرف (undefined). يمكن لـgetQueryData/المُحدِّث (updater) تلقيundefinedإذا لم يتم تحميل الاستعلام بعد. قم بالحماية باستخدامold ?? []وحدد نوع الاستدعاء كـsetQueryData<Todo[]>.- التراجع لا يعمل أبداً. تأكد من أن
onMutateيقوم بالفعل بـreturnلكائن اللقطة — إذا أرجعundefined، فسيكون معامل الاستدعاء الثالثundefinedولن يكون هناك شيء لاستعادته. حافظ على حمايةcontext?.previousTodos. - الصف المعلق "عبر واجهة المستخدم" يختفي مبكراً جداً. لقد نسيت إرجاع وعد (promise)
invalidateQueriesمنonSettled؛ بدونه تخرج الطفرة من الحالة المعلقة قبل وصول إعادة الجلب.4 - إعادة المحاولة على عنصر فاشل لا تفعل شيئاً مفيداً. تذكر أن الـ
variablesتنجو من الخطأ، لذا فإنmutate(variables)تعيد تشغيل نفس المدخلات من زر إعادة المحاولة.4
TanStack Query onMutate مقابل React 19 useOptimistic
هذه أدوات مختلفة تتعايش معاً في عام 2026. يوفر خطاف useOptimistic المدمج في React 19 واجهة مستخدم متفائلة محلية للمكون مرتبطة بالإجراءات (actions) والانتقالات (transitions): تعود طبقتها المتفائلة تلقائياً عند استقرار الإجراء، لكنها لا تدير ذاكرة تخزين مؤقت مشتركة للخادم عبر المكونات بالطريقة التي تفعلها ذاكرة تخزين TanStack Query.5 الجأ إلى useOptimistic للصدى المحلي المدفوع بالنماذج والانتقالات (غالباً جنباً إلى جنب مع Server Actions، كما في دليل React 19 useOptimistic الخاص بنا)؛ والجأ إلى TanStack Query onMutate عندما يجب أن ينعكس التغيير المتفائل في كل مكان يقرأ نفس الاستعلام المخزن مؤقتاً وتتم تسويته مع الخادم بعد ذلك.
الخطوات التالية ومزيد من القراءة
- اجمع بين الطفرات المتفائلة وتحميل البيانات من جانب الخادم — راجع الجلب المسبق لـ TanStack Query في Next.js App Router.
- تحقق من صحة النموذج الذي يغذي طفرتك باستخدام TanStack Form و Zod.
- للتحديثات المتفائلة المتزامنة عبر العديد من الطفرات الجارية، اقرأ تعمق TkDodo المرتبط من الدليل الرسمي.4
الحواشي
-
@tanstack/React-query@5.101.1peerDependencies— سجل npm، تم الاسترجاع في 2026-06-24. https://www.npmjs.com/package/@tanstack/React-query ↩ -
@tanstack/React-querydist-tags (latest = 5.101.1) — سجل npm، تم الاسترجاع في 2026-06-24. https://www.npmjs.com/package/@tanstack/React-query ↩ -
"Mutations" — وثائق TanStack Query v5 لـ React (حالات الطفرات، تواقيع الاستدعاء،
context.client)، تم الاسترجاع في 2026-06-24. https://tanstack.com/query/latest/docs/framework/React/guides/mutations ↩ ↩2
"Optimistic Updates" — وثائق TanStack Query v5 React، تم الاسترجاع في 2026-06-24. https://tanstack.com/query/latest/docs/framework/React/guides/optimistic-updates ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
"useOptimistic" — وثائق React. https://React.dev/reference/React/useOptimistic ↩