Jotai 2 — إدارة الحالة الذرية في React و Next.js: من الصفر إلى الإنتاج

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

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

مقاربة ذرية تصاعدية لحالة React. يتعامل Jotai مع الحالة كمجموعة من الذرات — وحدات صغيرة مستقلة تتكوّن معًا لتشكّل حالة تطبيقك. لا reducers، ولا providers افتراضيًا، ولا نمطية selector. في هذا الدرس، ستبني عربة تسوّق حقيقية باستخدام Next.js 15 App Router، مع تعلم كل نمط Jotai تحتاجه للإنتاج.

ما الذي ستتعلمه

عند إتمام هذا الدليل، ستكون قادرًا على:

  • إعداد Jotai 2 في مشروع Next.js 15 App Router مع TypeScript
  • إنشاء ذرات أساسية، وذرات مشتقة، وذرات للكتابة فقط
  • التعامل مع الذرات غير المتزامنة عبر Suspense وأداة loadable
  • حفظ الحالة في localStorage باستخدام atomWithStorage
  • ترطيب الحالة المُصيَّرة على الخادم بأمان عبر useHydrateAtoms
  • بناء عائلات ذرية للمجموعات الديناميكية
  • التصحيح باستخدام Jotai DevTools
  • شحن عربة تسوق إنتاجية ببنية ذرية حقيقية

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

قبل البدء، تأكد من توفر:

  • Node.js 20+ (node --version)
  • خبرة في TypeScript (الأنواع، generics، hooks)
  • معرفة بأساسيات Next.js App Router (layouts، صفحات، مكونات خادم/عميل)
  • فهم أساسي لحالة React (useState وuseContext)

لماذا Jotai في 2026؟

تطوّرت إدارة الحالة في React إلى ثلاث مدارس رئيسية: القائمة على المخزن (Redux، Zustand)، والذرية (Jotai، Recoil)، والمبنية على الإشارات (Preact Signals، Solid). Jotai هو الخيار الذرّي الأكثر شعبية ويتناغم بشكل رائع مع React 19 و Next.js 15.

الخاصيةJotaiZustandRedux ToolkitContext API
حجم الحزمة~3 KB~1 KB~12 KB0 KB (مدمج)
النموذج الذهنيذرّي (تصاعدي)مخزن (تنازلي)مخزن + reducersشجري
النمطيةضئيلةضئيلةمتوسطةعالية عند التوسع
تجربة TypeScriptممتازةممتازةجيدةيدوية
الأساسيات غير المتزامنةمن الدرجة الأولىيدويةعبر thunks/sagasيدوية
دعم SSRمدمجمدمجإعداد إضافيمدمج
التحكم في إعادة التصييرلكل ذرة (تلقائي)عبر selectorsعبر selectorsالشجرة كاملة

يتألّق النموذج الذرّي عندما يحتوي رسم الحالة على شرائح مستقلة كثيرة تشتق بعضها من بعض. غيّر ذرة واحدة فتعيد التصيير فقط المكونات التي تقرأها فعلًا — بلا selectors ولا ألعاب memoization.


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

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

npx create-next-app@latest jotai-cart --typescript --tailwind --app --src-dir
cd jotai-cart

ثبّت Jotai وإضافة DevTools:

npm install jotai
npm install -D jotai-devtools

ستبدو بنية المشروع على النحو التالي:

jotai-cart/
├── src/
│   ├── app/
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── atoms/          # الذرات هنا
│   ├── components/
│   └── lib/
├── package.json
└── tsconfig.json

أنشئ مجلد atoms/ — ستعيش الذرات هناك منظمة حسب المجال.

mkdir -p src/atoms src/components

الخطوة 2: أول ذرة لك

اللبنة الأساسية في Jotai هي atom. فكّر في الذرة كقطعة حالة يستطيع أي مكوّن قراءتها والكتابة فيها، دون أي provider.

أنشئ src/atoms/counter.ts:

import { atom } from "jotai";
 
export const countAtom = atom(0);

هذا كل شيء — لا مخزن، لا شريحة، لا reducer. استخدمها في مكوّن:

// src/components/Counter.tsx
"use client";
 
import { useAtom } from "jotai";
import { countAtom } from "@/atoms/counter";
 
export function Counter() {
  const [count, setCount] = useAtom(countAtom);
 
  return (
    <div className="flex items-center gap-3">
      <button
        onClick={() => setCount((c) => c - 1)}
        className="rounded bg-slate-200 px-3 py-1"
      >
        -
      </button>
      <span className="text-xl font-semibold">{count}</span>
      <button
        onClick={() => setCount((c) => c + 1)}
        className="rounded bg-slate-200 px-3 py-1"
      >
        +
      </button>
    </div>
  );
}

ثم أسقط <Counter /> في صفحة. تُعيد useAtom نفس شكل الصفّين مثل useState، لكن القيمة مشتركة عبر التطبيق بأكمله.

مهم — "use client". تعمل خطّافات Jotai (useAtom, useSetAtom, useAtomValue) فقط في مكونات العميل. أضف "use client" في بداية أي ملف مكوّن يستخدمها.

نسختا القراءة فقط والكتابة فقط

إذا كان المكوّن يقرأ فقط، استخدم useAtomValue لتخطي setter. إن كان يكتب فقط، استخدم useSetAtom لتجنب إعادة التصيير عند تغيّر القيمة.

import { useAtomValue, useSetAtom } from "jotai";
import { countAtom } from "@/atoms/counter";
 
function Display() {
  const count = useAtomValue(countAtom);
  return <span>{count}</span>;
}
 
function IncrementButton() {
  const setCount = useSetAtom(countAtom);
  return <button onClick={() => setCount((c) => c + 1)}>+1</button>;
}

لن يُعاد تصيير IncrementButton أبدًا عند تغيّر count، رغم أنه يكتب في الذرة نفسها. هذا تلقائي — دون الحاجة إلى memoization.


الخطوة 3: الذرات المشتقة

تقرأ الذرة المشتقة من ذرات أخرى وتعيد الحساب كلما تغيّرت إحداها. هذا قلب الحالة الذرية — تُعلن عن العلاقات، ويبقيها Jotai متزامنة.

أنشئ src/atoms/cart.ts:

import { atom } from "jotai";
 
export type CartItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
};
 
export const cartItemsAtom = atom<CartItem[]>([]);
 
export const cartTotalAtom = atom((get) => {
  const items = get(cartItemsAtom);
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});
 
export const cartCountAtom = atom((get) => {
  const items = get(cartItemsAtom);
  return items.reduce((sum, item) => sum + item.quantity, 0);
});

cartTotalAtom و cartCountAtom ذرات مشتقة للقراءة فقط. تعتمدان على cartItemsAtom، لذا كلما تغيّرت العناصر تُحدَّث المجاميع — وتعيد التصيير فقط المكونات التي تقرأ تلك الذرات المشتقة.

ذرات مشتقة قابلة للكتابة

يمكنك أيضًا إنشاء ذرات تحوّل عمليات الكتابة. الوسيط الثاني لـ atom هو دالة كتابة:

export const addItemAtom = atom(
  null, // لا قيمة قراءة — كتابة فقط
  (get, set, newItem: CartItem) => {
    const items = get(cartItemsAtom);
    const existing = items.find((i) => i.id === newItem.id);
 
    if (existing) {
      set(
        cartItemsAtom,
        items.map((i) =>
          i.id === newItem.id
            ? { ...i, quantity: i.quantity + newItem.quantity }
            : i,
        ),
      );
    } else {
      set(cartItemsAtom, [...items, newItem]);
    }
  },
);
 
export const removeItemAtom = atom(null, (get, set, id: string) => {
  set(
    cartItemsAtom,
    get(cartItemsAtom).filter((i) => i.id !== id),
  );
});

ذرات الإجراءات كهذه تُبقي مكوناتك خالية من منطق الأعمال. يستدعي الزرّ setAddItem(newItem) دون معرفة القواعد.

"use client";
 
import { useSetAtom } from "jotai";
import { addItemAtom } from "@/atoms/cart";
 
export function AddToCart({ item }: { item: CartItem }) {
  const addItem = useSetAtom(addItemAtom);
  return (
    <button onClick={() => addItem({ ...item, quantity: 1 })}>
      أضف إلى السلة
    </button>
  );
}

الخطوة 4: الحالة المستمرة عبر atomWithStorage

عربات التسوق الحقيقية تنجو من التحديث. يوفّر Jotai أداة atomWithStorage تُزامن ذرة مع localStorage أو sessionStorage أو أي تخزين مخصّص:

import { atomWithStorage } from "jotai/utils";
import type { CartItem } from "./cart";
 
export const persistedCartAtom = atomWithStorage<CartItem[]>(
  "noqta-cart-v1",
  [],
);

استبدل cartItemsAtom بـ persistedCartAtom حيثما تريد الاستمرار. الواجهة متطابقة — ترقية تعويضية.

الحماية من عدم تطابق ترطيب SSR

على الخادم، لا يوجد localStorage. تُعيد atomWithStorage القيمة الابتدائية على الخادم ثم تُرطّب القيمة الفعلية على العميل. لتجنّب نمط الوميض، انتظر التركيب:

"use client";
 
import { useEffect, useState } from "react";
import { useAtomValue } from "jotai";
import { persistedCartAtom } from "@/atoms/cart";
 
export function CartBadge() {
  const [mounted, setMounted] = useState(false);
  const items = useAtomValue(persistedCartAtom);
 
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;
 
  return <span className="badge">{items.length}</span>;
}

نصيحة. يمكنك أيضًا استخدام الخيار getOnInit: true بحيث تقرأ atomWithStorage التخزين بشكل متزامن أثناء الترطيب. يتجنّب ذلك الوميض، لكنه يعمل بأمان فقط في صفحات مُصيَّرة بالكامل على العميل.


الخطوة 5: الذرات غير المتزامنة لجلب البيانات

يمكن لذرات Jotai أن تكون غير متزامنة. قد تُعيد دالة القراءة Promise، ويتكامل Jotai مع React Suspense للانتظار.

import { atom } from "jotai";
 
export type Product = {
  id: string;
  name: string;
  price: number;
};
 
export const productsAtom = atom(async (): Promise<Product[]> => {
  const res = await fetch("/api/products");
  if (!res.ok) throw new Error("Failed to load products");
  return res.json();
});

اقرأها مع useAtomValue — القيمة جاهزة:

"use client";
 
import { Suspense } from "react";
import { useAtomValue } from "jotai";
import { productsAtom } from "@/atoms/products";
 
function ProductList() {
  const products = useAtomValue(productsAtom);
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          {p.name} — ${p.price}
        </li>
      ))}
    </ul>
  );
}
 
export function ProductsPage() {
  return (
    <Suspense fallback={<p>جارٍ تحميل المنتجات...</p>}>
      <ProductList />
    </Suspense>
  );
}

تخطي Suspense باستخدام loadable

إذا كنت تُفضّل حالات تحميل وخطأ دقيقة بدلًا من Suspense، غلّف الذرة بـ loadable:

import { loadable } from "jotai/utils";
 
export const productsLoadableAtom = loadable(productsAtom);
const value = useAtomValue(productsLoadableAtom);
 
if (value.state === "loading") return <Skeleton />;
if (value.state === "hasError") return <Error error={value.error} />;
return <ProductList data={value.data} />;

loadable مثالي للوحات القيادة حيث تريد مُحمِّلات مستقلة لكل ويدجت، لا بديلًا واحدًا للصفحة كلها.


الخطوة 6: العائلات الذرية للمجموعات الديناميكية

تُنشئ العائلات الذرية ذرات عند الطلب، مُفهرسة بمعامِل. مثالية لنمط "ذرة واحدة لكل عنصر" — فكّر في قائمة مهام تمتلك فيها كل مهمة ذرتها الخاصة.

import { atomFamily } from "jotai/utils";
import { atom } from "jotai";
 
export type Todo = { id: string; text: string; done: boolean };
 
export const todoAtomFamily = atomFamily((id: string) =>
  atom<Todo>({ id, text: "", done: false }),
);

استخدمها داخل مكوّن باستدعاء العائلة بمعرّف:

function TodoItem({ id }: { id: string }) {
  const [todo, setTodo] = useAtom(todoAtomFamily(id));
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={(e) => setTodo({ ...todo, done: e.target.checked })}
      />
      {todo.text}
    </label>
  );
}

يؤدي تحديث مهمة واحدة إلى إعادة تصيير TodoItem الخاص بها فقط — لا تومض العناصر الشقيقة. هذه هي الدقة الذرية في العمل.


الخطوة 7: ترطيب SSR في Next.js App Router

يُصيِّر Next.js 15 الصفحات على الخادم. لتعبئة الذرات ببيانات الخادم، استخدم useHydrateAtoms من jotai/utils. غلّف القيم الأولية في مكوّن عميل يُشغَّل مرة واحدة، ثم صيّر الأبناء.

أنشئ src/components/AtomsHydrator.tsx:

"use client";
 
import { useHydrateAtoms } from "jotai/utils";
import type { ReactNode } from "react";
import type { Product } from "@/atoms/products";
import { productsAtom } from "@/atoms/products";
 
type HydrateProps = {
  products: Product[];
  children: ReactNode;
};
 
export function AtomsHydrator({ products, children }: HydrateProps) {
  useHydrateAtoms([[productsAtom, products]]);
  return <>{children}</>;
}

اجلب البيانات على الخادم ثم مرّرها:

// src/app/page.tsx (server component)
import { AtomsHydrator } from "@/components/AtomsHydrator";
import { ProductList } from "@/components/ProductList";
 
async function getProducts() {
  const res = await fetch("https://api.noqta.tn/products", {
    next: { revalidate: 60 },
  });
  return res.json();
}
 
export default async function Page() {
  const products = await getProducts();
 
  return (
    <AtomsHydrator products={products}>
      <ProductList />
    </AtomsHydrator>
  );
}

عند إقلاع العميل، يحتوي productsAtom بالفعل على بيانات الخادم — لا وميض تحميل، ولا إعادة جلب.

لماذا لا نستخدم Provider مع initialValues؟ في Jotai v1 كان بإمكانك تمرير initialValues إلى <Provider>. في Jotai 2 الواجهة هي useHydrateAtoms. هذا خطّاف واحد يعمل سواء استخدمت Provider أم لا.

نطاق Provider للحالة متعددة المستأجرين

لا تحتاج معظم التطبيقات إلى <Provider>. النطاق الافتراضي عام، وهو عادة ما تريده. استعمل Provider فقط عندما تحتاج نسخ ذرات معزولة — مثلًا نافذة منبثقة يجب ألا تتسرّب حالتها، أو لوحات متعددة المستأجرين تحمل مستخدمين مختلفين.

import { Provider } from "jotai";
 
export function TenantScope({ children }: { children: ReactNode }) {
  return <Provider>{children}</Provider>;
}

أي شيء داخل TenantScope يقرأ نسخته الخاصة من كل ذرة مستخدمة فيه، معزولًا عن بقية الشجرة.


الخطوة 8: التصحيح باستخدام Jotai DevTools

تم التثبيت في الخطوة 1. ركّب مكوّن DevTools في التطوير فقط:

// src/components/DevTools.tsx
"use client";
 
import { DevTools as JotaiDevTools } from "jotai-devtools";
import "jotai-devtools/styles.css";
 
export function DevTools() {
  if (process.env.NODE_ENV !== "development") return null;
  return <JotaiDevTools theme="dark" />;
}

ضعه في أعلى layout الجذر. ستحصل على لوحة تعرض كل ذرة حيّة وقيمتها وتبعياتها وشريط سفر عبر الزمن.


الخطوة 9: وضع كل شيء معًا — عربة التسوق

أنشئ src/atoms/shop.ts:

import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
 
export type Product = { id: string; name: string; price: number };
export type CartLine = { productId: string; quantity: number };
 
export const productsAtom = atom<Product[]>([]);
 
export const cartLinesAtom = atomWithStorage<CartLine[]>("noqta-cart", []);
 
export const cartDetailsAtom = atom((get) => {
  const products = get(productsAtom);
  const lines = get(cartLinesAtom);
 
  return lines
    .map((line) => {
      const product = products.find((p) => p.id === line.productId);
      if (!product) return null;
      return {
        ...product,
        quantity: line.quantity,
        subtotal: product.price * line.quantity,
      };
    })
    .filter(Boolean);
});
 
export const cartTotalAtom = atom((get) =>
  get(cartDetailsAtom).reduce((sum, l) => sum + (l?.subtotal ?? 0), 0),
);
 
export const addToCartAtom = atom(
  null,
  (get, set, productId: string, quantity = 1) => {
    const lines = get(cartLinesAtom);
    const existing = lines.find((l) => l.productId === productId);
    if (existing) {
      set(
        cartLinesAtom,
        lines.map((l) =>
          l.productId === productId
            ? { ...l, quantity: l.quantity + quantity }
            : l,
        ),
      );
    } else {
      set(cartLinesAtom, [...lines, { productId, quantity }]);
    }
  },
);
 
export const removeFromCartAtom = atom(null, (get, set, productId: string) => {
  set(
    cartLinesAtom,
    get(cartLinesAtom).filter((l) => l.productId !== productId),
  );
});

ومكوّن ملخّص السلة:

"use client";
 
import { useAtomValue, useSetAtom } from "jotai";
import {
  cartDetailsAtom,
  cartTotalAtom,
  removeFromCartAtom,
} from "@/atoms/shop";
 
export function CartSummary() {
  const items = useAtomValue(cartDetailsAtom);
  const total = useAtomValue(cartTotalAtom);
  const remove = useSetAtom(removeFromCartAtom);
 
  if (items.length === 0) return <p>سلّتك فارغة.</p>;
 
  return (
    <div className="space-y-3">
      {items.map((item) =>
        item ? (
          <div
            key={item.id}
            className="flex items-center justify-between border-b py-2"
          >
            <div>
              <p className="font-medium">{item.name}</p>
              <p className="text-sm text-slate-500">
                {item.quantity} x ${item.price}
              </p>
            </div>
            <div className="flex items-center gap-3">
              <span className="font-semibold">${item.subtotal}</span>
              <button
                onClick={() => remove(item.id)}
                className="text-red-500 hover:underline"
              >
                إزالة
              </button>
            </div>
          </div>
        ) : null,
      )}
      <div className="flex justify-between pt-2 text-lg font-bold">
        <span>المجموع</span>
        <span>${total}</span>
      </div>
    </div>
  );
}

ثلاث ذرات، إجراءان، دون أي نمطية Redux. يشترك كل مكوّن فقط في الذرات التي يقرأها — أضف سطرًا فتُعاد تصيير ملخّص السلة فقط، لا شبكة المنتجات.


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

ذرات Jotai سهلة الاختبار لأنها قيم عادية. استخدم المساعد createStore للحصول على مخزن معزول لكل اختبار:

import { createStore } from "jotai";
import { cartLinesAtom, addToCartAtom, cartTotalAtom } from "@/atoms/shop";
 
test("adds items and computes total", () => {
  const store = createStore();
  store.set(addToCartAtom, "p1", 2);
  store.set(addToCartAtom, "p1", 1);
 
  expect(store.get(cartLinesAtom)).toEqual([
    { productId: "p1", quantity: 3 },
  ]);
});

اجمع ذلك مع Vitest أو Jest لتغطّي كل تفاعلات الذرات دون تركيب React.


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

تعيد المكونات التصيير أكثر من المتوقع. على الأرجح تُفكّك ذرة كائنية. قسّم الذرات الكبيرة إلى أصغر، أو استخدم selectAtom للاشتراك في شريحة.

عدم تطابق ترطيب مع atomWithStorage. استخدم علم mounted داخل مكونات العميل، أو صيّر الذرة المخزّنة فقط بعد أول تركيب.

"Cannot find module 'jotai/utils'". قد تحتاج TypeScript إلى خريطة مسار صريحة. تأكد أن tsconfig.json يحتوي "moduleResolution": "bundler" (Next.js 15 يضبطها افتراضيًا).

الذرة غير المتزامنة ترمي خطأ أثناء SSR. غلّف المستهلك في <Suspense>، أو انتقل إلى loadable لتصبح الحالة قابلة للفحص بدلًا من معلّقة.

لوحة DevTools لا تظهر. تأكد من استيراد jotai-devtools/styles.css وأن process.env.NODE_ENV يساوي "development" وقت التشغيل.


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

  • استكشف jotai-effect للتأثيرات الجانبية العامة المرتبطة بدورات حياة الذرات
  • اقرن Jotai بـ TanStack Query باستخدام jotai-tanstack-query لحالة الخادم المخزّنة مؤقتًا
  • قارن المعماريات عبر قراءة دليل Zustand + Next.js لدينا
  • تعلّم عن أنماط React Server Components في دليل Next.js 15 PPR

الخلاصة

Jotai بسيط بشكل خادع: بدائي atom واحد يتألّف إلى معماريات تطبيق كاملة. يمنحك النموذج الذرّي تفاعلية دقيقة تلقائية، شبه صفر نمطية، وقصة TypeScript ممتعة. عندما يكبر تطبيق React لديك أبعد من useState وتريد مكتبة حالة تبدو وكأنها React أصلي، فإن Jotai هو الخيار الأكثر أناقة في 2026.

تمتلك الآن الطاقم الكامل: ذرات أساسية ومشتقة، إجراءات قابلة للكتابة، استمرارية، بيانات غير متزامنة، ترطيب SSR، عائلات ذرية، وأدوات مطوّر. اشحن شيئًا بها.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على مقدمة في MCP: دليل البدء السريع للمبتدئين.

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

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

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

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

Neon Serverless Postgres مع Next.js App Router: بناء تطبيق كامل مع تفريع قواعد البيانات

تعلّم كيفية بناء تطبيق Next.js كامل مدعوم بقاعدة بيانات Neon Serverless Postgres. يغطي هذا الدليل التطبيقي برنامج التشغيل بدون خادم، تفريع قواعد البيانات لعمليات النشر التجريبية، تجميع الاتصالات، وأنماط الإنتاج الجاهزة.

28 د قراءة·