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
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
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 },
});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
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 را انتخاب کرده دوباره رندر میشود. (در حالت توسعه ممکن است هر لاگ دو بار چاپ شود؛ توضیح آن در درس ۶-۴ بود.)
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>
);
}جمعبندی
پیش از انتخاب ابزار State Management، Server State را به یک کتابخانه تخصصی بسپارید؛ آنچه میماند معمولاً کوچک است. Context با useReducer تا وقتی کافی است که مشکلی ملموس نداشته باشید. Zustand، Redux Toolkit، Jotai، Valtio، MobX و XState مدلهای ذهنی متفاوتی دارند، و Recoil دیگر فعال نیست. همه آنها روی ایده Store بیرونی و useSyncExternalStore بنا شدهاند، و باید با Selector مصرف شوند، نه با خواندن کل Store. در برنامههای با رندر سمت سرور، مراقب Storeهای سراسری باشید. در درس بعدی، به Testing در اکوسیستم میپردازیم.
