Redux Toolkit چیست؟ آموزش کامل مدیریت State در React با ساخت اپلیکیشن هوش مصنوعی

در این آموزش Redux Toolkit را از صفر یاد می‌گیرید؛ از Store، Slice، Action، Reducer، useSelector و useDispatch تا عملیات Async، RTK Query و TypeScript. در پایان یک اپلیکیشن هوش مصنوعی React با مدیریت مدل، تنظیمات و مکالمات می‌سازیم.

Share
Redux Toolkit چیست؟ آموزش کامل مدیریت State در React با ساخت اپلیکیشن هوش مصنوعی

با بزرگ شدن اپلیکیشن React، مدیریت State به یکی از مهم‌ترین مسائل معماری تبدیل می‌شود. در یک ابزار ساده شاید چند useState کافی باشد، اما یک داشبورد هوش مصنوعی واقعی معمولاً باید مدل انتخاب‌شده، تنظیمات تولید، پیام‌ها، وضعیت کاربر، Theme، فیلترها، تاریخچه مکالمه و وضعیت درخواست‌های API را میان چندین Component به اشتراک بگذارد.

اگر این اطلاعات بدون ساختار مشخص میان Componentها منتقل شوند، با مشکلاتی مانند Prop Drilling، State تکراری، داده ناسازگار، درخواست‌های شبکه تکراری و منطق پراکنده روبه‌رو می‌شویم.

Redux Toolkit ابزار استاندارد اکوسیستم Redux برای مدیریت این نوع State است. این کتابخانه تنظیم Store، ساخت Reducer، تولید Action، مدیریت عملیات Async و دریافت داده از API را نسبت به Redux قدیمی بسیار ساده‌تر می‌کند.

اما Redux Toolkit چیست؟ چه تفاوتی با Redux، Context API و useState دارد؟ Store، Action، Reducer و Slice چه هستند؟ چگونه از createAsyncThunk یا RTK Query استفاده کنیم؟ چه داده‌ای باید داخل Redux قرار بگیرد و چه داده‌ای نباید در Store ذخیره شود؟

در این راهنما، Redux Toolkit را از مفاهیم پایه تا ساخت یک اپلیکیشن هوش مصنوعی واقعی با React بررسی می‌کنیم.

Redux Toolkit چیست؟

Redux Toolkit که معمولاً با نام کوتاه RTK شناخته می‌شود، مجموعه ابزار رسمی و استاندارد برای نوشتن منطق Redux است.

Redux Toolkit برای ساده کردن مشکلات رایج Redux قدیمی طراحی شده است:

  • تنظیم پیچیده Store
  • نیاز به Packageهای متعدد
  • Boilerplate زیاد
  • نوشتن دستی Action Type
  • نوشتن Action Creatorهای تکراری
  • پیاده‌سازی دشوار Updateهای Immutable
  • مدیریت پراکنده عملیات Async
  • دریافت و Cache کردن داده‌های API

براساس مستندات رسمی Redux Toolkit، این Package به‌عنوان روش استاندارد نوشتن منطق Redux طراحی شده و ابزارهایی مانند configureStore، createSlice، createAsyncThunk و RTK Query را ارائه می‌کند.

Redux چیست؟

Redux یک کتابخانه مدیریت State است که State مشترک برنامه را در یک Store مرکزی نگه می‌دارد.

چرخه ساده Redux:

رابط کاربری
    |
    | dispatch(action)
    v
Redux Store
    |
    | اجرای reducer
    v
State جدید
    |
    | selector
    v
به‌روزرسانی رابط کاربری

برای مثال کاربر مدل هوش مصنوعی جدیدی را انتخاب می‌کند:

کلیک روی مدل
    |
    v
dispatch(setSelectedModel(modelId))
    |
    v
Reducer اجرا می‌شود
    |
    v
selectedModelId تغییر می‌کند
    |
    v
Componentهای وابسته دوباره Render می‌شوند

تفاوت Redux و Redux Toolkit

Redux هسته اصلی مدیریت State و Redux Toolkit مجموعه ابزار پیشنهادی برای استفاده عملی از Redux است.

معیارRedux قدیمیRedux Toolkit
تنظیم Storeدستی‌ترساده با configureStore
تعریف Actionمعمولاً دستیخودکار با createSlice
Action Typeرشته‌های دستیتولید خودکار
Update غیرقابل‌تغییرنیازمند کپی دستیساده با Immer
Async Logicتنظیم دستی ThunkThunk به‌صورت پیش‌فرض
Data Fetchingنیازمند منطق جداگانهRTK Query
Boilerplateزیادکمتر
Redux DevToolsتنظیم جداگانهپشتیبانی پیش‌فرض
روش پیشنهادی امروزبرای یادگیری مفاهیمبرای پروژه‌های واقعی

اگر پروژه جدیدی می‌سازید، معمولاً Redux Toolkit انتخاب مناسب‌تری از نوشتن Redux به روش قدیمی است.

آیا Redux Toolkit جایگزین Redux است؟

Redux Toolkit از Redux استفاده می‌کند و جایگزین مفاهیم اصلی آن نیست. شما همچنان با این مفاهیم کار می‌کنید:

  • Store
  • State
  • Action
  • Reducer
  • Dispatch
  • Selector

Redux Toolkit فقط پیاده‌سازی آن‌ها را ساده‌تر و استانداردتر می‌کند.

Redux چه مشکلی را حل می‌کند؟

در React، داده معمولاً از Component والد به فرزند از طریق Props منتقل می‌شود:

<App
  selectedModel={selectedModel}
>
  <Dashboard
    selectedModel={selectedModel}
  >
    <Playground
      selectedModel={selectedModel}
    />
  </Dashboard>
</App>

اگر Componentهای زیادی به یک State نیاز داشته باشند، مجبور می‌شویم آن را از چند سطح عبور دهیم. به این وضعیت Prop Drilling گفته می‌شود.

Redux یک Store مرکزی ایجاد می‌کند:

Redux Store
├── user
├── settings
├── chat
├── models
└── ui

هر Component می‌تواند فقط بخش موردنیاز خود را بخواند:

const selectedModelId = useSelector(
  (state) => state.settings.selectedModelId
);

و در صورت نیاز Action ارسال کند:

dispatch(
  setSelectedModelId("YOUR_MODEL_ID")
);

آیا هر پروژه React به Redux نیاز دارد؟

خیر. Redux برای همه پروژه‌ها ضروری نیست.

اگر پروژه کوچک است و State بیشتر در یک یا دو Component استفاده می‌شود، useState یا useReducer ممکن است کافی باشد.

Redux زمانی مفیدتر است که:

  • State میان Componentهای دور از هم مشترک است.
  • چند بخش برنامه یک State را تغییر می‌دهند.
  • منطق تغییر State پیچیده است.
  • نیاز به Debug دقیق تغییرات دارید.
  • State باید ساختار قابل‌پیش‌بینی داشته باشد.
  • پروژه تیمی و در حال رشد است.
  • داده‌های API به Cache و همگام‌سازی نیاز دارند.
  • تاریخچه تغییر Actionها برای Debug مهم است.
  • می‌خواهید منطق State از رابط کاربری جدا باشد.

چه زمانی Redux انتخاب مناسبی نیست؟

در این سناریوها Redux ممکن است پیچیدگی غیرضروری ایجاد کند:

  • فرم ساده با چند Input
  • Modal محلی یک Component
  • وضعیت Hover
  • باز یا بسته بودن یک Menu کوچک
  • پروژه آموزشی بسیار کوچک
  • State فقط در یک Component استفاده می‌شود
  • تیم بدون نیاز واقعی فقط به‌دلیل محبوبیت Redux آن را انتخاب کرده است

استفاده از Redux باید یک تصمیم معماری باشد، نه یک الزام برای تمام پروژه‌های React.

مقایسه useState، Context API و Redux Toolkit

معیارuseStateContext APIRedux Toolkit
State محلیعالیمناسب نیستمعمولاً لازم نیست
State مشترک سادهمحدودمناسبمناسب
منطق پیچیدهدشوارترممکن است شلوغ شودمناسب
Debug تغییراتمحدودمحدودRedux DevTools
Middlewareنداردندارددارد
Data Fetching و CacheدستیدستیRTK Query
ساختار استانداردسادهوابسته به تیمقوی
Boilerplateکممتوسطمتوسط
مقیاس پروژه بزرگمحدودوابسته به طراحیمناسب

یک الگوی منطقی:

  • useState برای State محلی
  • Context برای مقدارهای مشترک کم‌تغییر مانند Theme
  • Redux Toolkit برای State مشترک پیچیده
  • RTK Query برای Server State و Cache داده‌های API

این مرزها قطعی نیستند و به معماری پروژه بستگی دارند.

مفاهیم اصلی Redux

پیش از نوشتن کد، باید چند مفهوم اصلی را بشناسید.

State چیست؟

State اطلاعات فعلی برنامه است:

{
  settings: {
    selectedModelId: "YOUR_MODEL_ID",
    temperature: 0.4,
    maxTokens: 1000
  },
  chat: {
    messages: [],
    status: "idle",
    error: null
  }
}

Redux State باید تا حد امکان قابل پیش‌بینی و Serializable باشد.

Store چیست؟

Store محل مرکزی نگهداری Redux State است:

const store = configureStore({
  reducer: {
    settings: settingsReducer,
    chat: chatReducer,
  },
});

Store علاوه بر State، متدهای Dispatch و Subscribe را نیز فراهم می‌کند.

در بیشتر برنامه‌های Client-side React فقط یک Redux Store اصلی داریم.

Action چیست؟

Action یک Object ساده است که رخ دادن یک رویداد را توضیح می‌دهد:

{
  type: "settings/modelChanged",
  payload: "YOUR_MODEL_ID"
}

در Redux Toolkit معمولاً Action Creator به‌صورت خودکار ساخته می‌شود:

setSelectedModelId(
  "YOUR_MODEL_ID"
);

خروجی این تابع یک Action است.

Reducer چیست؟

Reducer مشخص می‌کند State در پاسخ به یک Action چگونه تغییر کند.

function reducer(state, action) {
  if (
    action.type ===
    "settings/modelChanged"
  ) {
    return {
      ...state,
      selectedModelId:
        action.payload,
    };
  }

  return state;
}

در Redux Toolkit معمولاً Reducerها داخل createSlice تعریف می‌شوند.

Dispatch چیست؟

Dispatch برای ارسال Action به Store استفاده می‌شود:

dispatch(
  setSelectedModelId(
    "YOUR_MODEL_ID"
  )
);

پس از Dispatch:

  1. Action به Store ارسال می‌شود.
  2. Reducer مربوط اجرا می‌شود.
  3. State جدید تولید می‌شود.
  4. Componentهای وابسته به State به‌روزرسانی می‌شوند.

Selector چیست؟

Selector تابعی برای خواندن بخشی از State است:

const selectSelectedModelId =
  (state) =>
    state.settings.selectedModelId;

در Component:

const selectedModelId =
  useSelector(
    selectSelectedModelId
  );

Selector کمک می‌کند ساختار داخلی Store مستقیماً در تمام Componentها تکرار نشود.

Slice چیست؟

Slice بخشی از Redux State به‌همراه Reducerها و Actionهای مربوط به آن است.

برای مثال:

settingsSlice
├── initialState
├── reducers
├── actions
└── reducer

نمونه:

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

const settingsSlice =
  createSlice({
    name: "settings",

    initialState: {
      selectedModelId:
        "YOUR_MODEL_ID",
      temperature: 0.4,
    },

    reducers: {
      setSelectedModelId(
        state,
        action
      ) {
        state.selectedModelId =
          action.payload;
      },

      setTemperature(
        state,
        action
      ) {
        state.temperature =
          action.payload;
      },
    },
  });

Redux Toolkit به‌صورت خودکار Action Creatorها و Reducer را تولید می‌کند.

نصب Redux Toolkit در React

برای یک پروژه موجود:

npm install @reduxjs/toolkit react-redux

Packageها:

  • @reduxjs/toolkit: ابزارهای Redux Toolkit
  • react-redux: اتصال Redux Store به React

این دستور مطابق راهنمای Quick Start رسمی Redux Toolkit است.

ساخت پروژه با Vite

npm create vite@latest redux-ai-app -- --template react
cd redux-ai-app
npm install
npm install @reduxjs/toolkit react-redux
npm run dev

ساختار پیشنهادی:

src/
├── app/
│   └── store.js
├── features/
│   ├── settings/
│   │   ├── settingsSlice.js
│   │   └── ModelSettings.jsx
│   ├── chat/
│   │   ├── chatSlice.js
│   │   └── ChatPanel.jsx
│   └── models/
│       └── modelsApi.js
├── App.jsx
└── main.jsx

ساخت اولین Store

فایل src/app/store.js:

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

export const store =
  configureStore({
    reducer: {},
  });

configureStore بسیاری از تنظیمات استاندارد Redux را به‌صورت پیش‌فرض انجام می‌دهد:

  • ترکیب Reducerها
  • اضافه کردن Middlewareهای پیش‌فرض
  • پشتیبانی از Redux Thunk
  • بررسی بعضی اشتباهات متداول در محیط توسعه
  • اتصال به Redux DevTools

طبق مستندات Redux Toolkit، configureStore تنظیم Store را ساده می‌کند و پیش‌فرض‌های مناسبی در اختیار برنامه قرار می‌دهد.

اتصال Store به React با Provider

فایل src/main.jsx:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";

import { store } from "./app/store";
import App from "./App";
import "./index.css";

const rootElement =
  document.getElementById("root");

if (!rootElement) {
  throw new Error(
    "Root element was not found."
  );
}

createRoot(rootElement).render(
  <StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </StrictMode>
);

Provider، Store را از طریق React Context در دسترس Componentهای فرزند قرار می‌دهد.

اگر useSelector یا useDispatch خارج Provider اجرا شود، خطا دریافت خواهید کرد.

ساخت settingsSlice

فایل src/features/settings/settingsSlice.js:

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

const initialState = {
  selectedModelId:
    "YOUR_MODEL_ID",

  temperature: 0.4,

  maxTokens: 1000,

  systemPrompt:
    "شما یک دستیار فارسی دقیق و کاربردی هستید.",
};

const settingsSlice =
  createSlice({
    name: "settings",

    initialState,

    reducers: {
      setSelectedModelId(
        state,
        action
      ) {
        state.selectedModelId =
          action.payload;
      },

      setTemperature(
        state,
        action
      ) {
        const value =
          Number(action.payload);

        if (
          Number.isFinite(value) &&
          value >= 0 &&
          value <= 2
        ) {
          state.temperature =
            value;
        }
      },

      setMaxTokens(
        state,
        action
      ) {
        const value =
          Number(action.payload);

        if (
          Number.isInteger(value) &&
          value > 0
        ) {
          state.maxTokens =
            value;
        }
      },

      setSystemPrompt(
        state,
        action
      ) {
        state.systemPrompt =
          String(action.payload);
      },

      resetSettings() {
        return initialState;
      },
    },
  });

export const {
  setSelectedModelId,
  setTemperature,
  setMaxTokens,
  setSystemPrompt,
  resetSettings,
} = settingsSlice.actions;

export const selectSettings =
  (state) =>
    state.settings;

export const selectSelectedModelId =
  (state) =>
    state.settings
      .selectedModelId;

export default settingsSlice.reducer;

چرا Reducer شبیه Mutation نوشته شده است؟

در Reducer بالا این کد را داریم:

state.temperature = value;

در Redux معمولی نباید State را مستقیماً تغییر داد. Redux Toolkit از Immer استفاده می‌کند و این کد را روی Draft State اجرا می‌کند. Immer براساس تغییرات، State جدید Immutable تولید می‌کند.

یعنی این کد:

state.temperature = value;

از دید Redux Toolkit یک Update غیرقابل‌تغییر معتبر ایجاد می‌کند.

اما این قابلیت فقط در Reducerهای پشتیبانی‌شده Redux Toolkit اعمال می‌شود. خارج از Reducer نباید Objectهای State را مستقیم تغییر دهید.

createSlice و استفاده داخلی آن از Immer در مستندات Quick Start توضیح داده شده است.

اضافه کردن Slice به Store

فایل src/app/store.js:

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

import settingsReducer from
  "../features/settings/settingsSlice";

export const store =
  configureStore({
    reducer: {
      settings:
        settingsReducer,
    },
  });

ساختار State:

{
  settings: {
    selectedModelId:
      "YOUR_MODEL_ID",
    temperature: 0.4,
    maxTokens: 1000,
    systemPrompt: "..."
  }
}

نام settings در Store همان مسیری است که Selector استفاده می‌کند:

state.settings.temperature

خواندن State با useSelector

import {
  useSelector,
} from "react-redux";

import {
  selectSettings,
} from "./settingsSlice";

export default function ModelSettings() {
  const settings =
    useSelector(
      selectSettings
    );

  return (
    <pre>
      {JSON.stringify(
        settings,
        null,
        2
      )}
    </pre>
  );
}

برای کاهش Renderهای غیرضروری بهتر است فقط مقدار موردنیاز را انتخاب کنید:

const temperature =
  useSelector(
    (state) =>
      state.settings.temperature
  );

به‌جای انتخاب Object بزرگ:

const settings =
  useSelector(
    (state) =>
      state.settings
  );

اگر هر تغییر کوچک Object جدیدی ایجاد کند، Component انتخاب‌کننده آن Object ممکن است دوباره Render شود.

تغییر State با useDispatch

import {
  useDispatch,
  useSelector,
} from "react-redux";

import {
  selectSelectedModelId,
  setSelectedModelId,
} from "./settingsSlice";

export default function ModelSelector() {
  const dispatch =
    useDispatch();

  const selectedModelId =
    useSelector(
      selectSelectedModelId
    );

  return (
    <label>
      شناسه مدل

      <input
        type="text"
        value={selectedModelId}
        onChange={(event) => {
          dispatch(
            setSelectedModelId(
              event.target.value
            )
          );
        }}
      />
    </label>
  );
}

setSelectedModelId یک Action Creator است که توسط createSlice تولید شده است.

Payload در Action

وقتی این کد اجرا می‌شود:

dispatch(
  setSelectedModelId(
    "YOUR_MODEL_ID"
  )
);

Action تقریباً چنین ساختاری دارد:

{
  type:
    "settings/setSelectedModelId",

  payload:
    "YOUR_MODEL_ID"
}

مقدار ورودی Action Creator در action.payload قرار می‌گیرد.

ساخت Reducer با چند مقدار Payload

addMessage: {
  reducer(state, action) {
    state.messages.push(
      action.payload
    );
  },

  prepare(role, content) {
    return {
      payload: {
        id: crypto.randomUUID(),
        role,
        content,
        createdAt:
          new Date()
            .toISOString(),
      },
    };
  },
},

استفاده:

dispatch(
  addMessage(
    "user",
    "سلام"
  )
);

prepare به ساخت Payload استاندارد کمک می‌کند.

ساخت chatSlice

فایل src/features/chat/chatSlice.js:

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

const initialState = {
  activeConversationId: null,
  messages: [],
  status: "idle",
  error: null,
};

const chatSlice =
  createSlice({
    name: "chat",

    initialState,

    reducers: {
      startConversation(
        state,
        action
      ) {
        state.activeConversationId =
          action.payload;

        state.messages = [];
        state.error = null;
        state.status = "idle";
      },

      addMessage: {
        reducer(
          state,
          action
        ) {
          state.messages.push(
            action.payload
          );
        },

        prepare(role, content) {
          return {
            payload: {
              id:
                crypto.randomUUID(),

              role,

              content,

              createdAt:
                new Date()
                  .toISOString(),
            },
          };
        },
      },

      setChatStatus(
        state,
        action
      ) {
        state.status =
          action.payload;
      },

      setChatError(
        state,
        action
      ) {
        state.error =
          action.payload;
      },

      clearChat(state) {
        state.messages = [];
        state.error = null;
        state.status = "idle";
      },
    },
  });

export const {
  startConversation,
  addMessage,
  setChatStatus,
  setChatError,
  clearChat,
} = chatSlice.actions;

export const selectMessages =
  (state) =>
    state.chat.messages;

export const selectChatStatus =
  (state) =>
    state.chat.status;

export const selectChatError =
  (state) =>
    state.chat.error;

export default chatSlice.reducer;

اضافه کردن به Store:

import chatReducer from
  "../features/chat/chatSlice";

export const store =
  configureStore({
    reducer: {
      settings:
        settingsReducer,

      chat:
        chatReducer,
    },
  });

داده‌های مناسب Redux Store

Redux برای داده‌های مشترک و قابل‌پیش‌بینی مناسب است:

  • تنظیمات مدل
  • شناسه مدل انتخابی
  • وضعیت احراز هویت کنترل‌شده
  • اطلاعات عمومی کاربر
  • فیلترهای مشترک
  • وضعیت Sidebar
  • تنظیم Theme
  • Conversation انتخاب‌شده
  • پیام‌های مدیریت‌شده در سطح برنامه
  • Entityهای مشترک
  • Cache داده‌های API از طریق RTK Query

چه داده‌هایی را در Redux Store قرار ندهیم؟

از ذخیره این موارد در Store خودداری کنید مگر طراحی خاصی داشته باشید:

  • API Key
  • Password
  • Refresh Token ناامن
  • Function
  • Promise
  • DOM Element
  • AbortController
  • File Objectهای بزرگ بدون نیاز
  • Connectionهای شبکه
  • Error Object پیچیده
  • Instance کلاس
  • داده موقتی Hover
  • State محلی ساده Input
  • داده تکراری قابل محاسبه
  • Objectهای غیرSerializable

نمونه نامناسب:

{
  controller:
    new AbortController(),

  onComplete:
    () => {},

  error:
    new Error("Failed")
}

نمونه مناسب‌تر:

{
  requestId:
    "request-123",

  status:
    "failed",

  error: {
    code:
      "REQUEST_FAILED",

    message:
      "درخواست انجام نشد."
  }
}

چرا Serializable بودن مهم است؟

Redux DevTools، Middlewareها، Persist کردن State و Replay Actionها با داده‌های Serializable بهتر کار می‌کنند.

داده‌های ساده مناسب:

  • String
  • Number
  • Boolean
  • null
  • Array
  • Object ساده

برای تاریخ به‌جای Date Object می‌توانید ISO String ذخیره کنید:

{
  createdAt:
    new Date()
      .toISOString()
}

و هنگام نمایش:

const date =
  new Date(message.createdAt);

Async Logic در Redux

Reducer باید Sync باشد و Side Effect اجرا نکند.

این کد داخل Reducer اشتباه است:

reducers: {
  async sendMessage(
    state,
    action
  ) {
    const response =
      await fetch("/api/chat");

    state.answer =
      await response.json();
  },
}

عملیات شبکه باید خارج Reducer انجام شود.

روش‌های متداول:

  • Thunk دستی
  • createAsyncThunk
  • RTK Query
  • Middleware اختصاصی

برای دریافت و Cache کردن Server State، RTK Query اغلب انتخاب مناسب‌تری است. برای Workflowهای Async سفارشی، createAsyncThunk همچنان کاربردی است.

createAsyncThunk چیست؟

createAsyncThunk یک Action Async می‌سازد و براساس نتیجه Promise، Actionهای زیر را تولید می‌کند:

pending
fulfilled
rejected

براساس مستندات Redux Toolkit، این تابع یک Type Prefix و تابع Promise دریافت می‌کند و Actionهای Lifecycle را تولید می‌کند.

ساخت sendChatMessage با createAsyncThunk

فایل src/features/chat/chatThunks.js:

import {
  createAsyncThunk,
} from "@reduxjs/toolkit";

export const sendChatMessage =
  createAsyncThunk(
    "chat/sendMessage",

    async (
      {
        message,
      },

      {
        getState,
        rejectWithValue,
        signal,
      }
    ) => {
      const state =
        getState();

      const {
        selectedModelId,
        temperature,
        maxTokens,
        systemPrompt,
      } = state.settings;

      try {
        const response =
          await fetch(
            "/api/chat",
            {
              method: "POST",

              headers: {
                "Content-Type":
                  "application/json",
              },

              body:
                JSON.stringify({
                  message,
                  modelId:
                    selectedModelId,
                  temperature,
                  maxTokens,
                  systemPrompt,
                }),

              signal,
            }
          );

        const data =
          await response
            .json()
            .catch(() => null);

        if (!response.ok) {
          return rejectWithValue({
            code:
              data?.code ||
              "REQUEST_FAILED",

            message:
              data?.error ||
              "دریافت پاسخ با خطا روبه‌رو شد.",
          });
        }

        if (
          typeof data?.answer !==
          "string"
        ) {
          return rejectWithValue({
            code:
              "INVALID_RESPONSE",

            message:
              "ساختار پاسخ قابل پردازش نیست.",
          });
        }

        return {
          answer:
            data.answer,
        };
      } catch (error) {
        if (
          error.name ===
          "AbortError"
        ) {
          throw error;
        }

        return rejectWithValue({
          code:
            "NETWORK_ERROR",

          message:
            "ارتباط با سرور برقرار نشد.",
        });
      }
    }
  );

مدیریت Lifecycle در extraReducers

فایل chatSlice.js:

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

import {
  sendChatMessage,
} from "./chatThunks";

const initialState = {
  messages: [],
  status: "idle",
  error: null,
};

const chatSlice =
  createSlice({
    name: "chat",

    initialState,

    reducers: {
      addUserMessage: {
        reducer(
          state,
          action
        ) {
          state.messages.push(
            action.payload
          );
        },

        prepare(content) {
          return {
            payload: {
              id:
                crypto.randomUUID(),

              role:
                "user",

              content,

              createdAt:
                new Date()
                  .toISOString(),
            },
          };
        },
      },

      clearChat(state) {
        state.messages = [];
        state.status = "idle";
        state.error = null;
      },
    },

    extraReducers:
      (builder) => {
        builder
          .addCase(
            sendChatMessage.pending,
            (state) => {
              state.status =
                "loading";

              state.error =
                null;
            }
          )

          .addCase(
            sendChatMessage.fulfilled,
            (
              state,
              action
            ) => {
              state.status =
                "succeeded";

              state.messages.push({
                id:
                  crypto.randomUUID(),

                role:
                  "assistant",

                content:
                  action.payload
                    .answer,

                createdAt:
                  new Date()
                    .toISOString(),
              });
            }
          )

          .addCase(
            sendChatMessage.rejected,
            (
              state,
              action
            ) => {
              if (
                action.meta.aborted
              ) {
                state.status =
                  "idle";

                return;
              }

              state.status =
                "failed";

              state.error =
                action.payload || {
                  code:
                    "UNKNOWN_ERROR",

                  message:
                    action.error
                      .message ||
                    "خطای پیش‌بینی‌نشده‌ای رخ داد.",
                };
            }
          );
      },
  });

export const {
  addUserMessage,
  clearChat,
} = chatSlice.actions;

export default chatSlice.reducer;

ارسال Thunk در Component

import {
  useRef,
  useState,
} from "react";

import {
  useDispatch,
  useSelector,
} from "react-redux";

import {
  addUserMessage,
} from "./chatSlice";

import {
  sendChatMessage,
} from "./chatThunks";

export default function ChatForm() {
  const dispatch =
    useDispatch();

  const [message, setMessage] =
    useState("");

  const activePromiseRef =
    useRef(null);

  const status =
    useSelector(
      (state) =>
        state.chat.status
    );

  const error =
    useSelector(
      (state) =>
        state.chat.error
    );

  async function handleSubmit(
    event
  ) {
    event.preventDefault();

    const cleanMessage =
      message.trim();

    if (
      !cleanMessage ||
      status === "loading"
    ) {
      return;
    }

    dispatch(
      addUserMessage(
        cleanMessage
      )
    );

    setMessage("");

    const promise =
      dispatch(
        sendChatMessage({
          message:
            cleanMessage,
        })
      );

    activePromiseRef.current =
      promise;

    try {
      await promise.unwrap();
    } catch (requestError) {
      if (
        requestError?.name !==
        "AbortError"
      ) {
        console.error(
          "Chat request failed."
        );
      }
    } finally {
      activePromiseRef.current =
        null;
    }
  }

  function handleCancel() {
    activePromiseRef
      .current
      ?.abort();
  }

  return (
    <form
      onSubmit={handleSubmit}
    >
      <label htmlFor="message">
        پیام
      </label>

      <textarea
        id="message"
        value={message}
        onChange={(event) =>
          setMessage(
            event.target.value
          )
        }
        rows="7"
        disabled={
          status === "loading"
        }
      />

      <div>
        {status ===
          "loading" && (
          <button
            type="button"
            onClick={
              handleCancel
            }
          >
            توقف
          </button>
        )}

        <button
          type="submit"
          disabled={
            status ===
              "loading" ||
            !message.trim()
          }
        >
          ارسال
        </button>
      </div>

      {error && (
        <p role="alert">
          {error.message}
        </p>
      )}
    </form>
  );
}

Promise برگشتی از Dispatch یک Async Thunk متد abort دارد. آن را داخل Redux State ذخیره نکردیم؛ زیرا Promise و کنترل‌کننده Request داده Serializable مناسبی برای Store نیستند.

unwrap چیست؟

Dispatch کردن Async Thunk همیشه یک Promise مربوط به Action نهایی برمی‌گرداند. با unwrap می‌توان Payload موفق را دریافت یا خطای Rejectشده را Throw کرد:

try {
  const result =
    await dispatch(
      sendChatMessage({
        message: "سلام",
      })
    ).unwrap();

  console.log(result.answer);
} catch (error) {
  console.error(error);
}

این روش برای زمانی مفید است که Component باید بعد از موفقیت یا شکست، عملیات دیگری انجام دهد.

RTK Query چیست؟

RTK Query ابزار Data Fetching و Cache موجود در Redux Toolkit است.

با RTK Query می‌توان:

  • Endpointها را تعریف کرد.
  • Hookهای React تولید کرد.
  • Loading و Error State دریافت کرد.
  • داده‌ها را Cache کرد.
  • Requestهای تکراری را کاهش داد.
  • داده‌ها را Refetch کرد.
  • Cache را براساس Tag باطل کرد.
  • Polling انجام داد.
  • داده را Transform کرد.
  • Mutationهای API ساخت.

RTK Query داخل Package @reduxjs/toolkit قرار دارد و Package جداگانه‌ای نیاز ندارد.

طبق مستندات RTK Query، این ابزار برای ساده‌سازی Data Fetching و Cache در برنامه‌های وب طراحی شده است.

تفاوت createAsyncThunk و RTK Query

معیارcreateAsyncThunkRTK Query
Async Workflow سفارشیعالیمحدودتر
دریافت داده APIدستیآماده
Cacheدستیداخلی
Deduplication درخواستدستیداخلی
Loading Stateبا SliceHook آماده
Error Stateبا SliceHook آماده
Refetchدستیداخلی
Invalidating CacheدستیTag
منطق چندمرحله‌ایمناسبهمیشه مناسب نیست
CRUD استانداردممکنبسیار مناسب

راهنمای عمومی:

  • برای Server State و CRUD از RTK Query استفاده کنید.
  • برای Workflowهای Async پیچیده و Actionمحور از Thunk استفاده کنید.
  • برای هر Request ساده یک Slice و Async Thunk جداگانه نسازید.
  • RTK Query و Async Thunk را بدون دلیل برای یک داده واحد ترکیب نکنید.

ساخت RTK Query API Slice

فایل src/features/api/appApi.js:

import {
  createApi,
  fetchBaseQuery,
} from "@reduxjs/toolkit/query/react";

export const appApi =
  createApi({
    reducerPath:
      "appApi",

    baseQuery:
      fetchBaseQuery({
        baseUrl: "/api",

        prepareHeaders(
          headers
        ) {
          headers.set(
            "Accept",
            "application/json"
          );

          return headers;
        },
      }),

    tagTypes: [
      "Models",
      "Conversations",
    ],

    endpoints:
      (builder) => ({
        getModels:
          builder.query({
            query:
              (category) => ({
                url: "/models",

                params:
                  category &&
                  category !== "all"
                    ? {
                        category,
                      }
                    : undefined,
              }),

            providesTags: [
              "Models",
            ],
          }),

        getConversation:
          builder.query({
            query:
              (conversationId) =>
                `/conversations/${conversationId}`,

            providesTags:
              (
                result,
                error,
                conversationId
              ) => [
                {
                  type:
                    "Conversations",

                  id:
                    conversationId,
                },
              ],
          }),

        sendChatMessage:
          builder.mutation({
            query:
              (requestBody) => ({
                url: "/chat",
                method: "POST",
                body: requestBody,
              }),
          }),

        deleteConversation:
          builder.mutation({
            query:
              (conversationId) => ({
                url:
                  `/conversations/${conversationId}`,

                method:
                  "DELETE",
              }),

            invalidatesTags:
              (
                result,
                error,
                conversationId
              ) => [
                {
                  type:
                    "Conversations",

                  id:
                    conversationId,
                },

                {
                  type:
                    "Conversations",

                  id:
                    "LIST",
                },
              ],
          }),
      }),
  });

export const {
  useGetModelsQuery,
  useGetConversationQuery,
  useSendChatMessageMutation,
  useDeleteConversationMutation,
} = appApi;

اضافه کردن RTK Query به Store

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

import {
  setupListeners,
} from "@reduxjs/toolkit/query";

import settingsReducer from
  "../features/settings/settingsSlice";

import chatReducer from
  "../features/chat/chatSlice";

import {
  appApi,
} from "../features/api/appApi";

export const store =
  configureStore({
    reducer: {
      settings:
        settingsReducer,

      chat:
        chatReducer,

      [appApi.reducerPath]:
        appApi.reducer,
    },

    middleware:
      (getDefaultMiddleware) =>
        getDefaultMiddleware()
          .concat(
            appApi.middleware
          ),
  });

setupListeners(
  store.dispatch
);

اضافه کردن appApi.reducer برای نگهداری Cache و appApi.middleware برای مدیریت Requestها لازم است.

دریافت مدل‌ها با RTK Query

import {
  useSelector,
} from "react-redux";

import {
  useGetModelsQuery,
} from "../api/appApi";

export default function ModelsList() {
  const category =
    useSelector(
      (state) =>
        state.settings
          .modelCategory
    );

  const {
    data: models = [],
    isLoading,
    isFetching,
    isError,
    error,
    refetch,
  } = useGetModelsQuery(
    category
  );

  if (isLoading) {
    return (
      <p>
        در حال دریافت مدل‌ها...
      </p>
    );
  }

  if (isError) {
    return (
      <section>
        <p role="alert">
          دریافت مدل‌ها
          انجام نشد.
        </p>

        <button
          type="button"
          onClick={refetch}
        >
          تلاش دوباره
        </button>
      </section>
    );
  }

  return (
    <section>
      {isFetching && (
        <p>
          در حال به‌روزرسانی...
        </p>
      )}

      <ul>
        {models.map(
          (model) => (
            <li key={model.id}>
              {model.name}
            </li>
          )
        )}
      </ul>
    </section>
  );
}

تفاوت isLoading و isFetching

در RTK Query:

  • isLoading: معمولاً برای اولین بار که داده‌ای وجود ندارد.
  • isFetching: Request در حال انجام است، حتی اگر داده Cacheشده قبلی موجود باشد.

می‌توانید هنگام Refetch داده قبلی را نگه دارید و فقط نشانگر کوچک به‌روزرسانی نمایش دهید.

ارسال پیام با RTK Query Mutation

import {
  useState,
} from "react";

import {
  useDispatch,
  useSelector,
} from "react-redux";

import {
  useSendChatMessageMutation,
} from "../api/appApi";

import {
  addMessage,
} from "../chat/chatSlice";

export default function ChatForm() {
  const dispatch =
    useDispatch();

  const [message, setMessage] =
    useState("");

  const settings =
    useSelector(
      (state) =>
        state.settings
    );

  const [
    sendChatMessage,
    {
      isLoading,
      error,
    },
  ] =
    useSendChatMessageMutation();

  async function handleSubmit(
    event
  ) {
    event.preventDefault();

    const cleanMessage =
      message.trim();

    if (
      !cleanMessage ||
      isLoading
    ) {
      return;
    }

    dispatch(
      addMessage(
        "user",
        cleanMessage
      )
    );

    setMessage("");

    try {
      const result =
        await sendChatMessage({
          message:
            cleanMessage,

          modelId:
            settings
              .selectedModelId,

          temperature:
            settings.temperature,

          maxTokens:
            settings.maxTokens,

          systemPrompt:
            settings.systemPrompt,
        }).unwrap();

      dispatch(
        addMessage(
          "assistant",
          result.answer
        )
      );
    } catch (requestError) {
      console.error(
        "Chat request failed."
      );
    }
  }

  return (
    <form
      onSubmit={handleSubmit}
    >
      <textarea
        value={message}
        onChange={(event) =>
          setMessage(
            event.target.value
          )
        }
        disabled={isLoading}
      />

      <button
        type="submit"
        disabled={
          isLoading ||
          !message.trim()
        }
      >
        {isLoading
          ? "در حال دریافت..."
          : "ارسال"}
      </button>

      {error && (
        <p role="alert">
          درخواست با خطا
          روبه‌رو شد.
        </p>
      )}
    </form>
  );
}

API Key در Redux Store قرار ندهید

این State ناامن است:

const initialState = {
  apiKey:
    "YOUR_DARVAREH_API_KEY",
};

Redux Store سمت Client است و کاربر می‌تواند State و Actionهای آن را با Developer Tools مشاهده کند.

این روش نیز امن نیست:

const apiKey =
  import.meta.env
    .VITE_DARVAREH_API_KEY;

API Key درواره باید فقط در Backend ذخیره شود:

DARVAREH_API_KEY=YOUR_DARVAREH_API_KEY
DARVAREH_MODEL_ID=YOUR_MODEL_ID

معماری مناسب:

React + Redux Toolkit
        |
        | /api/chat
        v
Backend برنامه شما
        |
        | API Key خصوصی
        v
API هوش مصنوعی درواره

ساخت Backend امن برای پروژه

در ریشه پروژه:

mkdir server
cd server
npm init -y
npm install express dotenv

فایل server/package.json:

{
  "name": "redux-ai-backend",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "node --watch server.js",
    "start": "node server.js"
  }
}

فایل server/.env:

DARVAREH_API_KEY=YOUR_DARVAREH_API_KEY
DARVAREH_MODEL_ID=YOUR_MODEL_ID
PORT=3000

فایل .env را وارد Git نکنید:

server/.env
node_modules
dist

برای شناسه مدل و قیمت به‌روز، صفحه مدل‌های درواره را بررسی کنید.

پیاده‌سازی Endpoint Chat

فایل server/server.js:

import "dotenv/config";
import express from "express";

const app = express();

const port =
  Number(
    process.env.PORT ||
    3000
  );

app.disable(
  "x-powered-by"
);

app.use(
  express.json({
    limit: "32kb",
  })
);

app.post(
  "/api/chat",

  async (
    request,
    response
  ) => {
    const message =
      typeof request.body
        ?.message ===
      "string"
        ? request.body
            .message
            .trim()
        : "";

    const requestedModelId =
      typeof request.body
        ?.modelId ===
      "string"
        ? request.body
            .modelId
            .trim()
        : "";

    if (!message) {
      return response
        .status(400)
        .json({
          error:
            "متن پیام الزامی است.",
        });
    }

    if (
      message.length >
      4000
    ) {
      return response
        .status(400)
        .json({
          error:
            "طول پیام بیشتر از مقدار مجاز است.",
        });
    }

    if (
      !process.env
        .DARVAREH_API_KEY
    ) {
      return response
        .status(500)
        .json({
          error:
            "تنظیمات سرویس کامل نیست.",
        });
    }

    const modelId =
      requestedModelId ||
      process.env
        .DARVAREH_MODEL_ID;

    if (!modelId) {
      return response
        .status(400)
        .json({
          error:
            "مدل انتخاب نشده است.",
        });
    }

    const temperature =
      Number(
        request.body
          ?.temperature
      );

    const safeTemperature =
      Number.isFinite(
        temperature
      ) &&
      temperature >= 0 &&
      temperature <= 2
        ? temperature
        : 0.4;

    const maxTokens =
      Number(
        request.body
          ?.maxTokens
      );

    const safeMaxTokens =
      Number.isInteger(
        maxTokens
      ) &&
      maxTokens > 0 &&
      maxTokens <= 8000
        ? maxTokens
        : 1000;

    const systemPrompt =
      typeof request.body
        ?.systemPrompt ===
      "string"
        ? request.body
            .systemPrompt
            .trim()
            .slice(0, 2000)
        : "شما یک دستیار فارسی دقیق و کاربردی هستید.";

    try {
      const upstreamResponse =
        await fetch(
          "https://api.darvareh.ir/v1/chat/completions",

          {
            method:
              "POST",

            headers: {
              Authorization:
                `Bearer ${process.env.DARVAREH_API_KEY}`,

              "Content-Type":
                "application/json",
            },

            body:
              JSON.stringify({
                model:
                  modelId,

                messages: [
                  {
                    role:
                      "system",

                    content:
                      systemPrompt,
                  },

                  {
                    role:
                      "user",

                    content:
                      message,
                  },
                ],

                temperature:
                  safeTemperature,

                max_tokens:
                  safeMaxTokens,
              }),
          }
        );

      const data =
        await upstreamResponse
          .json()
          .catch(() => null);

      if (
        !upstreamResponse.ok
      ) {
        console.error(
          "Darvareh API error:",
          {
            status:
              upstreamResponse
                .status,
          }
        );

        return response
          .status(502)
          .json({
            error:
              "سرویس هوش مصنوعی پاسخ معتبری برنگرداند.",
          });
      }

      const answer =
        data
          ?.choices?.[0]
          ?.message
          ?.content;

      if (
        typeof answer !==
          "string" ||
        !answer.trim()
      ) {
        return response
          .status(502)
          .json({
            error:
              "ساختار پاسخ قابل پردازش نبود.",
          });
      }

      return response.json({
        answer:
          answer.trim(),
      });
    } catch (error) {
      console.error(
        "Unexpected error:",
        error
      );

      return response
        .status(500)
        .json({
          error:
            "ارتباط با سرویس برقرار نشد.",
        });
    }
  }
);

app.listen(
  port,
  () => {
    console.log(
      `Backend is running on port ${port}`
    );
  }
);

Backend باید علاوه بر اعتبارسنجی ورودی، دسترسی کاربر به مدل، محدودیت مصرف، موجودی و سیاست‌های محصول را نیز بررسی کند.

تنظیم Proxy در Vite

فایل vite.config.js:

import {
  defineConfig,
} from "vite";

import react from
  "@vitejs/plugin-react";

export default defineConfig({
  plugins: [
    react(),
  ],

  server: {
    proxy: {
      "/api": {
        target:
          "http://localhost:3000",

        changeOrigin:
          true,
      },
    },
  },
});

RTK Query اکنون Request را به /api/chat می‌فرستد و Vite آن را در محیط توسعه به Backend هدایت می‌کند.

ساخت Selectorهای قابل استفاده مجدد

به‌جای تکرار مسیر State:

const messages =
  useSelector(
    (state) =>
      state.chat.messages
  );

Selector بسازید:

export const selectMessages =
  (state) =>
    state.chat.messages;

استفاده:

const messages =
  useSelector(
    selectMessages
  );

مزایا:

  • Component ساختار Store را کمتر می‌شناسد.
  • Refactor ساده‌تر می‌شود.
  • منطق انتخاب داده قابل تست است.
  • Selectorهای ترکیبی قابل ساخت هستند.

Selector مشتق‌شده

به‌جای ذخیره تعداد پیام‌ها در State:

{
  messages: [],
  messageCount: 0
}

آن را محاسبه کنید:

export const selectMessageCount =
  (state) =>
    state.chat
      .messages.length;

ذخیره داده قابل محاسبه می‌تواند باعث ناسازگاری شود:

messages.length = 5
messageCount = 4

createSelector و Memoization

برای محاسبه پیچیده از createSelector استفاده کنید:

import {
  createSelector,
} from "@reduxjs/toolkit";

const selectMessages =
  (state) =>
    state.chat.messages;

const selectSearchQuery =
  (state) =>
    state.chat.searchQuery;

export const selectFilteredMessages =
  createSelector(
    [
      selectMessages,
      selectSearchQuery,
    ],

    (
      messages,
      searchQuery
    ) => {
      const query =
        searchQuery
          .trim()
          .toLowerCase();

      if (!query) {
        return messages;
      }

      return messages.filter(
        (message) =>
          message.content
            .toLowerCase()
            .includes(query)
      );
    }
  );

createSelector نتیجه را تا زمانی که Inputها تغییر نکرده‌اند Memoize می‌کند.

از Memoization برای محاسبات واقعاً مفید استفاده کنید. هر Selector ساده به createSelector نیاز ندارد.

Normalized State چیست؟

برای فهرست بزرگ Entityها، ذخیره Array تو‌در‌تو می‌تواند Update را دشوار کند.

ساختار Array:

{
  conversations: [
    {
      id: "1",
      title: "First"
    },
    {
      id: "2",
      title: "Second"
    }
  ]
}

ساختار Normalized:

{
  ids: [
    "1",
    "2"
  ],

  entities: {
    "1": {
      id: "1",
      title: "First"
    },

    "2": {
      id: "2",
      title: "Second"
    }
  }
}

Redux Toolkit ابزار createEntityAdapter را برای مدیریت این ساختار ارائه می‌کند.

استفاده از createEntityAdapter

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

const conversationsAdapter =
  createEntityAdapter({
    sortComparer:
      (a, b) =>
        b.updatedAt
          .localeCompare(
            a.updatedAt
          ),
  });

const conversationsSlice =
  createSlice({
    name:
      "conversations",

    initialState:
      conversationsAdapter
        .getInitialState({
          status:
            "idle",
        }),

    reducers: {
      conversationAdded:
        conversationsAdapter
          .addOne,

      conversationsReceived:
        conversationsAdapter
          .setAll,

      conversationUpdated:
        conversationsAdapter
          .updateOne,

      conversationRemoved:
        conversationsAdapter
          .removeOne,
    },
  });

Selectorهای آماده:

export const {
  selectAll:
    selectAllConversations,

  selectById:
    selectConversationById,

  selectIds:
    selectConversationIds,
} =
  conversationsAdapter
    .getSelectors(
      (state) =>
        state.conversations
    );

برای فهرست کوچک، Adapter ممکن است ضروری نباشد؛ اما در مدیریت Entityهای متعدد مفید است.

State را براساس Feature تقسیم کنید

ساختار مناسب:

features/
├── auth/
│   └── authSlice.js
├── chat/
│   ├── chatSlice.js
│   └── ChatPanel.jsx
├── models/
│   ├── modelsApi.js
│   └── ModelsList.jsx
├── settings/
│   ├── settingsSlice.js
│   └── SettingsPanel.jsx
└── ui/
    └── uiSlice.js

ساختار نامناسب:

redux/
├── actions/
├── reducers/
├── constants/
└── types/

ساختار قدیمی مبتنی بر نوع فایل ممکن است منطق یک Feature را در چند پوشه پراکنده کند. در پروژه‌های جدید معمولاً Colocation براساس Feature خواناتر است.

نام‌گذاری Actionها

نام Slice و Reducer نام Action را می‌سازند:

name: "settings"
setTemperature()

Action Type:

settings/setTemperature

از نام‌های توصیفی استفاده کنید:

modelSelected
temperatureChanged
conversationCreated
chatCleared

نام Action بهتر است رویداد رخ‌داده را توصیف کند.

Redux Middleware چیست؟

Middleware میان Dispatch Action و رسیدن آن به Reducer قرار می‌گیرد.

dispatch(action)
      |
      v
middleware
      |
      v
reducer

کاربردها:

  • عملیات Async
  • Logging
  • Analytics
  • مدیریت API
  • تبدیل Action
  • مشاهده و کنترل Workflow

Redux Toolkit Middlewareهای پیش‌فرض مفیدی دارد و RTK Query نیز Middleware اختصاصی خود را اضافه می‌کند.

ساخت Middleware ساده برای زمان‌سنجی

const timingMiddleware =
  (storeApi) =>
  (next) =>
  (action) => {
    const startedAt =
      performance.now();

    const result =
      next(action);

    const duration =
      performance.now() -
      startedAt;

    if (
      import.meta.env.DEV
    ) {
      console.log(
        "Redux action:",
        {
          type:
            action.type,

          duration,
        }
      );
    }

    return result;
  };

اضافه کردن به Store:

export const store =
  configureStore({
    reducer: {
      settings:
        settingsReducer,
    },

    middleware:
      (getDefaultMiddleware) =>
        getDefaultMiddleware()
          .concat(
            timingMiddleware
          ),
  });

اطلاعات حساس Payload را در Log چاپ نکنید.

Redux DevTools

Redux Toolkit به‌صورت پیش‌فرض امکان اتصال به Redux DevTools را در محیط توسعه فراهم می‌کند.

با DevTools می‌توانید:

  • State فعلی را ببینید.
  • Actionهای Dispatchشده را بررسی کنید.
  • Payload هر Action را ببینید.
  • تغییر State را مقایسه کنید.
  • ترتیب Actionها را بررسی کنید.
  • بعضی Timelineها را Replay کنید.
  • Cache و Requestهای RTK Query را ببینید.

از قرار دادن اطلاعات حساس در Store خودداری کنید؛ زیرا ابزارهای توسعه می‌توانند آن‌ها را نمایش دهند.

Persist کردن بخشی از State

ممکن است بخواهید تنظیمات غیرحساس مانند Theme یا مدل انتخابی را در localStorage ذخیره کنید.

خواندن State اولیه:

function loadSettings() {
  try {
    const rawValue =
      localStorage.getItem(
        "ai-settings"
      );

    if (!rawValue) {
      return undefined;
    }

    const parsed =
      JSON.parse(rawValue);

    return {
      selectedModelId:
        typeof parsed
          .selectedModelId ===
        "string"
          ? parsed
              .selectedModelId
          : "YOUR_MODEL_ID",

      temperature:
        Number.isFinite(
          parsed.temperature
        )
          ? parsed.temperature
          : 0.4,

      maxTokens:
        Number.isInteger(
          parsed.maxTokens
        )
          ? parsed.maxTokens
          : 1000,

      systemPrompt:
        typeof parsed
          .systemPrompt ===
        "string"
          ? parsed
              .systemPrompt
          : "",
    };
  } catch {
    return undefined;
  }
}

در Store:

const savedSettings =
  loadSettings();

export const store =
  configureStore({
    reducer: {
      settings:
        settingsReducer,
    },

    preloadedState:
      savedSettings
        ? {
            settings:
              savedSettings,
          }
        : undefined,
  });

ذخیره تغییرات:

store.subscribe(
  () => {
    const state =
      store.getState();

    localStorage.setItem(
      "ai-settings",

      JSON.stringify({
        selectedModelId:
          state.settings
            .selectedModelId,

        temperature:
          state.settings
            .temperature,

        maxTokens:
          state.settings
            .maxTokens,

        systemPrompt:
          state.settings
            .systemPrompt,
      })
    );
  }
);

در پروژه واقعی بهتر است نوشتن در Storage را Debounce کنید.

در localStorage ذخیره نکنید:

  • API Key
  • Password
  • Secret
  • اطلاعات محرمانه
  • Token حساس بلندمدت
  • پیام‌های خصوصی بدون سیاست مشخص

Redux Toolkit با TypeScript

Redux Toolkit پشتیبانی مناسبی از TypeScript دارد.

فایل src/app/store.ts:

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

import settingsReducer from
  "../features/settings/settingsSlice";

export const store =
  configureStore({
    reducer: {
      settings:
        settingsReducer,
    },
  });

export type RootState =
  ReturnType<
    typeof store.getState
  >;

export type AppDispatch =
  typeof store.dispatch;

ساخت Hookهای Typed

فایل src/app/hooks.ts:

import {
  useDispatch,
  useSelector,
} from "react-redux";

import type {
  TypedUseSelectorHook,
} from "react-redux";

import type {
  AppDispatch,
  RootState,
} from "./store";

export const useAppDispatch =
  useDispatch.withTypes<
    AppDispatch
  >();

export const useAppSelector =
  useSelector.withTypes<
    RootState
  >();

اگر نسخه React Redux شما از withTypes پشتیبانی نکند، می‌توانید از الگوی Typed قدیمی‌تر استفاده کنید:

export const useAppDispatch =
  () =>
    useDispatch<
      AppDispatch
    >();

export const useAppSelector:
  TypedUseSelectorHook<
    RootState
  > =
  useSelector;

نسخه Package و مستندات همان نسخه را بررسی کنید.

Slice با TypeScript

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

import type {
  PayloadAction,
} from "@reduxjs/toolkit";

interface SettingsState {
  selectedModelId: string;
  temperature: number;
  maxTokens: number;
}

const initialState:
  SettingsState = {
    selectedModelId:
      "YOUR_MODEL_ID",

    temperature:
      0.4,

    maxTokens:
      1000,
  };

const settingsSlice =
  createSlice({
    name:
      "settings",

    initialState,

    reducers: {
      setSelectedModelId(
        state,

        action:
          PayloadAction<string>
      ) {
        state.selectedModelId =
          action.payload;
      },

      setTemperature(
        state,

        action:
          PayloadAction<number>
      ) {
        state.temperature =
          action.payload;
      },
    },
  });

Async Thunk با TypeScript

import {
  createAsyncThunk,
} from "@reduxjs/toolkit";

interface SendMessageInput {
  message: string;
}

interface SendMessageResult {
  answer: string;
}

interface ApiError {
  code: string;
  message: string;
}

export const sendMessage =
  createAsyncThunk<
    SendMessageResult,
    SendMessageInput,
    {
      rejectValue:
        ApiError;
    }
  >(
    "chat/sendMessage",

    async (
      input,
      {
        rejectWithValue,
      }
    ) => {
      const response =
        await fetch(
          "/api/chat",
          {
            method:
              "POST",

            headers: {
              "Content-Type":
                "application/json",
            },

            body:
              JSON.stringify(
                input
              ),
          }
        );

      const data =
        await response
          .json()
          .catch(() => null);

      if (!response.ok) {
        return rejectWithValue({
          code:
            data?.code ||
            "REQUEST_FAILED",

          message:
            data?.error ||
            "درخواست انجام نشد.",
        });
      }

      return {
        answer:
          data.answer,
      };
    }
  );

بهینه‌سازی Render با useSelector

این Selector در هر Dispatch یک Object جدید می‌سازد:

const settings =
  useSelector(
    (state) => ({
      modelId:
        state.settings
          .selectedModelId,

      temperature:
        state.settings
          .temperature,
    })
  );

حتی اگر مقادیر تغییر نکرده باشند، Object جدید می‌تواند باعث Render شود.

راهکار اول، Selectorهای جدا:

const modelId =
  useSelector(
    (state) =>
      state.settings
        .selectedModelId
  );

const temperature =
  useSelector(
    (state) =>
      state.settings
        .temperature
  );

راهکار دوم، Selector Memoized:

const selectModelSettings =
  createSelector(
    [
      (state) =>
        state.settings
          .selectedModelId,

      (state) =>
        state.settings
          .temperature,
    ],

    (
      modelId,
      temperature
    ) => ({
      modelId,
      temperature,
    })
  );

State را بیش از حد بزرگ نکنید

Store مرکزی نباید به محل ذخیره همه چیز تبدیل شود.

State محلی Input:

const [
  message,
  setMessage,
] = useState("");

معمولاً بهتر از Dispatch کردن Action برای هر کاراکتر است:

dispatch(
  messageInputChanged(
    event.target.value
  )
);

مگر اینکه متن Input واقعاً باید میان چند صفحه مشترک، Persist یا از بیرون کنترل شود.

تست Reducer

Reducer یک تابع قابل تست است:

import settingsReducer, {
  setTemperature,
} from "./settingsSlice";

test(
  "temperature changes",
  () => {
    const initialState = {
      selectedModelId:
        "model-a",

      temperature:
        0.4,

      maxTokens:
        1000,

      systemPrompt:
        "",
    };

    const nextState =
      settingsReducer(
        initialState,

        setTemperature(
          0.8
        )
      );

    expect(
      nextState.temperature
    ).toBe(0.8);

    expect(
      initialState.temperature
    ).toBe(0.4);
  }
);

تست بررسی می‌کند:

  • State جدید درست است.
  • State اولیه تغییر نکرده است.

تست Selector

import {
  selectSelectedModelId,
} from "./settingsSlice";

test(
  "selects model id",
  () => {
    const state = {
      settings: {
        selectedModelId:
          "model-a",
      },
    };

    expect(
      selectSelectedModelId(
        state
      )
    ).toBe("model-a");
  }
);

تست Component متصل به Redux

یک Store آزمایشی بسازید:

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

import {
  Provider,
} from "react-redux";

function renderWithStore(
  ui,
  {
    preloadedState,
  } = {}
) {
  const store =
    configureStore({
      reducer: {
        settings:
          settingsReducer,
      },

      preloadedState,
    });

  return {
    store,

    ...render(
      <Provider store={store}>
        {ui}
      </Provider>
    ),
  };
}

استفاده:

renderWithStore(
  <ModelSettings />,

  {
    preloadedState: {
      settings: {
        selectedModelId:
          "model-test",

        temperature:
          0.4,

        maxTokens:
          1000,

        systemPrompt:
          "",
      },
    },
  }
);

اشتباهات رایج Redux Toolkit

فراموش کردن Provider

خطای متداول:

could not find react-redux context value

راه‌حل:

<Provider store={store}>
  <App />
</Provider>

ثبت نکردن Reducer در Store

Slice ساخته شده اما Reducer اضافه نشده است:

reducer: {
  settings:
    settingsReducer,
}

تغییر مستقیم State خارج Reducer

اشتباه:

const settings =
  store.getState()
    .settings;

settings.temperature =
  0.9;

درست:

store.dispatch(
  setTemperature(
    0.9
  )
);

اجرای Request داخل Reducer

Reducer باید Pure و Sync باقی بماند. Request را با Thunk یا RTK Query اجرا کنید.

ذخیره داده غیرSerializable

Function، Promise، DOM Node، AbortController و Error پیچیده را داخل Store قرار ندهید.

ذخیره API Key در Store

Redux Store در Client قابل مشاهده است. Secret باید در Backend باشد.

استفاده از Redux برای همه Stateها

State محلی ساده را در Component نگه دارید.

انتخاب Object بزرگ با useSelector

فقط مقدارهای موردنیاز Component را انتخاب کنید.

ذخیره State مشتق‌شده

داده قابل محاسبه را با Selector بسازید، نه اینکه نسخه تکراری آن را ذخیره کنید.

درخواست API تکراری

برای Server State از RTK Query استفاده کنید تا Cache و Deduplication بهتر مدیریت شوند.

ساخت چند API Slice برای یک Base URL

راهنمای عمومی RTK Query این است که معمولاً برای هر Base URL یک API Slice داشته باشید و Endpointها را در همان ساختار توسعه دهید.

تغییر Middleware پیش‌فرض به‌جای گسترش آن

اشتباه:

middleware: [
  appApi.middleware,
]

ممکن است Middlewareهای پیش‌فرض را حذف کند.

مناسب‌تر:

middleware:
  (getDefaultMiddleware) =>
    getDefaultMiddleware()
      .concat(
        appApi.middleware
      )

خطاهای رایج و راه‌حل‌ها

Cannot read properties of undefined

احتمالاً نام Reducer در Store با Selector یکسان نیست:

reducer: {
  settings:
    settingsReducer,
}

Selector باید بخواند:

state.settings

نه:

state.setting

Actions must be plain objects

ممکن است Store با روش نامناسب ساخته شده یا Middleware Async حذف شده باشد. از configureStore استفاده کنید.

A non-serializable value was detected

در Action یا State مقدار غیرSerializable قرار گرفته است.

خروجی Console معمولاً مسیر مشکل را نشان می‌دهد. به‌جای غیرفعال کردن Check، داده را به ساختار ساده تبدیل کنید.

RTK Query Request اجرا نمی‌شود

بررسی کنید:

  • appApi.reducer به Store اضافه شده است.
  • appApi.middleware اضافه شده است.
  • Hook داخل Provider اجرا می‌شود.
  • Endpoint و Base URL درست‌اند.
  • Backend فعال است.
  • Proxy محیط توسعه تنظیم شده است.

Hook داده قبلی را نمایش می‌دهد

RTK Query داده را Cache می‌کند. Argument Query، Tagها و سیاست Refetch را بررسی کنید.

createAsyncThunk خطا را درست نشان نمی‌دهد

برای خطای کنترل‌شده از rejectWithValue استفاده کنید و در Reducer مقدار action.payload را بررسی کنید.

Dispatch نتیجه واقعی را Throw نمی‌کند

از unwrap استفاده کنید:

await dispatch(
  sendMessage(input)
).unwrap();

Redux Toolkit در Next.js

استفاده از Redux در فریم‌ورک‌های Server Rendering نیازمند توجه بیشتری است:

  • Store نباید ناخواسته میان Requestهای کاربران مشترک شود.
  • Hydration باید درست انجام شود.
  • Server State و Client State باید تفکیک شوند.
  • همه داده‌ها نباید وارد Store شوند.
  • Componentهای Server و Client نقش متفاوت دارند.

برای پروژه Next.js از راهنمای رسمی Redux Toolkit مربوط به Next.js و نسخه فریم‌ورک خود استفاده کنید. Config یک SPA را مستقیماً در Server Rendering کپی نکنید.

Redux Toolkit در پروژه بزرگ

برای پروژه بزرگ:

  • State را براساس Feature تقسیم کنید.
  • Selectorهای عمومی بسازید.
  • Server State را با RTK Query مدیریت کنید.
  • Entityهای بزرگ را Normalize کنید.
  • Actionها را براساس رویداد نام‌گذاری کنید.
  • API Sliceها را بی‌دلیل تکثیر نکنید.
  • State غیرSerializable ذخیره نکنید.
  • تست Reducer و Selector بنویسید.
  • Performance را با اندازه‌گیری بهینه کنید.
  • از Redux DevTools برای Debug استفاده کنید.
  • API Key و Secret را خارج Store نگه دارید.

چک‌لیست Production

پیش از انتشار بررسی کنید:

  • Store با configureStore ساخته شده است.
  • React داخل Provider قرار دارد.
  • Sliceها براساس Feature سازمان‌دهی شده‌اند.
  • Reducerها در Store ثبت شده‌اند.
  • Selectorهای قابل استفاده مجدد وجود دارند.
  • State محلی بی‌دلیل وارد Redux نشده است.
  • داده مشتق‌شده با Selector محاسبه می‌شود.
  • Function، Promise و DOM Node داخل Store نیستند.
  • API Key در Store یا Frontend وجود ندارد.
  • Server State با روش مشخص مدیریت می‌شود.
  • RTK Query Reducer و Middleware ثبت شده‌اند.
  • Error و Loading State نمایش داده می‌شوند.
  • Request تکراری کنترل شده است.
  • ورودی در Backend اعتبارسنجی می‌شود.
  • دسترسی مدل در Backend بررسی می‌شود.
  • هزینه و مصرف API پایش می‌شود.
  • Reducerهای مهم تست شده‌اند.
  • Selectorهای پیچیده تست شده‌اند.
  • Redux DevTools در محیط توسعه بررسی شده است.
  • Persist فقط برای داده غیرحساس استفاده می‌شود.
  • Lint، Type Check، Test و Build اجرا می‌شوند.

پرسش‌های متداول

Redux Toolkit چیست؟

Redux Toolkit مجموعه ابزار رسمی برای نوشتن منطق Redux است. این Package تنظیم Store، ساخت Slice، تولید Action، مدیریت Reducer، عملیات Async و Data Fetching را ساده می‌کند.

تفاوت Redux و Redux Toolkit چیست؟

Redux هسته مدیریت State است. Redux Toolkit روش استاندارد و ساده‌تر استفاده از Redux را فراهم می‌کند.

آیا برای Redux Toolkit باید Redux جداگانه نصب کنیم؟

Package @reduxjs/toolkit وابستگی‌های لازم Redux را در اختیار پروژه قرار می‌دهد. برای اتصال به React نیز react-redux را نصب کنید:

npm install @reduxjs/toolkit react-redux

Store چیست؟

Store محل مرکزی نگهداری Redux State است.

Slice چیست؟

Slice بخشی از State به‌همراه Reducerها، Actionها و منطق مربوط به همان Feature است.

createSlice چه کاری انجام می‌دهد؟

createSlice براساس نام Slice، State اولیه و Reducerها، Action Creator و Reducer نهایی را تولید می‌کند.

useSelector چیست؟

Hook مربوط به React Redux برای خواندن بخشی از Redux State است.

useDispatch چیست؟

Hook مربوط به ارسال Action به Redux Store است.

createAsyncThunk چیست؟

ابزاری برای ساخت عملیات Async است که Actionهای pending، fulfilled و rejected تولید می‌کند.

RTK Query چیست؟

ابزار Data Fetching و Cache موجود در Redux Toolkit است که Endpoint و Hookهای React تولید می‌کند.

RTK Query بهتر است یا createAsyncThunk؟

برای دریافت، Cache و همگام‌سازی Server State معمولاً RTK Query مناسب‌تر است. برای Workflowهای Async سفارشی و Actionمحور، createAsyncThunk می‌تواند انتخاب بهتری باشد.

Redux بهتر است یا Context API؟

برای State مشترک ساده، Context ممکن است کافی باشد. برای State پیچیده، Debug دقیق، Middleware و Data Fetching ساختاریافته، Redux Toolkit امکانات بیشتری دارد.

آیا Redux برای پروژه کوچک لازم است؟

خیر. useState، useReducer یا Context ممکن است کافی باشند.

آیا می‌توان API Key را در Redux ذخیره کرد؟

خیر. Redux Store در Frontend قابل مشاهده است. API Key خصوصی باید در Backend و متغیر محیطی نگهداری شود.

آیا Redux State بعد از Refresh باقی می‌ماند؟

به‌صورت پیش‌فرض خیر. در صورت نیاز می‌توان بخش غیرحساس State را در Storage ذخیره و هنگام شروع برنامه بازیابی کرد.

آیا باید Response همه APIها را در Redux ذخیره کنیم؟

خیر. برای Server State می‌توانید از RTK Query استفاده کنید و برای داده محلی ساده از State Component بهره ببرید.

چگونه مدل هوش مصنوعی را انتخاب کنیم؟

شناسه مدل انتخاب‌شده را می‌توان در Slice تنظیمات نگهداری کرد؛ اما Backend باید معتبر بودن و مجاز بودن مدل را بررسی کند. فهرست مدل‌ها و قیمت‌ها در صفحه مدل‌های درواره قرار دارد.

جمع‌بندی

Redux Toolkit روش استاندارد و مدرن استفاده از Redux در پروژه‌های React است. این ابزار Store مرکزی، Slice، Action، Reducer، Middleware، عملیات Async و Data Fetching را در یک ساختار منظم و قابل نگهداری ارائه می‌کند.

مهم‌ترین نکات این آموزش:

  • همه پروژه‌های React به Redux نیاز ندارند.
  • configureStore برای ساخت Store استفاده می‌شود.
  • Provider، Store را در اختیار React قرار می‌دهد.
  • createSlice، Reducer و Actionها را تولید می‌کند.
  • useSelector State را می‌خواند.
  • useDispatch Action ارسال می‌کند.
  • Immer نوشتن Updateهای Immutable را ساده می‌کند.
  • عملیات شبکه نباید داخل Reducer اجرا شود.
  • createAsyncThunk برای Workflowهای Async مناسب است.
  • RTK Query دریافت و Cache داده API را ساده می‌کند.
  • State غیرSerializable نباید داخل Store قرار بگیرد.
  • State محلی ساده باید در Component باقی بماند.
  • داده‌های مشتق‌شده بهتر است با Selector محاسبه شوند.
  • API Key هوش مصنوعی نباید در Redux یا Frontend ذخیره شود.
  • Backend باید ورودی، دسترسی، مدل و محدودیت مصرف را بررسی کند.

برای ساخت داشبورد هوش مصنوعی، Playground، ابزار تولید محتوا یا اپلیکیشن سازمانی می‌توانید Redux Toolkit را برای مدیریت State سمت Client و API هوش مصنوعی درواره را برای اتصال Backend به مدل‌های مختلف استفاده کنید.

برای مشاهده شناسه مدل‌ها و اطلاعات به‌روز قیمت، صفحه مدل‌های درواره را ببینید.

مقالات مرتبط

منابع تکمیلی

برای مطالعه شرایط استفاده و محدودیت‌های مسئولیت، صفحه «سلب مسئولیت» را مشاهده کنید.

Read more

اتوماسیون هوش مصنوعی چیست؟ کاربردها و آموزش ساخت AI Automation

اتوماسیون هوش مصنوعی چیست؟ کاربردها و آموزش ساخت AI Automation

اتوماسیون هوش مصنوعی با ترکیب گردش‌کارهای خودکار و مدل‌های هوش مصنوعی، پردازش متن، دسته‌بندی، استخراج اطلاعات و تصمیم‌های پیشنهادی را خودکار می‌کند. در این راهنما، معماری و ساخت نمونه عملی آن با API درواره را می‌آموزید.

Agentic Commerce چیست؟ آینده خرید با ایجنت هوش مصنوعی

Agentic Commerce چیست؟ آینده خرید با ایجنت هوش مصنوعی

Agentic Commerce شیوه‌ای جدید برای خرید اینترنتی است که در آن ایجنت هوش مصنوعی می‌تواند نیاز کاربر را بفهمد، محصولات را جست‌وجو و مقایسه کند و فرایند خرید را پیش ببرد. در این راهنما با معماری، UCP، ACP و پیاده‌سازی آن با API درواره آشنا می‌شوید.