TanStack DB مع Next.js: بناء قاعدة بيانات عميل تفاعلية في 2026

فريق نقطة
بواسطة فريق نقطة ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

TanStack DB هو القطعة المفقودة في طبقات بيانات React الحديثة. بينما يحل TanStack Query بشكل رائع حالة الخادم، فإنه يترك فجوة عندما تحتاج إلى تفاعلية سريعة بين المكونات أو تصفية معقدة من جانب العميل أو تحديثات تفاؤلية تشعر بالفورية الحقيقية. يملأ TanStack DB هذه الفجوة بنموذج مجموعات تفاعلية واستعلامات مباشرة قائمة على differential dataflow ودعم TypeScript من الدرجة الأولى — كل ذلك فوق أساس TanStack Query الذي تعرفه بالفعل.

ما الذي ستبنيه

تطبيق إدارة مشاريع TaskFlow يعرض كل ميزة أساسية في TanStack DB:

  • مجموعات تفاعلية متزامنة مع خلفية REST
  • استعلامات مباشرة مع عمليات الانضمام والمرشحات والتجميعات
  • طفرات تفاؤلية تشعر بالفورية
  • تفاعلية بين المكونات بدون prop drilling
  • differential dataflow يعيد حساب ما تغير فقط
  • تكامل مع App Router من Next.js و Server Components

في النهاية، ستتحدث واجهتك في أقل من ميلي ثانية على أي تغيير في البيانات، حتى مع آلاف العناصر.

المتطلبات الأساسية

قبل البدء، تأكد من أن لديك:

  • Node.js 20+ مثبت
  • إلمام بـ React 19 و TypeScript
  • معرفة بأساسيات TanStack Query
  • فهم لـ App Router من Next.js
  • محرر شفرة (يُفضل VS Code)

لماذا TanStack DB؟

تفرض إدارة حالة React التقليدية مقايضة. مكتبات حالة الخادم مثل TanStack Query تتعامل مع الجلب بشكل جميل لكنها تعامل كل استعلام كمدخل ذاكرة تخزين مؤقت معزول. مكتبات حالة العميل مثل Zustand أو Jotai تفاعلية لكنها منفصلة عن خادمك. قواعد بيانات local-first قوية لكنها ثقيلة.

يقع TanStack DB بينهما. إنه متجر مُطبَّع وتفاعلي مرتبط فوق مجموعات TanStack Query، مع محرك استعلام مدعوم بـ differential dataflow. هذا يعني أنه عندما يتغير صف واحد، يتم إعادة حساب العروض التي تعتمد على هذا الصف فقط — وليس مجموعة البيانات بأكملها.

النتيجة تشبه قاعدة بيانات صغيرة في الذاكرة مع روابط React، مع الحفاظ على خلفية TanStack Query المألوفة لديك.

الخطوة 1: إعداد المشروع

أنشئ مشروع Next.js 15 جديد:

npx create-next-app@latest taskflow --typescript --tailwind --eslint --app --src-dir
cd taskflow

ثبّت TanStack DB والاعتماديات:

npm install @tanstack/react-db @tanstack/db @tanstack/react-query @tanstack/query-core
npm install -D @tanstack/react-query-devtools

يأتي TanStack DB بمحولات لعدة محركات مزامنة. سنستخدم محول مجموعة Query، الذي يعمل مع أي خلفية REST أو GraphQL.

الخطوة 2: تعريف المخطط

مجموعات TanStack DB مكتوبة بقوة. ابدأ بتعريف أنواع النطاق في src/lib/types.ts:

export type Project = {
  id: string;
  name: string;
  ownerId: string;
  createdAt: string;
};
 
export type Task = {
  id: string;
  projectId: string;
  title: string;
  status: "todo" | "doing" | "done";
  assigneeId: string | null;
  priority: number;
  createdAt: string;
};
 
export type User = {
  id: string;
  name: string;
  avatarUrl: string;
};

ستتدفق هذه الأنواع عبر كل استعلام وطفرة واشتراك، مما يمنحك أمان نوع شامل من البداية إلى النهاية.

الخطوة 3: إنشاء أول مجموعة

المجموعة هي وحدة التفاعلية في TanStack DB. كل مجموعة مدعومة بمصدر مزامنة وتعرض استعلامات تفاعلية. أنشئ src/db/collections.ts:

import { createCollection } from "@tanstack/react-db";
import { queryCollectionOptions } from "@tanstack/db-collections";
import type { Project, Task, User } from "@/lib/types";
 
const API = "/api";
 
export const projectsCollection = createCollection(
  queryCollectionOptions<Project>({
    id: "projects",
    queryKey: ["projects"],
    queryFn: async () => {
      const res = await fetch(`${API}/projects`);
      if (!res.ok) throw new Error("فشل تحميل المشاريع");
      return res.json();
    },
    getKey: (project) => project.id,
  })
);
 
export const tasksCollection = createCollection(
  queryCollectionOptions<Task>({
    id: "tasks",
    queryKey: ["tasks"],
    queryFn: async () => {
      const res = await fetch(`${API}/tasks`);
      if (!res.ok) throw new Error("فشل تحميل المهام");
      return res.json();
    },
    getKey: (task) => task.id,
  })
);
 
export const usersCollection = createCollection(
  queryCollectionOptions<User>({
    id: "users",
    queryKey: ["users"],
    queryFn: async () => {
      const res = await fetch(`${API}/users`);
      if (!res.ok) throw new Error("فشل تحميل المستخدمين");
      return res.json();
    },
    getKey: (user) => user.id,
  })
);

تقوم كل مجموعة تلقائياً بإلغاء تكرار عمليات الجلب وتخزين النتائج مؤقتاً وإصدار أحداث تغيير دقيقة عند إضافة الصفوف أو تعديلها أو إزالتها.

الخطوة 4: ربط المزود

يشارك TanStack DB QueryClient مع TanStack Query. قم بإعداد المزود في src/app/providers.tsx:

"use client";
 
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
 
export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 30_000,
            refetchOnWindowFocus: false,
          },
        },
      })
  );
 
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

غلّف التطبيق في src/app/layout.tsx:

import { Providers } from "./providers";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ar" dir="rtl">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

الخطوة 5: الاستعلامات المباشرة مع useLiveQuery

هنا يحدث السحر. يوفر TanStack DB useLiveQuery، خطاف استعلام تفاعلي يعيد الحساب فقط عندما تتغير اعتمادياته. أنشئ src/components/TaskList.tsx:

"use client";
 
import { useLiveQuery, eq } from "@tanstack/react-db";
import { tasksCollection, usersCollection } from "@/db/collections";
 
type Props = {
  projectId: string;
};
 
export function TaskList({ projectId }: Props) {
  const { data: tasks } = useLiveQuery((q) =>
    q
      .from({ task: tasksCollection })
      .join(
        { user: usersCollection },
        ({ task, user }) => eq(task.assigneeId, user.id),
        "left"
      )
      .where(({ task }) => eq(task.projectId, projectId))
      .orderBy(({ task }) => task.priority, "desc")
      .select(({ task, user }) => ({
        id: task.id,
        title: task.title,
        status: task.status,
        priority: task.priority,
        assigneeName: user?.name ?? "غير معين",
      }))
  );
 
  return (
    <ul className="space-y-2">
      {tasks.map((task) => (
        <li
          key={task.id}
          className="rounded-lg border border-slate-200 p-4"
        >
          <div className="flex items-center justify-between">
            <span className="font-medium">{task.title}</span>
            <span className="text-sm text-slate-500">
              {task.assigneeName}
            </span>
          </div>
          <div className="mt-1 text-xs uppercase text-slate-400">
            {task.status} · أولوية {task.priority}
          </div>
        </li>
      ))}
    </ul>
  );
}

يربط هذا الاستعلام ثلاثة مفاهيم: مجموعة المهام، ومجموعة المستخدمين، ومرشح حسب المشروع. لأنه يعمل عبر محرك differential dataflow، عندما يتغير عنوان مهمة واحدة، يتم إعادة حساب الصف الذي يحتوي على تلك المهمة فقط، وليس القائمة بأكملها.

الخطوة 6: الطفرات التفاؤلية

يجعل TanStack DB التحديثات التفاؤلية تافهة. أنشئ مساعد طفرات في src/db/mutations.ts:

import { createOptimisticAction } from "@tanstack/react-db";
import { tasksCollection } from "./collections";
import type { Task } from "@/lib/types";
 
export const updateTaskStatus = createOptimisticAction({
  onMutate: ({ taskId, status }: { taskId: string; status: Task["status"] }) => {
    tasksCollection.update(taskId, (draft) => {
      draft.status = status;
    });
  },
  mutationFn: async ({ taskId, status }) => {
    const res = await fetch(`/api/tasks/${taskId}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ status }),
    });
    if (!res.ok) throw new Error("فشل تحديث المهمة");
    return res.json();
  },
});
 
export const createTask = createOptimisticAction({
  onMutate: (input: Omit<Task, "id" | "createdAt">) => {
    const id = crypto.randomUUID();
    tasksCollection.insert({
      ...input,
      id,
      createdAt: new Date().toISOString(),
    });
    return { tempId: id };
  },
  mutationFn: async (input) => {
    const res = await fetch("/api/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(input),
    });
    if (!res.ok) throw new Error("فشل إنشاء المهمة");
    return res.json();
  },
  onSuccess: ({ tempId }, serverTask) => {
    tasksCollection.replace(tempId, serverTask);
  },
});

تعمل كتلة onMutate بشكل متزامن وتحدث المجموعة المحلية. يقوم كل مشترك بإعادة العرض قبل أن يبدأ طلب الشبكة. عندما يستجيب الخادم، يقوم onSuccess بمصالحة السجل المؤقت مع السجل الموثوق.

إذا فشلت الطفرة، يقوم TanStack DB بالتراجع تلقائياً عن التغيير التفاؤلي.

الخطوة 7: بناء لوحة السحب والإفلات

ادمج الاستعلامات والطفرات في لوحة Kanban في src/components/TaskBoard.tsx:

"use client";
 
import { useLiveQuery, eq } from "@tanstack/react-db";
import { tasksCollection } from "@/db/collections";
import { updateTaskStatus } from "@/db/mutations";
import type { Task } from "@/lib/types";
 
const COLUMNS: Task["status"][] = ["todo", "doing", "done"];
 
export function TaskBoard({ projectId }: { projectId: string }) {
  const { data: tasksByStatus } = useLiveQuery((q) =>
    q
      .from({ task: tasksCollection })
      .where(({ task }) => eq(task.projectId, projectId))
      .groupBy(({ task }) => task.status)
      .select(({ task }) => ({
        status: task.status,
        items: task,
      }))
  );
 
  const handleDrop = (taskId: string, status: Task["status"]) => {
    updateTaskStatus.mutate({ taskId, status });
  };
 
  return (
    <div className="grid grid-cols-3 gap-4">
      {COLUMNS.map((status) => {
        const column = tasksByStatus.find((c) => c.status === status);
        return (
          <Column
            key={status}
            status={status}
            tasks={column?.items ?? []}
            onDrop={handleDrop}
          />
        );
      })}
    </div>
  );
}
 
function Column({
  status,
  tasks,
  onDrop,
}: {
  status: Task["status"];
  tasks: Task[];
  onDrop: (taskId: string, status: Task["status"]) => void;
}) {
  return (
    <div
      className="rounded-lg bg-slate-50 p-3"
      onDragOver={(e) => e.preventDefault()}
      onDrop={(e) => {
        const taskId = e.dataTransfer.getData("text/plain");
        onDrop(taskId, status);
      }}
    >
      <h3 className="mb-3 font-semibold uppercase">{status}</h3>
      {tasks.map((task) => (
        <div
          key={task.id}
          draggable
          onDragStart={(e) =>
            e.dataTransfer.setData("text/plain", task.id)
          }
          className="mb-2 cursor-grab rounded-md bg-white p-3 shadow-sm"
        >
          {task.title}
        </div>
      ))}
    </div>
  );
}

اسحب بطاقة من "todo" إلى "doing" وتتحدث اللوحة بأكملها فوراً. تعيد الطفرة التفاؤلية كتابة الصف المحلي، ويعيد المحرك التفاضلي تشغيل دلو groupBy المتأثر، ويلتزم React بتصحيح دقيق.

الخطوة 8: التفاعلية بين المكونات

يشترك استعلام مباشر واحد مرة واحدة لكنه يمكنه تشغيل أي عدد من المكونات. أنشئ شريطاً جانبياً يعرض العدادات المجمعة في src/components/SidebarStats.tsx:

"use client";
 
import { useLiveQuery, count, eq } from "@tanstack/react-db";
import { tasksCollection } from "@/db/collections";
 
export function SidebarStats({ projectId }: { projectId: string }) {
  const { data: stats } = useLiveQuery((q) =>
    q
      .from({ task: tasksCollection })
      .where(({ task }) => eq(task.projectId, projectId))
      .groupBy(({ task }) => task.status)
      .select(({ task }) => ({
        status: task.status,
        total: count(task.id),
      }))
  );
 
  return (
    <aside className="space-y-3 p-4">
      {stats.map((row) => (
        <div key={row.status} className="flex justify-between">
          <span className="capitalize">{row.status}</span>
          <span className="font-semibold">{row.total}</span>
        </div>
      ))}
    </aside>
  );
}

تشترك كل من اللوحة والشريط الجانبي في نفس مجموعة المهام الأساسية. عندما تسقط مهمة بين الأعمدة، يتحدث كلا المكونين من نفس التغيير المتزايد. لا يوجد إبطال يدوي للذاكرة المؤقتة، ولا prop drilling، ولا حافلة أحداث.

الخطوة 9: التكامل مع Server Components

يتيح App Router من Next.js الجلب المسبق على الخادم. يمكن ترطيب مجموعات TanStack DB من البيانات المعروضة على الخادم. أنشئ محمل خادم في src/app/projects/[id]/page.tsx:

import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/lib/get-query-client";
import { TaskBoard } from "@/components/TaskBoard";
import { SidebarStats } from "@/components/SidebarStats";
 
export default async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const queryClient = getQueryClient();
 
  await Promise.all([
    queryClient.prefetchQuery({
      queryKey: ["tasks"],
      queryFn: () => fetch(`${process.env.API_URL}/tasks`).then((r) => r.json()),
    }),
    queryClient.prefetchQuery({
      queryKey: ["users"],
      queryFn: () => fetch(`${process.env.API_URL}/users`).then((r) => r.json()),
    }),
  ]);
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <div className="grid grid-cols-[260px_1fr] gap-6 p-6">
        <SidebarStats projectId={id} />
        <TaskBoard projectId={id} />
      </div>
    </HydrationBoundary>
  );
}

نظراً لأن TanStack DB يجلس فوق TanStack Query، تعمل حدود الترطيب القياسية بدون تعديل. تصل مجموعتك ممتلئة في العرض الأول بدون أي وميض تحميل.

الخطوة 10: فهارس مخصصة للسرعة

للمجموعات التي تحتوي على آلاف الصفوف، يمكنك إضافة فهارس لتسريع المرشحات. حدّث تعريف مجموعة المهام:

export const tasksCollection = createCollection(
  queryCollectionOptions<Task>({
    id: "tasks",
    queryKey: ["tasks"],
    queryFn: loadTasks,
    getKey: (task) => task.id,
    indexes: [
      { id: "byProject", keyFn: (task) => task.projectId },
      { id: "byStatus", keyFn: (task) => task.status },
      { id: "byAssignee", keyFn: (task) => task.assigneeId ?? "" },
    ],
  })
);

يستخدم مخطط الاستعلام هذه الفهارس تلقائياً عندما تطابق جملة where. يعمل المرشح حسب projectId على عشرة آلاف مهمة الآن في ميكروثانية بدلاً من المسح الكامل.

الخطوة 11: المزامنة في الوقت الفعلي مع Server-Sent Events

اقرن TanStack DB مع SSE للتعاون متعدد المستخدمين المباشر. أنشئ src/hooks/useTaskSync.ts:

"use client";
 
import { useEffect } from "react";
import { tasksCollection } from "@/db/collections";
import type { Task } from "@/lib/types";
 
type Event =
  | { type: "task.created"; task: Task }
  | { type: "task.updated"; task: Task }
  | { type: "task.deleted"; taskId: string };
 
export function useTaskSync(projectId: string) {
  useEffect(() => {
    const source = new EventSource(`/api/projects/${projectId}/stream`);
 
    source.onmessage = (event) => {
      const payload: Event = JSON.parse(event.data);
      switch (payload.type) {
        case "task.created":
          tasksCollection.upsert(payload.task);
          break;
        case "task.updated":
          tasksCollection.upsert(payload.task);
          break;
        case "task.deleted":
          tasksCollection.delete(payload.taskId);
          break;
      }
    };
 
    return () => source.close();
  }, [projectId]);
}

ضع هذا الخطاف مرة واحدة لكل صفحة. يرى كل عميل متصل الآن نفس تحديثات المهام في الوقت الفعلي، بنفس الكفاءة التفاضلية للطفرات المحلية.

اختبار التنفيذ

شغّل خادم التطوير وافتح نافذتي متصفح جنباً إلى جنب:

npm run dev

اسحب مهمة في النافذة الأولى. تقفز البطاقة بين الأعمدة فوراً في النافذة الأولى وتصل إلى النافذة الثانية في غضون رحلة SSE. افتح ملف تعريف React DevTools وتأكد من أن المكونات المتأثرة فقط تعيد العرض. أضف مائة مهمة أخرى ولاحظ غياب أي تباطؤ ملحوظ.

استكشاف الأخطاء وإصلاحها

يُرجع الاستعلام المباشر بيانات قديمة بعد التنقل. تأكد من أن المزود يغلف التخطيط بأكمله، وليس صفحة واحدة. يبطل QueryClient الجديد ذاكرة التخزين المؤقت للمجموعة.

يومض التحديث التفاؤلي عندما يستجيب الخادم. هذا يعني عادة أن الخادم يُرجع سجلاً بشكل مختلف عن الإدراج التفاؤلي. اضبط كلا الشكلين أو استخدم onSuccess لربط حمولة الخادم قبل المصالحة.

لا يبدو أن الفهرس مستخدم. لا تعمل الفهارس إلا لمرشحات المساواة. يعود مرشح النطاق أو استدعاء دالة على الحقل المفهرس إلى المسح الكامل.

يشكو TypeScript من المعينين الفارغين في الانضمام. استخدم انضماماً أيسر وحدد جانب المستخدم على أنه اختياري في رد نداء select. يُعلم المخطط نظام الأنواع بأن المستخدم قد يكون مفقوداً.

الخطوات التالية

الخاتمة

يكمل TanStack DB الصورة التي بدأها TanStack Query. من خلال وضع نموذج مجموعات تفاعلية ومحرك استعلام differential dataflow فوق ذاكرة التخزين المؤقت التي تثق بها بالفعل، فإنه يزيل الاحتكاك بين بيانات الخادم والعروض التي تعتمد عليها. يتوقف تطبيقك عن التفكير في حالات التحميل وإبطال ذاكرة التخزين المؤقت ويبدأ في التفكير في البيانات — نظيفة ومُطبَّعة وتفاعلية من البداية إلى النهاية.

المكتبة لا تزال صغيرة، لكن سطح API بالفعل مركز ومريح. إذا قمت بإنشاء لوحة معلومات أو أداة تعاونية في React ووجدت نفسك تكافح مع حالة قديمة، أعطِ TanStack DB مشروعاً وشاهد الحواف الخشنة تختفي.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على بناء واجهات REST API جاهزة للإنتاج باستخدام FastAPI و PostgreSQL و Docker.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

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

بناء تطبيقات تعاونية محلية أولاً باستخدام Yjs و React

تعلم كيفية بناء تطبيقات تعاونية تعمل في الوقت الفعلي حتى بدون اتصال بالإنترنت باستخدام تقنية CRDTs ومكتبة Yjs مع React. يغطي هذا الدليل مزامنة البيانات بدون تعارضات، والهندسة المعمارية المحلية أولاً، وبناء محرر مستندات مشترك من الصفر.

30 د قراءة·

إضافة المصادقة لتطبيق Next.js 15 باستخدام Auth.js v5: البريد الإلكتروني وOAuth والتحكم بالأدوار

تعلم كيفية إضافة نظام مصادقة جاهز للإنتاج لتطبيق Next.js 15 باستخدام Auth.js v5. يغطي هذا الدليل الشامل تسجيل الدخول عبر Google OAuth وبيانات الاعتماد بالبريد الإلكتروني وكلمة المرور والمسارات المحمية والتحكم بالوصول حسب الأدوار.

30 د قراءة·