تطوير الويب

TanStack Query SSR مع TanStack Start: prefetch وdehydrate وHydrationBoundary 2026

6 دقائق للقراءة

السلسلة: هذا الدرس جزء من سلسلة TanStack. اقرأ المقال الرئيسي.

عرض الخادم مع TanStack Start يربح rount-trip شبكي، لكنه يفرض نقل حالة cache من الخادم إلى العميل بلا إعادة تشغيل الطلبات عند الـ mount. هذا تماماً دور ثنائي dehydrate/HydrationBoundary من TanStack Query. هذا الدرس يبيّن التوصيل النظيف لـ TanStack Query v5 في تطبيق TanStack Start 2026.

الفخّان الكلاسيكيان: waterfall (المكوّن يُركَّب جانب العميل، يُطلق الطلب، المستخدم يرى spinner رغم وصول HTML)، وhydration مكسورة (الخادم جلب البيانات لكن العميل يُعيد جلبها لأن cache لم يُنقَل). دمج prefetchQuery في loader، dehydrate عند تسلسل router وHydrationBoundary عند العرض يحلّ الاثنين.

المتطلبات

  • Node.js 22 LTS
  • مشروع TanStack Start مع src/router.tsx
  • TypeScript مفعَّل
  • معرفة file-based routing

الخطوة 1 — تثبيت @tanstack/react-query وdevtools

npm install @tanstack/react-query @tanstack/react-query-devtools

الإصدار 5 هو الذي يُدخل HydrationBoundary بدل Hydrate القديم ويفصل useQuery عن useSuspenseQuery. إن رأيت إصدار 4، حدّث قبل المتابعة. npm ls @tanstack/react-query يجب أن يعرض سطراً واحداً.

الخطوة 2 — إعداد QueryClient بـ staleTime وgcTime

QueryClient الكائن المركزي للـ cache. في SSR، نسخة واحدة لكل طلب HTTP جانب الخادم، أبداً نسخة عالمية مشتركة، وإلا سيرى المستخدمون بيانات بعضهم.

// src/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60_000,
        gcTime: 5 * 60_000,
        retry: 1,
        refetchOnWindowFocus: false,
      },
    },
  })
}

staleTime: 60_000 يخبر Query أن البيانات تبقى طازجة 60 ثانية. حاسم في SSR لأن بلا staleTime غير صفر، البيانات المُهيدرَتة تُوسَم stale فوراً عند الـ mount وReact Query يُعيد الطلب.

الخطوة 3 — توصيل QueryClient في context الـ router

// src/router.tsx
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { makeQueryClient } from './lib/query-client'

export function createRouter() {
  const queryClient = makeQueryClient()

  return createTanStackRouter({
    routeTree,
    context: { queryClient },
    defaultPreload: 'intent',
  })
}

declare module '@tanstack/react-router' {
  interface Register {
    router: ReturnType<typeof createRouter>
  }
}

createRouter تُستدعى مرة واحدة لكل طلب جانب الخادم ومرة على العميل: عزل cache بين المستخدمين مضمون.

الخطوة 4 — Provider وHydrationBoundary في __root.tsx

// src/routes/__root.tsx
import {
  createRootRouteWithContext,
  Outlet,
  HeadContent,
  Scripts,
} from '@tanstack/react-router'
import {
  QueryClient,
  QueryClientProvider,
  HydrationBoundary,
  dehydrate,
} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

export const Route = createRootRouteWithContext<{
  queryClient: QueryClient
}>()({
  component: RootComponent,
  loader: ({ context }) => ({ dehydratedState: dehydrate(context.queryClient) }),
})

function RootComponent() {
  const { queryClient } = Route.useRouteContext()
  const { dehydratedState } = Route.useLoaderData()

  return (
    <html>
      <head><HeadContent /></head>
      <body>
        <QueryClientProvider client={queryClient}>
          <HydrationBoundary state={dehydratedState}>
            <Outlet />
          </HydrationBoundary>
          <ReactQueryDevtools initialIsOpen={false} />
        </QueryClientProvider>
        <Scripts />
      </body>
    </html>
  )
}

HydrationBoundary تستلم الحالة المُجفَّفة وتُحمّلها في QueryClient عند العرض الأول. على العروض اللاحقة، لا تفعل شيئاً: آلية bootstrap فقط.

الخطوة 5 — Prefetch في loader route

// src/routes/articles/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { queryOptions } from '@tanstack/react-query'

export const articlesQueryOptions = queryOptions({
  queryKey: ['articles', 'list'],
  queryFn: async () => {
    const res = await fetch('https://api.example.com/articles')
    if (!res.ok) throw new Error('Articles fetch failed')
    return res.json() as Promise<Array<{ id: string; title: string }>>
  },
  staleTime: 60_000,
})

export const Route = createFileRoute('/articles/')({
  loader: async ({ context }) => {
    await context.queryClient.prefetchQuery(articlesQueryOptions)
  },
  component: ArticlesPage,
})

helper queryOptions يسمح بمشاركة نفس queryKey وqueryFn بين loader والمكوّن بلا خطر typo. prefetchQuery لا يُرجع البيانات بل وعداً يُحلّ حين يمتلئ cache.

الخطوة 6 — قراءة البيانات بـ useSuspenseQuery

import { useSuspenseQuery } from '@tanstack/react-query'

function ArticlesPage() {
  const { data } = useSuspenseQuery(articlesQueryOptions)

  return (
    <ul>
      {data.map((a) => (
        <li key={a.id}>{a.title}</li>
      ))}
    </ul>
  )
}

اختبار عملي: عطّل JavaScript في المتصفّح وأعد تحميل الصفحة — قائمة المقالات يجب أن تبقى مرئية، لأنها تأتي من HTML الخادم.

الخطوة 7 — إلغاء صلاحية cache بعد mutation

// src/routes/articles/new.tsx
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { useMutation, useQueryClient } from '@tanstack/react-query'

export const Route = createFileRoute('/articles/new')({
  component: NewArticlePage,
})

function NewArticlePage() {
  const router = useRouter()
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: async (payload: { title: string }) => {
      const res = await fetch('https://api.example.com/articles', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(payload),
      })
      if (!res.ok) throw new Error('Create failed')
      return res.json()
    },
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ['articles'] })
      router.navigate({ to: '/articles' })
    },
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const fd = new FormData(e.currentTarget)
        mutation.mutate({ title: String(fd.get('title') ?? '') })
      }}
    >
      <input name="title" />
      <button disabled={mutation.isPending}>Creer</button>
    </form>
  )
}

queryKey المُمرَّر إلى invalidateQueries بادئة: ['articles'] يُلغي أيضاً ['articles', 'list'] و['articles', 'detail', id]. await على الإلغاء قبل التنقل يضمن تحديث القائمة قبل عرضها.

الخطوة 8 — إدارة الأخطاء مع ErrorBoundary

export const Route = createFileRoute('/articles/')({
  loader: async ({ context }) => {
    await context.queryClient.prefetchQuery(articlesQueryOptions)
  },
  component: ArticlesPage,
  errorComponent: ({ error, reset }) => (
    <div role="alert">
      <p>Erreur de chargement : {error.message}</p>
      <button onClick={reset}>Reessayer</button>
    </div>
  ),
})

أنماط cache: staleTime قصير مقابل طويل

نوع البيانات staleTime gcTime التبرير
قائمة مقالات عامة 60 000 مللي (دقيقة) 5 دقائق تغيير نادر، hydration سلس
ملف مستخدم متصل 30 000 مللي 5 دقائق قد يتغيّر جانب الخادم
إشعارات آنية 0 دقيقة تحديث عند focus دائماً
كتالوغ منتج 5 دقائق 15 دقيقة ثابت، نتجنّب حمل API
لوحة قيادة مالية 10 000 مللي دقيقتان دقّة أهمّ من أداء
إعداد التطبيق Infinity 30 دقيقة لا يتغيّر إلا عند النشر

قاعدة عملية: إن نُقلَت البيانات بـ SSR، staleTime يجب أن يكون أكبر من صفر، وإلا client يُعيد الجلب فوراً وفائدة prefetch تضيع.

أخطاء شائعة

الخطأ السبب الحل
QueryClient عالمي نسخة مشتركة على مستوى module أنشئ دائماً في مصنع router
queryKey مختلف loader/مكوّن ثابت غير مشترك مركّز عبر queryOptions
staleTime صفر مع SSR refetch فوري عند hydration اضبط على 30-60 ثانية على الأقل
نسيان await على prefetch loader يعطي اليد قبل امتلاء cache دائماً await في loader
useQuery بدل useSuspenseQuery حالة isLoading تومض فضّل useSuspenseQuery في SSR
HydrationBoundary منخفض hydration يُعاد عند كل mount ضعها مباشرة تحت QueryClientProvider
Mutation بلا invalidation UI يعرض البيانات القديمة وصّل invalidateQueries في onSuccess

أسئلة شائعة

ensureQueryData أم prefetchQuery في loader؟ prefetchQuery لا يرفع خطأ إن أخفقت الطلب، يدع المكوّن يعالجها عبر ErrorBoundary. ensureQueryData يُرجع البيانات ويرفع عند الفشل، يُصعد الخطأ إلى loader ويُطلق errorComponent للـ route.

هل يمكن استخدام Query بلا loader؟ تقنياً نعم، لكن نخسر SSR: الخادم يعرض fallback، العميل يُركَّب، يُعلَّق، يجلب، ثم يعرض. Loader هو ما يحوّل النمط إلى SSR حقيقي.

كيف نشارك QueryClient بين loader ومكوّن خارج نفس route؟ queryClient في context الـ router، متاح عبر Route.useRouteContext() في أي مكوّن route، وعبر useQueryClient() داخل شجرة QueryClientProvider.

ماذا يحدث إن أرجع API خطأً جانب الخادم؟ prefetchQuery يلتقط الخطأ صامتاً ويضعه في cache كحالة خطأ. عند العرض، useSuspenseQuery يُعيد رفعه، وerrorComponent للـ route يعرضه.

هل يستبدل Query store عالمي مثل Zustand؟ لا: Query يدير حالة الخادم (بيانات بعيدة، cache، invalidation)، لا الحالة المحلية لـ UI (modale مفتوحة، نموذج جارٍ، theme).

مقالات ذات صلة

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité