طراحی خود تابع Reducer

در دو درس قبلی، با مفهوم کلی Reducer و طراحی درست Actionها آشنا شدیم. در این درس، تمرکز را روی خود تابع Reducer می‌گذاریم: چگونه ساختار State را طراحی کنیم، چگونه به Actionهای مختلف پاسخ دهیم، و چه اصولی باعث می‌شود یک Reducer قابل‌اعتماد و قابل نگهداری باشد.

Reducer هم باید از Immutability پیروی کند

همان‌طور که در فصل سوم درباره Immutability دیدیم، هر بار که Reducer یک State جدید می‌سازد، باید یک Object یا Array جدید بازگرداند، نه اینکه State ورودی را مستقیماً تغییر دهد:

// نادرست: تغییر مستقیم state ورودی
function reducer(state, action) {
  switch (action.type) {
    case "renamed":
      state.name = action.name; // تغییر مستقیم
      return state;
  }
}

// درست: ساخت یک Object جدید
function reducer(state, action) {
  switch (action.type) {
    case "renamed":
      return { ...state, name: action.name };
  }
}

این قانون، دقیقاً همان دلیلی است که پیش‌تر برای State معمولی دیدیم: React برای تشخیص تغییر، به مقایسه ارجاع تکیه می‌کند، نه مقایسه عمیق محتوا.

پوشش دادن همه Actionهای ممکن

یک Reducer خوب باید تمام Actionهایی را که ممکن است dispatch شوند، به‌صورت صریح مدیریت کند و همیشه یک default برای Actionهای ناشناخته داشته باشد که معمولاً همان State فعلی را بدون تغییر بازمی‌گرداند:

function reducer(state, action) {
  switch (action.type) {
    case "incremented":
      return { count: state.count + 1 };
    case "decremented":
      return { count: state.count - 1 };
    default:
      return state;
  }
}

وجود همیشگی default، از بروز خطا در مواقعی که یک Action ناشناخته یا اشتباه ارسال شود جلوگیری می‌کند و کد را در برابر تغییرات آینده مقاوم‌تر می‌کند.

ساختار State را تا حد امکان ساده نگه دارید

یک اشتباه رایج، طراحی یک ساختار State بیش‌ازحد تودرتو است. هرچه State عمیق‌تر و تودرتوتر باشد، به‌روزرسانی غیرمخرب (با Spread) آن هم پیچیده‌تر می‌شود:

// ساختار پیچیده‌تر برای به‌روزرسانی
{ user: { profile: { settings: { theme: "dark" } } } }

// ساختار ساده‌تر و مسطح‌تر
{ theme: "dark", userName: "سارا" }

وقتی امکانش وجود دارد، مسطح نگه‌داشتن ساختار State، به‌روزرسانی آن را در Reducer، خواناتر و کم‌خطاتر می‌کند.

جدا کردن منطق پیچیده در توابع کمکی

اگر منطق مربوط به یک Action خاص طولانی یا پیچیده شود، می‌توان آن را در یک تابع جداگانه نوشت و فقط از داخل switch فراخوانی کرد؛ این کار خوانایی Reducer اصلی را حفظ می‌کند:

function addTask(tasks, newTask) {
  return [...tasks, { id: Date.now(), text: newTask, done: false }];
}

function tasksReducer(tasks, action) {
  switch (action.type) {
    case "added":
      return addTask(tasks, action.text);
    default:
      return tasks;
  }
}

یک نکته درباره مقدار اولیه: initializer function

اگر محاسبه مقدار اولیه State پرهزینه باشد (مثلاً خواندن و پردازش داده‌ای بزرگ)، useReducer یک آرگومان سوم اختیاری هم می‌پذیرد: یک تابع Initializer که فقط یک‌بار، در همان اولین رندر، برای ساخت مقدار اولیه اجرا می‌شود؛ به‌جای این‌که این محاسبه در هر رندر تکرار شود.

function createInitialTasks(count) {
  return Array.from({ length: count }, (_, i) => ({ id: i, text: "" }));
}

const [tasks, dispatch] = useReducer(tasksReducer, 5, createInitialTasks);

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

یک Reducer با ساختار ساده و پوشش کامل Actionها
برای دیدن پیش‌نمایش کامپوننت، روی Run بزنید.

جمع‌بندی

یک Reducer خوب، State جدید را بدون تغییر مستقیم State قبلی می‌سازد، تمام Actionهای شناخته‌شده را پوشش می‌دهد و یک default امن دارد، ساختار State را تا حد امکان مسطح نگه می‌دارد، و در صورت لزوم، منطق پیچیده را به توابع کمکی جداگانه می‌سپارد. در درس بعدی، useReducer را مستقیماً با useState مقایسه می‌کنیم.