frontend

TanStack Query Optimistic Updates: Rollback في

٢٤ يونيو ٢٠٢٦

TanStack Query Optimistic Updates: Rollback in 2026

تكتب تحديثات TanStack Query المتفائلة (optimistic updates) النتيجة المتوقعة في التخزين المؤقت (cache) في اللحظة التي يتفاعل فيها المستخدم، ثم تتم المطابقة مع الخادم عند استقرار الطفرة (mutation). تقوم بذلك في onMutate الخاص بـ useMutation: قم بإلغاء عمليات إعادة الجلب (refetches)، وأخذ لقطة (snapshot) للتخزين المؤقت، والكتابة باستخدام setQueryData، وإرجاع اللقطة حتى يتمكن onError من التراجع (roll back).

ملخص

سنقوم ببناء تطبيق مهام (todo) صغير ومكتوب بالكامل (fully typed) مع API غير متزامن وهمي، ونضيف تحديثات React Query المتفائلة باستخدام useMutation. سترى نهج التخزين المؤقت (onMutatesetQueryData ← التراجع)، والنهج الأبسط "عبر واجهة المستخدم" باستخدام 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

الحواشي

  1. @tanstack/React-query@5.101.1 peerDependencies — سجل npm، تم الاسترجاع في 2026-06-24. https://www.npmjs.com/package/@tanstack/React-query

  2. @tanstack/React-query dist-tags (latest = 5.101.1) — سجل npm، تم الاسترجاع في 2026-06-24. https://www.npmjs.com/package/@tanstack/React-query

  3. "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