توسعه فرانت‌اند

Zustand: مدیریت state مشترک در React

انتخاب state مشترک، action و selector، تغییر immutable، مرز persist و SSR و نمونه کامل React برای فهرست کارها با Zustand.

1404/11/19 5 دقیقه مطالعه 1,797 بازدید

وقتی چند بخش صفحه به یک state نیاز دارند

فرض کنید فرم افزودن کار، فهرست کارها و شمارندهٔ کارهای انجام‌شده در سه component جدا قرار دارند. اگر هر کدام نسخهٔ مستقل داده را نگه دارند، هماهنگ کردنشان دشوار می‌شود. می‌توان state را به والد مشترک برد و با props منتقل کرد؛ برای خیلی از صفحه‌ها همین انتخاب کافی است. وقتی همین داده در شاخه‌های دورتر هم مصرف می‌شود یا عمرش از یک component بیشتر است، یک store مشترک می‌تواند مرز مسئولیت را روشن کند. Zustand یکی از ابزارهای این کار در React است.

انتخاب آن باید از نیاز برنامه بیاید. عبارت‌هایی مثل بهترین کتابخانه یا محبوب‌ترین گزینه، تصمیم معماری را توضیح نمی‌دهند. اندازهٔ بسته هم به نسخه، روش build و middlewareهای مصرفی وابسته است و یک عدد ثابت برای همهٔ پروژه‌ها نداریم. آنچه اینجا بررسی می‌کنیم، تعریف state و action، انتخاب بخش مصرفی با selector و مرز دادهٔ مشترک است. مثال نهایی کوچک است تا بتوان مسیر تغییر داده را دنبال کرد، نه اینکه شکل هر پروژه را تعیین کند.

قبل از ساخت store، صاحب داده را پیدا کنید

متن در حال تایپ یک فرم معمولاً متعلق به همان فرم است. فیلتر قابل اشتراک‌گذاری یک صفحه شاید بهتر باشد در URL ثبت شود. پاسخ API با زمان اعتبار، خطا، retry و cache، مسئلهٔ مدیریت دادهٔ سرور است. تنظیم نمایش که چند بخش رابط مصرف می‌کنند می‌تواند state مشترک سمت کاربر باشد. جمع کردن همهٔ این موارد در یک store بزرگ، الزاماً کار را ساده نمی‌کند. برای هر مقدار بپرسید چه کسی آن را تغییر می‌دهد، چه کسی می‌خواند و چه زمانی باید از بین برود.

در نمونهٔ این مقاله، متن input محلی است و پس از ثبت به فهرست مشترک می‌رود. شمارنده از همان فهرست محاسبه می‌شود؛ عدد جداگانه‌ای برای تعداد کارها ذخیره نمی‌کنیم. داشتن دو منبع برای یک واقعیت، خطر ناهماهنگی می‌آورد. اگر حذف کار انجام شود و شمارنده فراموش شود، رابط دو جواب مختلف می‌دهد. دادهٔ مشتق‌شده را تا وقتی هزینهٔ محاسبه مسئله نشده، از منبع اصلی به دست بیاورید و دلیل ذخیرهٔ نسخهٔ دوم را روشن نگه دارید.

Store، action و selector

تابع create می‌تواند یک hook برای مصرف store بسازد. در تعریف اولیه، داده‌ها و actionها کنار هم قرار می‌گیرند. action نامی برای یک تغییر معنادار است؛ مانند افزودن کار یا تغییر وضعیت آن. component نباید جزئیات تمام ساختار داده را در هر handler تکرار کند. روش پایه در مخزن رسمی Zustand آمده است. برای store سراسری ساده به Provider اجباری نیاز نیست، اما این به معنی بی‌اهمیت بودن محدوده و عمر store نیست.

Selector مشخص می‌کند component کدام قسمت را می‌خواند. اگر تمام store را بدون selector مصرف کنید، تغییر بخش‌های نامرتبط هم می‌تواند آن component را دوباره به‌روزرسانی کند. انتخاب مستقیم یک عدد، رشته یا reference موجود، قرارداد ساده‌تری دارد. ساختن object یا array تازه در هر اجرای selector نیازمند توجه به پایداری خروجی است. راهنمای useShallow برای انتخاب چند مقدار توضیح می‌دهد چگونه مقایسهٔ سطحی به خروجی مناسب کمک می‌کند؛ هر محاسبه‌ای هم با shallow برابر نمی‌شود.

تغییر داده باید reference مناسب بسازد

وقتی آرایهٔ قبلی را با push تغییر می‌دهیم و همان reference را نگه می‌داریم، تشخیص تغییر برای مصرف‌کننده سخت می‌شود. در مثال، افزودن با آرایهٔ تازه، حذف با filter و تغییر وضعیت با map انجام می‌شود. object کاری که عوض شده نیز تازه ساخته می‌شود. تابع set در حالت معمول بخش‌های سطح اول را merge می‌کند؛ این رفتار، objectهای تو در تو را خودکار بازسازی نمی‌کند. راهنمای تغییر immutable این مرز را مشخص کرده است.

برای تغییری که به مقدار فعلی وابسته است از شکل تابعی set استفاده می‌کنیم. این کار محاسبه را روی state دریافت‌شده انجام می‌دهد. اعتبارسنجی هم نزدیک action مفید است: عنوان خالی وارد فهرست نمی‌شود و فیلتر تنها یکی از مقدارهای تعریف‌شده است. همین کنترل کوچک مانع آن می‌شود که یک component جدید با فراخوانی مستقیم action، قرارداد داده را ناخواسته نقض کند. Store قرار نیست جای همهٔ قواعد دامنه باشد، ولی باید ورودی‌های خودش را بشناسد.

نمونهٔ کامل: کارهای یک جلسه

برای این نمونه Node 24 و npm لازم است. در پوشهٔ خالی، package.json و index.html را در ریشه بگذارید؛ سپس store.js، main.jsx و styles.css را داخل src ذخیره کنید. کد JSX در فایل اصلی React را وارد می‌کند و به plugin جداگانه‌ای برای این نمونه نیاز ندارد. npm install وابستگی‌ها و lock واقعی را می‌سازد؛ سپس npm run dev صفحه را روی نشانی اعلام‌شدهٔ Vite باز می‌کند. npm run build خروجی تولید می‌کند و npm run preview برای دیدن همان خروجی روی محیط محلی است.

{"name":"shared-state-demo","private":true,"version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},"dependencies":{"react":"^19.2.0","react-dom":"^19.2.0","zustand":"^5.0.0"},"devDependencies":{"vite":"^8.0.0"}}
<!doctype html><html lang="fa" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>فهرست کارها</title></head><body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>
import { create } from 'zustand';
export const useTasks = create((set) => ({
  tasks: [],
  filter: 'all',
  add: (title) => {
    const text = title.trim();
    if (!text) return;
    const task = { id: crypto.randomUUID(), title: text, done: false };
    set((state) => ({ tasks: [...state.tasks, task] }));
  },
  toggle: (id) => set((state) => ({
    tasks: state.tasks.map((task) => task.id === id ? { ...task, done: !task.done } : task),
  })),
  remove: (id) => set((state) => ({ tasks: state.tasks.filter((task) => task.id !== id) })),
  setFilter: (filter) => {
    if (['all', 'open', 'done'].includes(filter)) set({ filter });
  },
}));
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { useTasks } from './store.js';
import './styles.css';

function AddTask() {
  const [title, setTitle] = useState('');
  const add = useTasks((state) => state.add);
  return <form onSubmit={(event) => {
    event.preventDefault();
    if (!title.trim()) return;
    add(title);
    setTitle('');
  }}>
    <label htmlFor="title">کار تازه</label>
    <input id="title" required maxLength={160} value={title} onChange={(event) => setTitle(event.target.value)} />
    <button type="submit">افزودن</button>
  </form>;
}

function Summary() {
  const total = useTasks((state) => state.tasks.length);
  const done = useTasks((state) => state.tasks.filter((task) => task.done).length);
  return <p role="status">{done.toLocaleString('fa-IR')} کار انجام شده از {total.toLocaleString('fa-IR')} کار</p>;
}

function Tasks() {
  const tasks = useTasks((state) => state.tasks);
  const filter = useTasks((state) => state.filter);
  const toggle = useTasks((state) => state.toggle);
  const remove = useTasks((state) => state.remove);
  const shown = tasks.filter((task) => filter === 'all' || (filter === 'done' ? task.done : !task.done));
  if (!shown.length) return <p>کاری در این فیلتر نیست.</p>;
  return <ul>{shown.map((task) => <li key={task.id}>
    <label><input type="checkbox" checked={task.done} onChange={() => toggle(task.id)} /><span>{task.title}</span></label>
    <button type="button" onClick={() => remove(task.id)} aria-label={`حذف ${task.title}`}>حذف</button>
  </li>)}</ul>;
}

function Filters() {
  const filter = useTasks((state) => state.filter);
  const setFilter = useTasks((state) => state.setFilter);
  return <div className="filters" aria-label="فیلتر کارها">
    {[['all', 'همه'], ['open', 'باز'], ['done', 'انجام‌شده']].map(([value, label]) =>
      <button key={value} type="button" aria-pressed={filter === value} onClick={() => setFilter(value)}>{label}</button>)}
  </div>;
}

function App() {
  return <main><h1>کارهای این جلسه</h1><p>داده‌ها فقط در حافظه‌اند و با بارگذاری دوباره پاک می‌شوند.</p><AddTask /><Summary /><Filters /><Tasks /></main>;
}
createRoot(document.getElementById('root')).render(<App />);
:root { font-family: system-ui, sans-serif; color: #20323a; background: #edf3f4; }
body { margin: 0; padding: 2rem 1rem; }
main { max-width: 40rem; margin: auto; background: white; padding: 1.5rem; border-radius: 1rem; }
form { display: grid; gap: .7rem; }
input, button { font: inherit; }
input:not([type=checkbox]) { padding: .6rem; border: 1px solid #687d87; border-radius: .4rem; }
button { padding: .5rem .8rem; border: 1px solid #49636b; border-radius: .4rem; background: white; color: #20323a; cursor: pointer; }
button[aria-pressed=true], form button { background: #075c55; color: white; }
:focus-visible { outline: 3px solid #bb6500; outline-offset: 3px; }
.filters { display: flex; gap: .5rem; flex-wrap: wrap; }
ul { padding: 0; list-style: none; }
li { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding-block: .8rem; border-bottom: 1px solid #d2dde1; }
li label { display: flex; gap: .5rem; align-items: center; min-width: 0; }
li span { overflow-wrap: anywhere; }

نام‌های فایل در متن، نام مقصد هستند؛ فایل‌های نمونه را به همین نام‌ها کپی کنید. محدودهٔ نسخه‌های dependency مشخص است و فایل lock پس از نصب باید در پروژه نگه داشته شود. صفحه API و storage ندارد؛ با reload فهرست پاک می‌شود. ساخت شناسه از crypto.randomUUID روی محیط محلی امن مرورگر استفاده می‌کند. اگر برنامه را در محیط دیگری میزبانی می‌کنید، نیاز secure context را در نظر بگیرید. این کد برای توضیح مسیر داده ارائه شده و گزارشی از اجرای آزمایش یا تضمین رفتار همهٔ مرورگرها نیست.

در رابط، state مشترک را می‌بینیم

فرم پس از ثبت، عنوان را پاک می‌کند اما focus را با جابه‌جایی اجباری از input نمی‌گیرد. checkbox نام قابل خواندن دارد، دکمهٔ حذف نام کار را در برچسب دسترس‌پذیر بیان می‌کند و فیلتر با aria-pressed وضعیت انتخاب را نشان می‌دهد. پیام تعداد کارها در status قرار گرفته تا تغییر کوتاه آن قابل اعلام باشد. طراحی store این جزئیات را خودکار فراهم نمی‌کند؛ هر control هنوز باید معنای درست در HTML داشته باشد. نوشتهٔ دسترس‌پذیری از روز اول این بخش طراحی را ادامه می‌دهد.

محاسبهٔ فهرست نمایشی در component انجام شده، چون خروجی آن آرایهٔ تازه‌ای است. Selector شمارنده یک عدد برمی‌گرداند؛ حتی اگر آرایه تغییر کند و تعداد ثابت بماند، خروجی آن مقدار پایدار دارد. برای فهرست طولانی ممکن است معماری دیگری لازم شود، اما پیش از اضافه کردن memo، storeهای خرد یا ابزارهای پیچیده، رفتار واقعی صفحه را بررسی کنید. تعداد خط کمتر در کد، به تنهایی نشانهٔ کمتر شدن render یا بهتر شدن تجربه نیست.

Persist را با تصمیم دربارهٔ داده اضافه کنید

نگهداری state میان بارگذاری‌ها امکان جداگانه‌ای است. Middleware persist می‌تواند از storage استفاده کند، اما قبل از افزودنش تعیین کنید کدام فیلدها باید باقی بمانند. گزینهٔ partialize برای انتخاب فیلدها و version و migrate برای تغییر شکل دادهٔ ذخیره‌شده وجود دارند. تنظیم نمایش ممکن است قابل نگهداری باشد؛ اطلاعات حساس یا مجوز کاربر نباید صرفاً با ذخیرهٔ مرورگر معتبر فرض شود. توضیح گزینه‌ها در راهنمای رسمی persist آمده است.

خواندن storage غیرهم‌زمان می‌تواند پس از اولین نمایش کامل شود. بنابراین رابط باید بداند آیا اطلاعات ذخیره‌شده وارد state شده یا هنوز منتظر است. ممکن است کاربر پیش از پایان این فرایند چیزی تغییر دهد؛ سیاست ترکیب داده باید روشن باشد. در مثال فعلی عمداً persist نداریم تا این مسئولیت اضافی پنهان نشود. اگر فقط یک تنظیم را نگه می‌دارید، لازم نیست کل فهرست، وضعیت درخواست و actionها را با آن به storage بفرستید.

SSR به عمر store حساس است

نمونهٔ ما فقط در مرورگر اجرا می‌شود. در سروری که به چند کاربر پاسخ می‌دهد، state قابل تغییر در یک متغیر سراسری می‌تواند میان درخواست‌ها مشترک شود. ساخت store برای هر درخواست یا محدودهٔ مناسب، بخشی از طراحی SSR است. از طرف دیگر، اولین خروجی مرورگر و HTML سرور باید با قرارداد hydration سازگار باشند. راهنمای Next.js در Zustand دربارهٔ محدودهٔ store توضیح می‌دهد و راهنمای SSR و hydration اختلاف خروجی اولیه را بررسی می‌کند.

نباید store سمت کاربر را به عنوان منبع مشترک Server Component تصور کرد. دادهٔ سرور مسیر خودش را دارد و مقدار اولیهٔ لازم باید از مرز مشخص به بخش client برسد. مقالهٔ مهاجرت Next.js به App Router برای بررسی این مرز مفید است. اگر action شامل درخواست شبکه شد، خطا، درخواست قدیمی و لغو را هم طراحی کنید؛ متن Promise و async/await توضیح می‌دهد چرا پایان یافتن یک Promise با به‌روز بودن نتیجه یکی نیست.

Store را با قراردادهای کوچک نگه دارید

وقتی پروژه رشد می‌کند، نام actionها و محدودهٔ داده از انتخاب کتابخانه مهم‌تر می‌شوند. معلوم باشد logout چه چیزی را پاک می‌کند، خروج از صفحه چه state را نگه می‌دارد و تغییر حساب کاربر چه اطلاعاتی را نامعتبر می‌کند. نوشتن این قراردادها جلوی وابستگی پنهان میان componentها را می‌گیرد. Zustand می‌تواند انتقال و مصرف state مشترک را مختصر کند؛ کیفیت نتیجه به همین تصمیم‌های روشن وابسته است. برای صفحه‌ای که فقط یک input و یک دکمه دارد، state محلی همچنان انتخاب کاملی است.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!