State Management در اکوسیستم React

در فصل شانزدهم، اصول مدیریت State در مقیاس بزرگ را دیدیم: سطوح Local، Lifted و Global، تفاوت Client State با Server State، نشانه‌های نیاز به ابزار و Trade-offها. این درس همان موضوع را از نگاه اکوسیستم دنبال می‌کند: امروز ابزارها چه شکلی دارند، هرکدام چگونه کار می‌کنند، و چه اشتباه‌هایی در عمل رایج است.

تصحیحی نسبت به درس ۱۶-۵

در آن درس، Recoil را کنار Jotai به‌عنوان رویکرد Atomic نام بردیم. منابع اکوسیستم Recoil را دیگر پروژه‌ای فعال نمی‌دانند و آن را متوقف‌شده (Discontinued یا Deprecated) توصیف می‌کنند، پس برای پروژه جدید توصیه نمی‌شود. Jotai مدل ذهنی مشابهی دارد و همچنان فعال است. اگر در پروژه قدیمی به Recoil برخوردید، مهاجرت را در برنامه‌تان بگذارید.

اصل نخست: اول Server State را جدا کنید

همان‌طور که در درس‌های ۱۶-۲ و ۲۵-۳ دیدیم، بخش بزرگی از چیزی که در برنامه‌ها «State» می‌نامیم در واقع Server State است و باید با ابزارهایی مثل TanStack Query مدیریت شود. وقتی این بخش جدا شود، چیزی که برای ابزار State Management می‌ماند (Client State) معمولاً کوچک است: تم، فیلترهای انتخاب‌شده، وضعیت باز بودن پنل‌ها، پیش‌نویس فرم‌ها. به همین دلیل، بسیاری از برنامه‌ها پس از این جداسازی، نیازشان به یک Store سنگین کم می‌شود. در معماری Server Components هم (فصل ۲۴) بخشی از داده اصلاً به Client State تبدیل نمی‌شود، چون روی سرور می‌ماند.

نقشه ابزارهای Client State

ابزار مدل ذهنی مناسب برای
Context + useReducer داخلی React (فصل ۸ و ۹) State کوچک تا متوسط، بدون وابستگی بیرونی
Zustand یک Store ساده، بدون Provider پروژه‌های جدید با Global State سبک
Redux Toolkit یک Store متمرکز با Slice و Reducer پروژه‌های بزرگ، تیم‌های آشنا با Redux، نیاز به ابزار دیباگ قوی
Jotai Atomهای کوچک و مستقل State پراکنده با وابستگی‌های مشتق‌شده
Valtio / MobX آبجکت قابل‌تغییر و واکنشی (Proxy یا Observable) تیم‌هایی که مدل شیءگرا را ترجیح می‌دهند
XState ماشین حالت (State Machine) جریان‌های پیچیده با وضعیت‌ها و گذارهای مشخص

طبق گزارش‌های منتشرشده، Zustand برای پروژه‌های جدید بسیار رایج شده و Redux Toolkit هنوز در پروژه‌های بزرگ و قدیمی‌تر پراستفاده است. این‌ها روندهای قابل‌تغییرند، نه قانون؛ انتخاب باید بر اساس نیاز پروژه باشد (چارچوب انتخاب در درس ۲۵-۹ جمع‌بندی می‌شود).

Zustand

jsx
import { create } from "zustand";

const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);

  return <button onClick={increment}>{count}</button>;
}

خود Store یک Hook است و هیچ Provider ای لازم نیست. تابعی که به Hook داده می‌شود ((state) => state.count) Selector نام دارد: کامپوننت فقط به همان بخش از Store وابسته می‌شود. این همان ایده‌ای است که در درس ۸-۳ دیدیم: وقتی چند مقدار مستقل در یک Context بود، تغییر هرکدام همه مصرف‌کنندگان را رندر می‌کرد. Selector همین مشکل را در سطح Store حل می‌کند.

Redux Toolkit

jsx
import { createSlice, configureStore } from "@reduxjs/toolkit";

const counterSlice = createSlice({
  name: "counter",
  initialState: { count: 0 },
  reducers: {
    incremented(state) {
      state.count += 1;
    },
  },
});

export const { incremented } = counterSlice.actions;

export const store = configureStore({
  reducer: { counter: counterSlice.reducer },
});
jsx
import { Provider, useSelector, useDispatch } from "react-redux";

function Counter() {
  const count = useSelector((state) => state.counter.count);
  const dispatch = useDispatch();

  return <button onClick={() => dispatch(incremented())}>{count}</button>;
}

// در ریشه: <Provider store={store}><Counter /></Provider>

این همان ساختار Reducer، Action و Dispatch فصل نهم است، فقط در سطح کل برنامه. یک نکته مهم: در createSlice می‌توان داخل Reducer به‌ظاهر State را مستقیم تغییر داد (state.count += 1)، ولی این یک ظاهر است؛ ابزار Immer پشت صحنه از آن یک به‌روزرسانی Immutable می‌سازد. این برخلاف Reducerهای دستی فصل نهم و قاعده Immutability فصل سوم است که در آن‌ها باید خودمان آبجکت جدید برمی‌گرداندیم. RTK Query، که در درس ۲۵-۳ نام بردیم، بخشی از همین بسته است.

Jotai

jsx
import { atom, useAtom } from "jotai";

const countAtom = atom(0);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

هر atom یک واحد کوچک و مستقل State است، و کامپوننت‌ها فقط به Atomهایی که می‌خوانند وابسته می‌شوند.

زیر کاپوت: چرا این ابزارها با React هماهنگ‌اند

همه این کتابخانه‌ها در اصل یک Store بیرون از React هستند و باید به React خبر دهند که چه زمانی تغییر کرده است. این دقیقاً مسئله‌ای است که useSyncExternalStore (درس ۲۳-۲) برای آن ساخته شده، و همان دلیلی که گفتیم کتابخانه‌های State پشت صحنه از آن استفاده می‌کنند تا در Concurrent Rendering دچار Tearing نشوند. مثال قابل اجرای این درس، یک Store مینیمال شبیه Zustand را با همین Hook می‌سازد.

اشتباه‌های رایج

  • خواندن کل Store بدون Selector: کامپوننت با هر تغییر هر بخش Store رندر می‌شود. همیشه فقط بخش موردنیاز را انتخاب کنید.
  • برگرداندن آبجکت یا آرایه تازه از Selector: همان مشکل Referential Equality فصل سیزدهم و getSnapshot درس ۲۳-۲ است؛ در هر رندر مرجع جدید یعنی «تغییر کرده». کتابخانه‌ها معمولاً ابزار مقایسه سطحی برای این کار دارند (مثلاً useShallow در Zustand).
  • ریختن Server State در Store: داده API را در Zustand یا Redux کپی نکنید؛ همان خطای کپی داده در useState درس ۲۵-۳.
  • Store سراسری روی سرور: در برنامه‌های با رندر سمت سرور، یک Store که در سطح ماژول ساخته شده، بین درخواست‌های مختلف مشترک می‌ماند و می‌تواند داده یک کاربر را به کاربر دیگر نشت دهد. Store را داخل Client Component یا به‌ازای هر درخواست بسازید و در Server Component از آن نخوانید و ننویسید (درس ۲۴-۲).
  • Global کردن همه‌چیز: طبق اصل State Colocation (درس ۳-۶)، State را فقط وقتی به Store ببرید که واقعاً چند بخش دور از هم به آن نیاز دارند.

چیزهایی که لازم نیست «State Management» باشند

  • URL: فیلتر، صفحه و تب فعال را می‌توان در Query Parameter نگه داشت (درس ۱۵-۴)؛ قابل اشتراک‌گذاری و سازگار با دکمه Back.
  • فرم‌ها: برای فرم‌های بزرگ، کتابخانه‌های مخصوص فرم (مثل React Hook Form) مسئله Validation و مدیریت فیلدها را سبک‌تر از یک Store سراسری حل می‌کنند.

مثال قابل اجرا

در این مثال، یک Store مینیمال با useSyncExternalStore ساخته شده است. دو کامپوننت از دو بخش مختلف Store می‌خوانند. دکمه اول فقط count را عوض می‌کند: کنسول را باز کنید و ببینید فقط کامپوننتی که count را انتخاب کرده دوباره رندر می‌شود. (در حالت توسعه ممکن است هر لاگ دو بار چاپ شود؛ توضیح آن در درس ۶-۴ بود.)

یک Store مینیمال شبیه Zustand با useSyncExternalStore
import { useSyncExternalStore } from "react";

function createStore(initializer) {
  let state;
  const listeners = new Set();

  function setState(partial) {
    const next = typeof partial === "function" ? partial(state) : partial;
    state = { ...state, ...next };
    listeners.forEach((listener) => listener());
  }

  state = initializer(setState);

  return {
    getState: () => state,
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

function useStore(store, selector) {
  return useSyncExternalStore(store.subscribe, () => selector(store.getState()));
}

const store = createStore((set) => ({
  count: 0,
  name: "سارا",
  increment: () => set((s) => ({ count: s.count + 1 })),
  rename: () => set((s) => ({ name: s.name + "!" })),
}));

function CountLabel() {
  console.log("CountLabel رندر شد");
  const count = useStore(store, (s) => s.count);
  return <p>شمارنده: {count}</p>;
}

function NameLabel() {
  console.log("NameLabel رندر شد");
  const name = useStore(store, (s) => s.name);
  return <p>نام: {name}</p>;
}

function App() {
  const { increment, rename } = store.getState();

  return (
    <div>
      <CountLabel />
      <NameLabel />
      <button onClick={increment}>افزایش شمارنده</button>
      <button onClick={rename}>تغییر نام</button>
    </div>
  );
}
برای دیدن پیش‌نمایش کامپوننت، روی Run بزنید.

جمع‌بندی

پیش از انتخاب ابزار State Management، Server State را به یک کتابخانه تخصصی بسپارید؛ آنچه می‌ماند معمولاً کوچک است. Context با useReducer تا وقتی کافی است که مشکلی ملموس نداشته باشید. Zustand، Redux Toolkit، Jotai، Valtio، MobX و XState مدل‌های ذهنی متفاوتی دارند، و Recoil دیگر فعال نیست. همه آن‌ها روی ایده Store بیرونی و useSyncExternalStore بنا شده‌اند، و باید با Selector مصرف شوند، نه با خواندن کل Store. در برنامه‌های با رندر سمت سرور، مراقب Storeهای سراسری باشید. در درس بعدی، به Testing در اکوسیستم می‌پردازیم.