التدفق · فصل ٧

تحديث السلة قبل رد السيرفر

onMutate · setQueryData · rollback · invalidateQueries
🎯 الهدف: تحدّث كاش السلة أول ما المستخدم يضغط، وترجّعه لو الطلب فشل، وبعدين تجيب الداتا الحقيقية.

في TanStack Query تقدر تعمل Optimistic Updates باستخدام QueryClient. أول ما المستخدم يضغط Add to Cart، عدد المنتجات في الهيدر ومحتوى السلة يتحدثوا فورًا، من غير ما تستنى الـ Backend يرد.

الفكرة إننا نحدّث الكاش مؤقتًا، وبعدها:

  • لو الـ API نجح، نعمل تحديث للداتا الحقيقية.
  • لو الـ API فشل، نرجّع الكاش لحالته القديمة.
  • لو فيه طلبات متزامنة، لازم ناخد بالنا إننا ما نمسحش تحديثات أحدث بالغلط.

التدفق

مفتاح الاستعلام والنوع

في FSD، خلّي مفاتيح السلة واستعلاماتها جوه entities/cart. كل الـ Features والـ Widgets تستخدم نفس التعريف.

entities/cart/model/queries.ts
import { queryOptions } from '@tanstack/react-query';import { getCart } from '../api/get-cart';export const cartKeys = {  all: ['cart'] as const,  current: () => [...cartKeys.all, 'current'] as const,};export const cartQueries = {  current: () =>    queryOptions({      queryKey: cartKeys.current(),      queryFn: getCart,    }),};

شكل السلة للتوضيح. استخدم شكل الـ Response الحقيقي اللي بيرجعه الـ Backend عندك.

entities/cart/model/types.ts
export interface CartItem {  productId: string;  quantity: number;  price: number;}export interface Cart {  items: CartItem[];}

التحديث المؤقت

features/add-to-cart/model/use-add-to-cart.ts
import {  useMutation,  useQueryClient,} from '@tanstack/react-query';import { addCartItem } from '@/entities/cart';import { cartKeys } from '@/entities/cart/model/queries';import type { Cart } from '@/entities/cart/model/types';type AddCartItemInput = {  productId: string;  quantity: number;};export function useAddToCart() {  const queryClient = useQueryClient();  const queryKey = cartKeys.current();  return useMutation({    mutationFn: (input: AddCartItemInput) =>      addCartItem(input),    onMutate: async (input) => {      await queryClient.cancelQueries({        queryKey,      });      const previousCart =        queryClient.getQueryData<Cart>(queryKey);      if (previousCart) {        queryClient.setQueryData<Cart>(          queryKey,          (oldCart) => {            if (!oldCart) return oldCart;            const existingItem = oldCart.items.find(              (item) =>                item.productId === input.productId,            );            if (existingItem) {              return {                ...oldCart,                items: oldCart.items.map((item) =>                  item.productId === input.productId                    ? {                        ...item,                        quantity:                          item.quantity + input.quantity,                      }                    : item,                ),              };            }            return {              ...oldCart,              items: [                ...oldCart.items,                {                  productId: input.productId,                  quantity: input.quantity,                  price: 0,                },              ],            };          },        );      }      return { previousCart };    },    onError: (_error, _input, context) => {      if (context?.previousCart) {        queryClient.setQueryData(          queryKey,          context.previousCart,        );      }    },    onSettled: () =>      queryClient.invalidateQueries({        queryKey,      }),  });}

onMutate يشتغل قبل الطلب. cancelQueries يمنع إعادة جلب شغالة من إنها تمسح التحديث المؤقت. previousCart للنسخ لو الطلب فشل. التحديث يحصل بس لو السلة موجودة في الكاش أصلًا. onError يرجّع النسخة القديمة. onSettled يعلّم البيانات إنها تتحدث من السيرفر، سواء نجح الطلب أو فشل.

الزرار

features/add-to-cart/ui/add-to-cart-button.tsx
import { useAddToCart } from '../model/use-add-to-cart';export function AddToCartButton({  productId,}: {  productId: string;}) {  const mutation = useAddToCart();  return (    <button      disabled={mutation.isPending}      onClick={() =>        mutation.mutate({          productId,          quantity: 1,        })      }    >      {mutation.isPending        ? 'Adding...'        : 'Add to Cart'}    </button>  );}

isPending يفضل true طول ما طلب الـ API شغال، حتى لو السلة اتحدثت في الواجهة. ده يمنع الضغط المتكرر. مش معناه إننا بنستنى السيرفر عشان نعرض التحديث.

الهيدر

widgets/header/ui/cart-badge.tsx
import { useQuery } from '@tanstack/react-query';import { cartQueries } from '@/entities/cart';export function CartBadge() {  const { data: cart } = useQuery(    cartQueries.current(),  );  const count =    cart?.items.reduce(      (total, item) => total + item.quantity,      0,    ) ?? 0;  return <span>{count}</span>;}

طالما الهيدر وصفحة السلة بيستخدموا نفس QueryClient ونفس cartKeys.current()، تحديث الكاش بـ setQueryData يظهر للمكونات اللي بتراقب نفس الاستعلام.

أنهي دالة

الدالةوظيفتها
setQueryDataتحديث الكاش فورًا بشكل مباشر
getQueryDataقراءة القيمة الحالية من الكاش
cancelQueriesإلغاء عمليات القراءة الجارية اللي ممكن تستبدل التحديث المؤقت
invalidateQueriesتعليم البيانات إنها قديمة وطلب إعادة جلبها عند الحاجة

التحديث الفوري مناسب لو تقدر تتوقع شكل النتيجة بدقة. لو العملية فيها مخزون وأسعار وخصومات وحسابات سيرفر، ممكن تكتفي بعدّاد مؤقت، أو تستنى رد السيرفر.

خلّصت الفصل لما

🧠 اختبر نفسك
١) ليه cancelQueries قبل setQueryData؟
عشان إعادة جلب شغالة متكتبش على التحديث المؤقت.
٢) الطلب فشل. الكاش يرجع إزاي؟
onError يحط previousCart تاني بـ setQueryData.
٣) الهيدر ليه يتحدث قبل رد السيرفر؟
لأنه بيقرأ نفس المفتاح اللي setQueryData عدّله.