React چیست؟ آموزش کامل ری‌اکت از صفر با ساخت اپلیکیشن هوش مصنوعی

در این آموزش React را از صفر تا سطح کاربردی یاد می‌گیرید؛ از Component، JSX، Props و State تا Hook، useEffect، فرم و ارتباط با API. در پایان یک اپلیکیشن هوش مصنوعی واقعی با React، Vite و API درواره می‌سازیم.

Share
React چیست؟ آموزش کامل ری‌اکت از صفر با ساخت اپلیکیشن هوش مصنوعی


React یا ری‌اکت یکی از محبوب‌ترین ابزارهای توسعه رابط کاربری وب است. React به توسعه‌دهنده اجازه می‌دهد رابط‌های پیچیده را از Componentهای کوچک، مستقل و قابل‌استفاده مجدد بسازد.

به‌جای اینکه تمام صفحه را در یک فایل بزرگ مدیریت کنیم، می‌توانیم بخش‌هایی مانند Header، فرم، کارت محصول، پیام گفت‌وگو و دکمه را به Componentهای جداگانه تبدیل کنیم. هر Component ورودی مشخصی دریافت می‌کند، State خود را مدیریت می‌کند و در صورت تغییر داده، بخش مرتبط رابط را دوباره نمایش می‌دهد.

در این آموزش ابتدا مفاهیم اصلی React را یاد می‌گیریم و سپس یک اپلیکیشن واقعی می‌سازیم: یک دستیار فارسی که با React و Vite در Frontend اجرا می‌شود، یک Backend کوچک Node.js دارد و از طریق API درواره به مدل هوش مصنوعی انتخاب‌شده متصل می‌شود.

React چیست؟

React یک کتابخانه JavaScript برای ساخت رابط کاربری است. در React، رابط از Componentها تشکیل می‌شود و هر Component می‌تواند بخشی از UI را براساس داده و State نمایش دهد.

مثال یک Component ساده:

function Welcome() {
  return <h1>به آموزش React خوش آمدید</h1>;
}

استفاده از Component:

function App() {
  return (
    <main>
      <Welcome />
      <p>اولین اپلیکیشن React شما آماده است.</p>
    </main>
  );
}

براساس مستندات رسمی React، مفاهیم پایه این کتابخانه شامل Component، JSX، نمایش داده، شرط، فهرست، Event، State و اشتراک‌گذاری داده میان Componentها است.

آیا React فریم‌ورک است یا کتابخانه؟

React در مستندات رسمی به‌عنوان کتابخانه رابط کاربری معرفی می‌شود. React به‌تنهایی تمام نیازهای یک اپلیکیشن وب را پوشش نمی‌دهد.

برای امکانات دیگری مانند موارد زیر معمولاً ابزارهای تکمیلی انتخاب می‌شوند:

  • Routing
  • دریافت و Cache کردن داده
  • ساخت و Bundle کردن پروژه
  • Server-side Rendering
  • مدیریت فرم‌های پیچیده
  • تست
  • Authentication
  • مدیریت State سراسری
  • Backend

فریم‌ورک‌هایی مانند Next.js از React استفاده می‌کنند و قابلیت‌های بیشتری مانند Routing، Rendering سمت سرور و ساختار پروژه ارائه می‌دهند.

در این آموزش برای یادگیری خود React از Vite استفاده می‌کنیم تا لایه‌های اصلی React را بدون پیچیدگی یک فریم‌ورک Full Stack ببینیم.

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

فرض کنید یک رابط گفت‌وگوی هوش مصنوعی دارید. این صفحه شامل بخش‌های زیر است:

  • Header
  • انتخاب مدل
  • فهرست پیام‌ها
  • پیام کاربر
  • پیام دستیار
  • فرم ارسال
  • دکمه توقف
  • نمایش خطا
  • وضعیت Loading
  • شمارنده پیام‌ها

در JavaScript خالص می‌توان تمام این بخش‌ها را ساخت، اما با افزایش State و تعامل‌ها، هماهنگ نگه‌داشتن DOM با داده دشوارتر می‌شود.

در React، شما مشخص می‌کنید رابط در هر State باید چگونه به نظر برسد. وقتی State تغییر کند، React بخش لازم از رابط را به‌روزرسانی می‌کند.

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

State برنامه
    ↓
Componentهای React
    ↓
رابط کاربری

برای مثال:

function Status({ isLoading }) {
  return (
    <span>
      {isLoading ? "در حال دریافت پاسخ" : "آماده"}
    </span>
  );
}

با تغییر isLoading، متن مناسب نمایش داده می‌شود.

تفاوت React با JavaScript خالص

React جای JavaScript را نمی‌گیرد. React با JavaScript ساخته شده و کد React در نهایت به JavaScript قابل‌اجرا تبدیل می‌شود.

موضوعJavaScript خالصReact
ساخت رابطتغییر مستقیم DOMتعریف UI براساس State
استفاده مجددتابع و ModuleComponent
مدیریت StateدستیHookها و الگوهای React
ساختار پروژه بزرگنیازمند معماری دستیمعماری Component-based
اکوسیستمWeb APIهای مرورگراکوسیستم React
نیاز به JavaScriptپایه کارضروری

قبل از یادگیری React بهتر است این مفاهیم JavaScript را بشناسید:

  • const و let
  • تابع
  • Arrow Function
  • Array و Object
  • map و filter
  • Destructuring
  • Spread Syntax
  • Module
  • Promise
  • Async/Await
  • Fetch API
  • Event

تفاوت React و React Native

React برای ساخت رابط وب استفاده می‌شود و معمولاً خروجی آن عناصر HTML است.

React Native برای ساخت اپلیکیشن موبایل استفاده می‌شود و Componentهای بومی موبایل را نمایش می‌دهد.

// React وب
<button>ارسال</button>
// React Native
<Button title="ارسال" />

مفاهیمی مانند Component، Props، State و Hook میان آن‌ها مشترک‌اند، اما عناصر، APIها و روش استایل‌دهی متفاوت هستند.

تفاوت React و Next.js

ویژگیReactNext.js
نوع ابزارکتابخانه UIفریم‌ورک React
Routing داخلیندارددارد
Server Renderingبه‌تنهایی مدیریت نمی‌کندپشتیبانی می‌کند
Backend Routeندارددارد
ساختار پروژهانتخاب توسعه‌دهندهساختار مشخص‌تر
مناسب یادگیری Reactبسیار مناسبلایه‌های بیشتر
مناسب محصول Full Stackبا ابزارهای مکملمناسب‌تر برای بسیاری از پروژه‌ها

یادگیری خود React پیش از Next.js باعث می‌شود Component، State، Hook و Rendering را بهتر درک کنید.

پیش‌نیازهای آموزش

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

  • Node.js
  • npm
  • یک ویرایشگر مانند VS Code
  • مرورگر مدرن
  • آشنایی مقدماتی با HTML، CSS و JavaScript

بررسی نصب:

node --version
npm --version

Vite چیست؟

Vite ابزار توسعه و Build برای پروژه‌های وب مدرن است. با استفاده از Vite می‌توان یک پروژه React را سریع ایجاد و در حالت Development اجرا کرد.

طبق راهنمای رسمی Vite، دستور npm create vite@latest برای ساخت پروژه جدید قابل‌استفاده است.

ساخت پروژه React:

npm create vite@latest react-ai-assistant -- --template react

ورود به پوشه:

cd react-ai-assistant

نصب وابستگی‌ها:

npm install

اجرای پروژه:

npm run dev

آدرس نمایش‌داده‌شده در Terminal را در مرورگر باز کنید.

ساختار اولیه پروژه React

ساختار معمول پروژه ایجادشده با Vite:

react-ai-assistant/
├── public/
├── src/
│   ├── assets/
│   ├── App.jsx
│   ├── App.css
│   ├── index.css
│   └── main.jsx
├── index.html
├── package.json
└── vite.config.js

نقطه شروع برنامه در main.jsx است:

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

import App from "./App.jsx";
import "./index.css";

createRoot(
  document.getElementById("root")
).render(
  <StrictMode>
    <App />
  </StrictMode>
);

عنصر root در فایل index.html قرار دارد:

<div id="root"></div>

React اپلیکیشن را داخل این عنصر Render می‌کند.

Component در React چیست؟

Component یک تابع JavaScript است که بخشی از رابط کاربری را برمی‌گرداند.

function Header() {
  return (
    <header>
      <h1>دستیار هوش مصنوعی</h1>
    </header>
  );
}

استفاده:

function App() {
  return (
    <div>
      <Header />
    </div>
  );
}

نام Component باید با حرف بزرگ شروع شود:

function Message() {
  return <p>پیام</p>;
}

اگر با حرف کوچک بنویسید، React آن را مانند تگ HTML در نظر می‌گیرد:

function message() {
  return <p>پیام</p>;
}

JSX چیست؟

JSX ساختاری شبیه HTML است که داخل JavaScript نوشته می‌شود:

const element = (
  <section>
    <h2>مدل انتخاب‌شده</h2>
    <p>{modelId}</p>
  </section>
);

JSX خود HTML نیست. این Syntax در فرایند Build به فراخوانی‌های JavaScript تبدیل می‌شود.

تفاوت‌های JSX و HTML

در JSX به‌جای class از className استفاده می‌شود:

<div className="message">
  پیام
</div>

برخی Attributeها با نام Camel Case نوشته می‌شوند:

<button onClick={handleClick}>
  ارسال
</button>

همه تگ‌ها باید بسته شوند:

<img src="/logo.png" alt="درواره" />

Component باید یک ریشه واحد برگرداند:

function Profile() {
  return (
    <>
      <h2>پروفایل</h2>
      <p>اطلاعات کاربر</p>
    </>
  );
}

<>...</> یک Fragment است و عنصر اضافی در DOM ایجاد نمی‌کند.

استفاده از JavaScript در JSX

عبارت JavaScript داخل {} قرار می‌گیرد:

function ModelInfo() {
  const modelId = "YOUR_MODEL_ID";

  return (
    <p>
      مدل فعال: <code>{modelId}</code>
    </p>
  );
}

محاسبه:

function MessageCount({ messages }) {
  return (
    <span>
      تعداد پیام‌ها: {messages.length}
    </span>
  );
}

فراخوانی تابع:

function Greeting({ name }) {
  return <h2>{name.trim()}</h2>;
}

داخل {} باید Expression قرار گیرد. دستورهایی مانند if مستقیماً در JSX نوشته نمی‌شوند، اما می‌توان نتیجه شرط را پیش از return محاسبه کرد.

Props چیست؟

Props داده‌هایی هستند که Component والد به Component فرزند ارسال می‌کند.

function UserMessage({ content }) {
  return (
    <article className="message message--user">
      <strong>شما</strong>
      <p>{content}</p>
    </article>
  );
}

استفاده:

<UserMessage content="React چیست؟" />

چند Prop:

function Message({
  role,
  content,
  createdAt
}) {
  return (
    <article className={`message message--${role}`}>
      <p>{content}</p>
      <time>{createdAt}</time>
    </article>
  );
}

Props فقط خواندنی هستند. Component نباید Prop دریافتی را مستقیماً تغییر دهد.

این کار مناسب نیست:

function Message({ content }) {
  content = content.trim();

  return <p>{content}</p>;
}

نسخه روشن‌تر:

function Message({ content }) {
  const normalizedContent = content.trim();

  return <p>{normalizedContent}</p>;
}

State چیست؟

State اطلاعاتی است که در طول عمر Component تغییر می‌کند و بر نمایش رابط اثر می‌گذارد.

برای تعریف State از Hook به نام useState استفاده می‌کنیم:

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button
      type="button"
      onClick={() => setCount(count + 1)}
    >
      تعداد: {count}
    </button>
  );
}

در این مثال:

  • count مقدار فعلی State است.
  • setCount تابع به‌روزرسانی State است.
  • 0 مقدار اولیه است.

مرجع کامل: مستندات useState در React

به‌روزرسانی State براساس مقدار قبلی

اگر مقدار جدید به State قبلی وابسته است، از Updater Function استفاده کنید:

setCount((previousCount) => {
  return previousCount + 1;
});

نسخه کوتاه:

setCount((count) => count + 1);

این الگو در به‌روزرسانی‌های متوالی قابل‌اعتمادتر است.

State را مستقیماً تغییر ندهید

این کار اشتباه است:

messages.push(newMessage);
setMessages(messages);

آرایه اصلی تغییر کرده و Reference آن ثابت مانده است.

نسخه مناسب:

setMessages((currentMessages) => [
  ...currentMessages,
  newMessage
]);

برای Object:

setSettings((currentSettings) => ({
  ...currentSettings,
  temperature: 0.3
}));

React State را مانند Snapshot در نظر می‌گیرد. بهتر است Array و Object موجود را تغییر ندهید و مقدار جدید بسازید.

Event در React

Event Handler به‌صورت تابع به عنصر داده می‌شود:

function SendButton() {
  function handleClick() {
    console.log("پیام ارسال شد");
  }

  return (
    <button
      type="button"
      onClick={handleClick}
    >
      ارسال
    </button>
  );
}

این اشتباه است:

<button onClick={handleClick()}>
  ارسال
</button>

در این حالت تابع هنگام Render اجرا می‌شود.

نسخه صحیح:

<button onClick={handleClick}>
  ارسال
</button>

یا:

<button onClick={() => handleClick()}>
  ارسال
</button>

فرم کنترل‌شده در React

در Controlled Component، مقدار Input در State نگهداری می‌شود:

import { useState } from "react";

function PromptForm() {
  const [prompt, setPrompt] = useState("");

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

    const normalizedPrompt = prompt.trim();

    if (!normalizedPrompt) {
      return;
    }

    console.log(normalizedPrompt);
    setPrompt("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        value={prompt}
        onChange={(event) => {
          setPrompt(event.target.value);
        }}
      />

      <button type="submit">
        ارسال
      </button>
    </form>
  );
}

در این الگو، React منبع اصلی مقدار Input است.

نمایش شرطی

با if:

function Status({ error, isLoading }) {
  if (error) {
    return <p className="error">{error}</p>;
  }

  if (isLoading) {
    return <p>در حال دریافت پاسخ...</p>;
  }

  return <p>آماده</p>;
}

با عملگر سه‌تایی:

<span>
  {isLoading ? "در حال پردازش" : "آماده"}
</span>

با &&:

{error && (
  <p className="error">
    {error}
  </p>
)}

مراقب مقدار صفر باشید:

// ممکن است عدد صفر روی صفحه نمایش داده شود
{messages.length && <MessageList />}

نسخه بهتر:

{messages.length > 0 && <MessageList />}

نمایش فهرست با map

function MessageList({ messages }) {
  return (
    <section>
      {messages.map((message) => (
        <Message
          key={message.id}
          role={message.role}
          content={message.content}
        />
      ))}
    </section>
  );
}

اهمیت key در React

هر عضو فهرست باید یک key پایدار و یکتا داشته باشد:

<Message
  key={message.id}
  content={message.content}
/>

در فهرستی که ترتیب آن تغییر می‌کند، استفاده از Index انتخاب مناسبی نیست:

messages.map((message, index) => (
  <Message
    key={index}
    content={message.content}
  />
));

بهتر است هر پیام هنگام ایجاد ID داشته باشد:

const message = {
  id: crypto.randomUUID(),
  role: "user",
  content: prompt
};

key برای React است و به‌عنوان Prop معمولی در Component دریافت نمی‌شود. اگر ID را نیاز دارید، جداگانه ارسال کنید:

<Message
  key={message.id}
  messageId={message.id}
/>

بالا بردن State یا Lifting State Up

اگر چند Component به یک داده مشترک نیاز دارند، State را در نزدیک‌ترین والد مشترک قرار دهید.

function App() {
  const [messages, setMessages] = useState([]);

  return (
    <>
      <MessageList messages={messages} />

      <PromptForm
        onSend={(message) => {
          setMessages((current) => [
            ...current,
            message
          ]);
        }}
      />
    </>
  );
}

به‌این‌ترتیب:

  • App مالک State است.
  • MessageList داده را دریافت می‌کند.
  • PromptForm رویداد را به والد اطلاع می‌دهد.

Hook چیست؟

Hook تابعی ویژه در React است که امکان استفاده از قابلیت‌هایی مانند State، Context و Lifecycle را در Function Component فراهم می‌کند.

Hookهای پرکاربرد:

  • useState
  • useEffect
  • useRef
  • useMemo
  • useCallback
  • useReducer
  • useContext

قواعد اصلی Hook:

  • Hook را در بالاترین سطح Component فراخوانی کنید.
  • Hook را داخل شرط، حلقه یا تابع تو‌در‌تو فراخوانی نکنید.
  • Hook را فقط در React Component یا Custom Hook فراخوانی کنید.
  • نام Custom Hook را با use شروع کنید.

این کار اشتباه است:

if (isEnabled) {
  const [value, setValue] = useState("");
}

نسخه مناسب:

const [value, setValue] = useState("");

if (!isEnabled) {
  return null;
}

useEffect چیست؟

useEffect برای هماهنگ‌کردن Component با یک سیستم خارجی استفاده می‌شود؛ مانند:

  • درخواست شبکه
  • Event Listener مرورگر
  • Timer
  • اتصال WebSocket
  • API غیرReact
  • همگام‌سازی عنوان صفحه

مثال:

import { useEffect } from "react";

function PageTitle({ messageCount }) {
  useEffect(() => {
    document.title =
      `${messageCount} پیام | دستیار درواره`;
  }, [messageCount]);

  return null;
}

Effect پس از Commit شدن تغییرات اجرا می‌شود.

مرجع رسمی: مستندات useEffect در React

آیا برای هر محاسبه‌ای به useEffect نیاز داریم؟

خیر. داده‌ای که مستقیماً از Props یا State قابل‌محاسبه است، معمولاً نباید State جدا یا Effect داشته باشد.

روش غیرضروری:

const [messageCount, setMessageCount] =
  useState(0);

useEffect(() => {
  setMessageCount(messages.length);
}, [messages]);

نسخه ساده‌تر:

const messageCount = messages.length;

Effect برای هماهنگی با سیستم خارجی است، نه هر محاسبه داخل Component.

Cleanup در useEffect

برای Event Listener یا Timer باید Cleanup تعریف شود:

useEffect(() => {
  function handleOnline() {
    console.log("اتصال برقرار شد");
  }

  window.addEventListener(
    "online",
    handleOnline
  );

  return () => {
    window.removeEventListener(
      "online",
      handleOnline
    );
  };
}, []);

برای Timer:

useEffect(() => {
  const timerId = setInterval(() => {
    console.log("checking...");
  }, 5000);

  return () => {
    clearInterval(timerId);
  };
}, []);

useRef چیست؟

useRef مقدار قابل‌تغییری را نگهداری می‌کند که تغییر آن باعث Render مجدد نمی‌شود. همچنین برای دسترسی به عنصر DOM کاربرد دارد.

Focus روی Textarea:

import { useRef } from "react";

function PromptForm() {
  const inputRef = useRef(null);

  function focusInput() {
    inputRef.current?.focus();
  }

  return (
    <>
      <textarea ref={inputRef} />

      <button
        type="button"
        onClick={focusInput}
      >
        نوشتن پیام
      </button>
    </>
  );
}

نگهداری AbortController نیز کاربرد مناسبی برای Ref است:

const controllerRef = useRef(null);

useReducer چیست؟

وقتی State چند حالت و Transition مرتبط دارد، useReducer می‌تواند منطق را منظم‌تر کند.

import { useReducer } from "react";

const initialState = {
  messages: [],
  isLoading: false,
  error: ""
};

function chatReducer(state, action) {
  switch (action.type) {
    case "message/added":
      return {
        ...state,
        messages: [
          ...state.messages,
          action.payload
        ]
      };

    case "request/started":
      return {
        ...state,
        isLoading: true,
        error: ""
      };

    case "request/succeeded":
      return {
        ...state,
        isLoading: false,
        messages: [
          ...state.messages,
          action.payload
        ]
      };

    case "request/failed":
      return {
        ...state,
        isLoading: false,
        error: action.payload
      };

    default:
      throw new Error(
        `Unknown action: ${action.type}`
      );
  }
}

استفاده:

const [state, dispatch] = useReducer(
  chatReducer,
  initialState
);

برای یک Boolean ساده، useState مناسب‌تر است. برای Workflowهایی با حالت‌های مرتبط، Reducer می‌تواند قابل‌فهم‌تر باشد.

Context چیست؟

Context امکان ارسال داده به Componentهای عمیق را بدون عبور دستی Prop از تمام لایه‌ها فراهم می‌کند.

موارد مناسب:

  • Theme
  • زبان رابط
  • اطلاعات کاربر احراز هویت‌شده
  • تنظیمات عمومی
  • وابستگی مشترک محدود

Context نباید بدون نیاز برای تمام Stateهای برنامه استفاده شود. State نزدیک به محل مصرف معمولاً ساده‌تر و قابل‌نگهداری‌تر است.

Custom Hook چیست؟

Custom Hook منطق React قابل‌استفاده مجدد را استخراج می‌کند.

import {
  useEffect,
  useState
} from "react";

export function useOnlineStatus() {
  const [isOnline, setIsOnline] =
    useState(navigator.onLine);

  useEffect(() => {
    function updateStatus() {
      setIsOnline(navigator.onLine);
    }

    window.addEventListener(
      "online",
      updateStatus
    );

    window.addEventListener(
      "offline",
      updateStatus
    );

    return () => {
      window.removeEventListener(
        "online",
        updateStatus
      );

      window.removeEventListener(
        "offline",
        updateStatus
      );
    };
  }, []);

  return isOnline;
}

استفاده:

function ConnectionStatus() {
  const isOnline = useOnlineStatus();

  return (
    <span>
      {isOnline ? "آنلاین" : "آفلاین"}
    </span>
  );
}

Custom Hook رابط کاربری برنمی‌گرداند؛ منطق و State قابل‌اشتراک را فراهم می‌کند.

تفکر Component-based

براساس راهنمای Thinking in React، یک روش مناسب طراحی رابط این است:

  1. رابط را به Componentهای کوچک تقسیم کنید.
  2. نسخه ثابت رابط را بسازید.
  3. حداقل State موردنیاز را مشخص کنید.
  4. مالک مناسب هر State را پیدا کنید.
  5. جریان داده و Eventها را طراحی کنید.

برای دستیار هوش مصنوعی می‌توانیم این ساختار را داشته باشیم:

App
├── AppHeader
├── ModelInfo
├── MessageList
│   └── MessageItem
├── EmptyState
├── ErrorMessage
└── PromptForm

پروژه عملی: ساخت اپلیکیشن هوش مصنوعی با React

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

  • نمایش پیام‌های کاربر و دستیار
  • فرم کنترل‌شده
  • مدیریت State با useReducer
  • Componentهای مستقل
  • Custom Hook برای گفت‌وگو
  • ارتباط با Backend
  • مدیریت Loading
  • مدیریت خطا
  • توقف درخواست
  • پاک‌کردن گفت‌وگو
  • Scroll خودکار
  • جلوگیری از افشای API Key
  • طراحی فارسی و RTL

معماری:

React در مرورگر
    ↓
Backend برنامه
    ↓
API درواره
    ↓
مدل هوش مصنوعی

ساختار نهایی پروژه

react-ai-assistant/
├── backend/
│   ├── .env
│   ├── package.json
│   └── server.js
├── src/
│   ├── components/
│   │   ├── AppHeader.jsx
│   │   ├── MessageItem.jsx
│   │   ├── MessageList.jsx
│   │   └── PromptForm.jsx
│   ├── hooks/
│   │   └── useChat.js
│   ├── services/
│   │   └── chatApi.js
│   ├── App.jsx
│   ├── App.css
│   ├── index.css
│   └── main.jsx
├── .gitignore
├── index.html
├── package.json
└── vite.config.js

تنظیم Proxy در Vite

Frontend روی یک Port و Backend روی Port دیگری اجرا می‌شوند. در Development می‌توانیم درخواست‌های /api را با Proxy به Backend بفرستیم.

فایل vite.config.js:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

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

  server: {
    proxy: {
      "/api": {
        target: "http://localhost:3001",
        changeOrigin: true
      }
    }
  }
});

در Frontend درخواست را به مسیر نسبی می‌فرستیم:

fetch("/api/chat");

کلید API همچنان در Backend قرار دارد. Proxy فقط مسیر درخواست Development را ساده می‌کند.

ساخت Service ارتباط با Backend

فایل src/services/chatApi.js:

export async function sendChatRequest({
  messages,
  signal
}) {
  const response = await fetch("/api/chat", {
    method: "POST",

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

    body: JSON.stringify({
      messages
    }),

    signal
  });

  const contentType =
    response.headers.get("content-type") || "";

  if (!contentType.includes("application/json")) {
    throw new Error(
      "پاسخ سرور در قالب مورد انتظار نیست."
    );
  }

  const data = await response.json();

  if (!response.ok) {
    throw new Error(
      data.error ||
      `درخواست ناموفق بود. کد: ${response.status}`
    );
  }

  if (
    typeof data.answer !== "string" ||
    !data.answer.trim()
  ) {
    throw new Error(
      "پاسخ معتبر از مدل دریافت نشد."
    );
  }

  return data.answer;
}

قرار دادن منطق شبکه در Service چند مزیت دارد:

  • Component ساده‌تر می‌شود.
  • تست ارتباط راحت‌تر است.
  • Endpoint در یک محل مدیریت می‌شود.
  • منطق Parse کردن پاسخ تکرار نمی‌شود.

ساخت Custom Hook گفت‌وگو

فایل src/hooks/useChat.js:

import {
  useCallback,
  useReducer,
  useRef
} from "react";

import {
  sendChatRequest
} from "../services/chatApi.js";

const initialState = {
  messages: [],
  isLoading: false,
  error: ""
};

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

function chatReducer(state, action) {
  switch (action.type) {
    case "request/started":
      return {
        ...state,
        isLoading: true,
        error: "",
        messages: [
          ...state.messages,
          action.payload
        ]
      };

    case "request/succeeded":
      return {
        ...state,
        isLoading: false,
        messages: [
          ...state.messages,
          action.payload
        ]
      };

    case "request/failed":
      return {
        ...state,
        isLoading: false,
        error: action.payload
      };

    case "request/cancelled":
      return {
        ...state,
        isLoading: false,
        error: "درخواست متوقف شد."
      };

    case "chat/cleared":
      return initialState;

    default:
      throw new Error(
        `Unknown action type: ${action.type}`
      );
  }
}

function getApiMessages(messages) {
  return messages
    .slice(-20)
    .map(({ role, content }) => ({
      role,
      content
    }));
}

export function useChat() {
  const [state, dispatch] = useReducer(
    chatReducer,
    initialState
  );

  const controllerRef = useRef(null);

  const sendMessage = useCallback(
    async (rawPrompt) => {
      const prompt = rawPrompt.trim();

      if (!prompt) {
        throw new Error(
          "پیام نمی‌تواند خالی باشد."
        );
      }

      if (prompt.length > 4000) {
        throw new Error(
          "پیام نباید بیشتر از ۴۰۰۰ کاراکتر باشد."
        );
      }

      if (state.isLoading) {
        return;
      }

      const userMessage =
        createMessage("user", prompt);

      const requestMessages = [
        ...state.messages,
        userMessage
      ];

      dispatch({
        type: "request/started",
        payload: userMessage
      });

      const controller =
        new AbortController();

      controllerRef.current = controller;

      try {
        const answer = await sendChatRequest({
          messages:
            getApiMessages(requestMessages),

          signal: controller.signal
        });

        dispatch({
          type: "request/succeeded",

          payload: createMessage(
            "assistant",
            answer
          )
        });
      } catch (error) {
        if (error.name === "AbortError") {
          dispatch({
            type: "request/cancelled"
          });

          return;
        }

        dispatch({
          type: "request/failed",
          payload:
            error.message ||
            "خطای غیرمنتظره رخ داد."
        });
      } finally {
        controllerRef.current = null;
      }
    },
    [
      state.isLoading,
      state.messages
    ]
  );

  const cancelRequest = useCallback(() => {
    controllerRef.current?.abort();
  }, []);

  const clearChat = useCallback(() => {
    controllerRef.current?.abort();

    dispatch({
      type: "chat/cleared"
    });
  }, []);

  return {
    ...state,
    sendMessage,
    cancelRequest,
    clearChat
  };
}

این Hook مسئول State و منطق گفت‌وگو است. Componentهای رابط لازم نیست جزئیات fetch یا AbortController را بدانند.

ساخت Header

فایل src/components/AppHeader.jsx:

export function AppHeader({
  messageCount,
  isLoading,
  onClear
}) {
  return (
    <header className="app-header">
      <div>
        <p className="eyebrow">
          پروژه عملی React
        </p>

        <h1>
          دستیار هوش مصنوعی درواره
        </h1>

        <p className="app-header__meta">
          {messageCount} پیام در گفت‌وگو
        </p>
      </div>

      <div className="app-header__actions">
        <span
          className={
            isLoading
              ? "status status--loading"
              : "status"
          }
        >
          {isLoading
            ? "در حال پردازش"
            : "آماده"}
        </span>

        <button
          className="button button--ghost"
          type="button"
          onClick={onClear}
          disabled={
            isLoading || messageCount === 0
          }
        >
          گفت‌وگوی جدید
        </button>
      </div>
    </header>
  );
}

ساخت Component پیام

فایل src/components/MessageItem.jsx:

function formatTime(value) {
  return new Intl.DateTimeFormat("fa-IR", {
    hour: "2-digit",
    minute: "2-digit"
  }).format(new Date(value));
}

export function MessageItem({
  role,
  content,
  createdAt
}) {
  const isUser = role === "user";

  return (
    <article
      className={
        isUser
          ? "message message--user"
          : "message message--assistant"
      }
    >
      <div
        className="message__avatar"
        aria-hidden="true"
      >
        {isUser ? "ش" : "د"}
      </div>

      <div className="message__body">
        <div className="message__meta">
          <strong>
            {isUser
              ? "شما"
              : "دستیار درواره"}
          </strong>

          <time dateTime={createdAt}>
            {formatTime(createdAt)}
          </time>
        </div>

        <p className="message__content">
          {content}
        </p>
      </div>
    </article>
  );
}

React متن content را به‌صورت متن نمایش می‌دهد و آن را مانند innerHTML مستقیماً تفسیر نمی‌کند. برای نمایش Markdown باید یک راهکار معتبر همراه با پاک‌سازی HTML انتخاب شود.

ساخت فهرست پیام‌ها

فایل src/components/MessageList.jsx:

import {
  useEffect,
  useRef
} from "react";

import {
  MessageItem
} from "./MessageItem.jsx";

export function MessageList({
  messages,
  isLoading
}) {
  const endRef = useRef(null);

  useEffect(() => {
    endRef.current?.scrollIntoView({
      behavior: "smooth",
      block: "end"
    });
  }, [
    messages,
    isLoading
  ]);

  if (messages.length === 0) {
    return (
      <section className="empty-state">
        <div
          className="empty-state__icon"
          aria-hidden="true"
        >
          ✦
        </div>

        <h2>
          اولین پیام را ارسال کنید
        </h2>

        <p>
          می‌توانید درباره برنامه‌نویسی، تولید محتوا
          یا تحلیل متن سؤال بپرسید.
        </p>
      </section>
    );
  }

  return (
    <section
      className="message-list"
      aria-label="پیام‌های گفت‌وگو"
      aria-live="polite"
    >
      {messages.map((message) => (
        <MessageItem
          key={message.id}
          role={message.role}
          content={message.content}
          createdAt={message.createdAt}
        />
      ))}

      {isLoading && (
        <article className="message message--assistant">
          <div
            className="message__avatar"
            aria-hidden="true"
          >
            د
          </div>

          <div className="typing">
            <span></span>
            <span></span>
            <span></span>

            <span className="sr-only">
              در حال آماده‌سازی پاسخ
            </span>
          </div>
        </article>
      )}

      <div ref={endRef}></div>
    </section>
  );
}

ساخت فرم ارسال پیام

فایل src/components/PromptForm.jsx:

import {
  useRef,
  useState
} from "react";

export function PromptForm({
  isLoading,
  onSend,
  onCancel
}) {
  const [prompt, setPrompt] = useState("");
  const [validationError, setValidationError] =
    useState("");

  const inputRef = useRef(null);

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

    const normalizedPrompt = prompt.trim();

    if (!normalizedPrompt) {
      setValidationError(
        "لطفاً متن پیام را وارد کنید."
      );

      inputRef.current?.focus();
      return;
    }

    if (normalizedPrompt.length > 4000) {
      setValidationError(
        "پیام نباید بیشتر از ۴۰۰۰ کاراکتر باشد."
      );

      return;
    }

    setValidationError("");
    setPrompt("");

    try {
      await onSend(normalizedPrompt);
    } catch (error) {
      setValidationError(error.message);
      setPrompt(normalizedPrompt);
    } finally {
      inputRef.current?.focus();
    }
  }

  function handleKeyDown(event) {
    if (
      event.key === "Enter" &&
      !event.shiftKey &&
      !event.nativeEvent.isComposing
    ) {
      event.preventDefault();
      event.currentTarget.form?.requestSubmit();
    }
  }

  return (
    <form
      className="prompt-form"
      onSubmit={handleSubmit}
    >
      <label
        className="sr-only"
        htmlFor="prompt"
      >
        پیام شما
      </label>

      <textarea
        ref={inputRef}
        id="prompt"
        name="prompt"
        rows="2"
        maxLength="4000"
        value={prompt}
        placeholder="پیام خود را بنویسید..."
        disabled={isLoading}
        onChange={(event) => {
          setPrompt(event.target.value);

          if (validationError) {
            setValidationError("");
          }
        }}
        onKeyDown={handleKeyDown}
      />

      <div className="prompt-form__footer">
        <span className="character-count">
          {prompt.length.toLocaleString("fa-IR")}
          {" / "}
          ۴۰۰۰
        </span>

        <div className="prompt-form__actions">
          {isLoading && (
            <button
              className="button button--secondary"
              type="button"
              onClick={onCancel}
            >
              توقف
            </button>
          )}

          <button
            className="button button--primary"
            type="submit"
            disabled={
              isLoading || !prompt.trim()
            }
          >
            ارسال پیام
          </button>
        </div>
      </div>

      {validationError && (
        <p
          className="form-error"
          role="alert"
        >
          {validationError}
        </p>
      )}
    </form>
  );
}

ساخت Component اصلی App

فایل src/App.jsx:

import {
  AppHeader
} from "./components/AppHeader.jsx";

import {
  MessageList
} from "./components/MessageList.jsx";

import {
  PromptForm
} from "./components/PromptForm.jsx";

import {
  useChat
} from "./hooks/useChat.js";

import "./App.css";

export default function App() {
  const {
    messages,
    isLoading,
    error,
    sendMessage,
    cancelRequest,
    clearChat
  } = useChat();

  return (
    <div className="page-shell">
      <main className="chat-app">
        <AppHeader
          messageCount={messages.length}
          isLoading={isLoading}
          onClear={clearChat}
        />

        <MessageList
          messages={messages}
          isLoading={isLoading}
        />

        {error && (
          <p
            className="request-error"
            role="alert"
          >
            {error}
          </p>
        )}

        <PromptForm
          isLoading={isLoading}
          onSend={sendMessage}
          onCancel={cancelRequest}
        />

        <p className="disclosure">
          پاسخ هوش مصنوعی ممکن است نیازمند بررسی باشد.
        </p>
      </main>
    </div>
  );
}

استایل عمومی

فایل src/index.css:

:root {
  font-family:
    Vazirmatn,
    Tahoma,
    Arial,
    sans-serif;

  color: #172033;
  background-color: #f4f5fa;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  min-width: 320px;
  min-height: 100%;
}

body {
  min-width: 320px;
  min-height: 100dvh;
  margin: 0;
}

button,
textarea {
  font: inherit;
}

button {
  border: 0;
}

button:focus-visible,
textarea:focus-visible {
  outline: 3px solid rgb(109 74 255 / 28%);
  outline-offset: 3px;
}

استایل اپلیکیشن

فایل src/App.css:

:root {
  --primary: #6d4aff;
  --primary-dark: #5536df;
  --primary-soft: #f0edff;

  --text: #172033;
  --muted: #697386;
  --border: #e2e6ef;
  --surface: #ffffff;
  --page: #f4f5fa;
  --error: #b42318;

  --radius-sm: 10px;
  --radius-md: 16px;
  --radius-lg: 24px;
}

.page-shell {
  min-height: 100dvh;
  padding: 24px;

  background:
    radial-gradient(
      circle at 90% 5%,
      rgb(109 74 255 / 12%),
      transparent 28%
    ),
    var(--page);
}

.chat-app {
  display: grid;
  grid-template-rows:
    auto
    minmax(300px, 1fr)
    auto
    auto
    auto;

  width: min(100%, 960px);
  min-height: calc(100dvh - 48px);
  margin-inline: auto;

  background-color: rgb(255 255 255 / 92%);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);

  box-shadow:
    0 28px 80px rgb(20 24 40 / 9%);

  overflow: hidden;
}

.app-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;

  padding: 22px 26px;

  border-block-end:
    1px solid var(--border);
}

.app-header h1 {
  margin: 0;
  font-size: clamp(1.25rem, 3vw, 1.8rem);
}

.eyebrow {
  margin: 0 0 3px;

  color: var(--primary);
  font-size: 0.75rem;
  font-weight: 800;
}

.app-header__meta {
  margin: 4px 0 0;

  color: var(--muted);
  font-size: 0.75rem;
}

.app-header__actions {
  display: flex;
  align-items: center;
  gap: 10px;
}

.status {
  padding: 6px 11px;

  color: #087443;
  background-color: #ecfdf3;

  border: 1px solid #abefc6;
  border-radius: 999px;

  font-size: 0.75rem;
  white-space: nowrap;
}

.status--loading {
  color: #6941c6;
  background-color: #f4f0ff;
  border-color: #d9d0ff;
}

.message-list {
  min-height: 0;
  padding: clamp(20px, 4vw, 40px);

  background-color: #f8f9fc;

  overflow-y: auto;
  overscroll-behavior: contain;
  scroll-behavior: smooth;
}

.message {
  display: flex;
  align-items: flex-start;
  gap: 10px;

  margin-block-end: 20px;
}

.message--user {
  flex-direction: row-reverse;
}

.message__avatar {
  display: grid;
  place-items: center;

  flex: 0 0 auto;
  width: 38px;
  height: 38px;

  color: #ffffff;
  background-color: var(--primary);

  border-radius: 12px;
  font-weight: 800;
}

.message--user .message__avatar {
  color: var(--primary);
  background-color: var(--primary-soft);
}

.message__body {
  max-width: min(78%, 720px);
}

.message--user .message__body {
  margin-inline-start: 0;
}

.message__meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;

  margin: 0 5px 5px;

  color: var(--muted);
  font-size: 0.7rem;
}

.message__content {
  margin: 0;
  padding: 13px 16px;

  background-color: var(--surface);
  border: 1px solid var(--border);
  border-radius:
    5px
    var(--radius-md)
    var(--radius-md)
    var(--radius-md);

  box-shadow:
    0 10px 28px rgb(20 24 40 / 5%);

  line-height: 1.9;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.message--user .message__content {
  color: #ffffff;
  background-color: var(--primary);
  border-color: var(--primary);

  border-radius:
    var(--radius-md)
    5px
    var(--radius-md)
    var(--radius-md);
}

.empty-state {
  align-self: center;

  max-width: 480px;
  margin: auto;
  padding: 40px 20px;

  text-align: center;
}

.empty-state__icon {
  display: grid;
  place-items: center;

  width: 64px;
  height: 64px;
  margin: 0 auto 16px;

  color: var(--primary);
  background-color: var(--primary-soft);

  border-radius: 20px;
  font-size: 1.7rem;
}

.empty-state h2 {
  margin: 0;
}

.empty-state p {
  margin: 8px 0 0;
  color: var(--muted);
}

.typing {
  display: flex;
  align-items: center;
  gap: 5px;

  min-height: 42px;
  padding: 12px 16px;

  background-color: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
}

.typing > span:not(.sr-only) {
  width: 7px;
  height: 7px;

  background-color: var(--primary);
  border-radius: 50%;

  animation:
    typing-pulse 1s ease-in-out infinite;
}

.typing > span:nth-child(2) {
  animation-delay: 120ms;
}

.typing > span:nth-child(3) {
  animation-delay: 240ms;
}

@keyframes typing-pulse {
  0%,
  100% {
    opacity: 0.3;
    transform: translateY(0);
  }

  50% {
    opacity: 1;
    transform: translateY(-3px);
  }
}

.prompt-form {
  margin: 16px 20px 8px;
  padding: 8px;

  background-color: var(--surface);
  border: 1px solid var(--border);
  border-radius: 18px;

  box-shadow:
    0 18px 45px rgb(20 24 40 / 8%);
}

.prompt-form:focus-within {
  border-color: rgb(109 74 255 / 55%);

  box-shadow:
    0 0 0 4px rgb(109 74 255 / 8%),
    0 18px 45px rgb(20 24 40 / 8%);
}

.prompt-form textarea {
  width: 100%;
  min-height: 74px;
  max-height: 200px;
  padding: 10px 12px;

  color: var(--text);
  background-color: transparent;

  border: 0;
  outline: 0;
  resize: vertical;
  line-height: 1.8;
}

.prompt-form textarea::placeholder {
  color: #98a2b3;
}

.prompt-form__footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;

  padding: 4px;
}

.prompt-form__actions {
  display: flex;
  gap: 8px;
}

.character-count {
  color: var(--muted);
  font-size: 0.7rem;
}

.button {
  min-height: 42px;
  padding: 8px 15px;

  border-radius: 11px;
  cursor: pointer;

  transition:
    opacity 150ms ease,
    background-color 150ms ease,
    transform 150ms ease;
}

.button:hover:not(:disabled) {
  transform: translateY(-1px);
}

.button:disabled {
  opacity: 0.48;
  cursor: not-allowed;
}

.button--primary {
  color: #ffffff;
  background-color: var(--primary);
}

.button--primary:hover:not(:disabled) {
  background-color: var(--primary-dark);
}

.button--secondary,
.button--ghost {
  color: var(--text);
  background-color: #eef0f5;
}

.form-error,
.request-error {
  color: var(--error);
  font-size: 0.78rem;
}

.form-error {
  margin: 7px 5px 2px;
}

.request-error {
  margin: 10px 24px 0;
  padding: 10px 12px;

  background-color: #fef3f2;
  border: 1px solid #fecdca;
  border-radius: var(--radius-sm);
}

.disclosure {
  margin: 0;
  padding: 4px 20px 14px;

  color: var(--muted);
  font-size: 0.7rem;
  text-align: center;
}

.sr-only {
  position: absolute;

  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;

  overflow: hidden;
  clip: rect(0, 0, 0, 0);

  white-space: nowrap;
  border: 0;
}

@media (max-width: 680px) {
  .page-shell {
    padding: 0;
  }

  .chat-app {
    min-height: 100dvh;
    border: 0;
    border-radius: 0;
  }

  .app-header {
    align-items: flex-start;
    padding: 18px;
  }

  .app-header__actions {
    flex-direction: column;
    align-items: flex-end;
  }

  .message-list {
    padding: 20px 14px;
  }

  .message__body {
    max-width: calc(100% - 46px);
  }

  .prompt-form {
    margin-inline: 12px;
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    scroll-behavior: auto !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

ساخت Backend امن

کلید API نباید در کد React یا متغیرهای قابل‌ارسال Vite قرار گیرد. هر مقداری که وارد Bundle مرورگر شود برای کاربر قابل‌مشاهده خواهد بود.

پوشه Backend را بسازید:

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

فایل backend/package.json:

{
  "name": "react-ai-assistant-backend",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "dependencies": {
    "dotenv": "^17.0.0",
    "express": "^5.0.0"
  }
}

نسخه دقیق وابستگی‌ها را همان نسخه‌ای در نظر بگیرید که npm هنگام نصب در package.json و Lock File ثبت می‌کند.

فایل backend/server.js:

import "dotenv/config";

import express from "express";

const app = express();

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

const apiKey =
  process.env.DARVAREH_API_KEY;

const modelId =
  process.env.DARVAREH_MODEL_ID;

const chatUrl =
  "https://api.darvareh.ir/v1/chat/completions";

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

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

function isValidMessage(message) {
  return (
    message !== null &&
    typeof message === "object" &&
    ["user", "assistant"].includes(message.role) &&
    typeof message.content === "string" &&
    message.content.trim().length > 0 &&
    message.content.length <= 10000
  );
}

function validateMessages(messages) {
  return (
    Array.isArray(messages) &&
    messages.length > 0 &&
    messages.length <= 20 &&
    messages.every(isValidMessage)
  );
}

function extractAnswer(data) {
  const answer =
    data?.choices?.[0]?.message?.content;

  if (
    typeof answer !== "string" ||
    !answer.trim()
  ) {
    throw new Error(
      "Invalid model response"
    );
  }

  return answer;
}

app.post("/api/chat", async (request, response) => {
  if (!apiKey || !modelId) {
    return response.status(500).json({
      error:
        "تنظیمات API در سرور کامل نیست."
    });
  }

  const { messages } = request.body ?? {};

  if (!validateMessages(messages)) {
    return response.status(400).json({
      error:
        "ساختار پیام‌ها معتبر نیست."
    });
  }

  const controller =
    new AbortController();

  const timeoutId = setTimeout(() => {
    controller.abort();
  }, 60000);

  try {
    const upstreamResponse = await fetch(
      chatUrl,
      {
        method: "POST",

        headers: {
          "Authorization":
            `Bearer ${apiKey}`,

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

        body: JSON.stringify({
          model: modelId,

          messages: [
            {
              role: "system",

              content:
                "شما یک دستیار فارسی، دقیق و مفید هستید. " +
                "پاسخ را روشن، کاربردی و ساختاریافته ارائه کنید."
            },

            ...messages
          ],

          temperature: 0.4,
          max_tokens: 1200
        }),

        signal: controller.signal
      }
    );

    const rawBody =
      await upstreamResponse.text();

    let data;

    try {
      data = JSON.parse(rawBody);
    } catch {
      return response.status(502).json({
        error:
          "پاسخ سرویس مدل قابل پردازش نبود."
      });
    }

    if (!upstreamResponse.ok) {
      console.error(
        "Upstream request failed:",
        upstreamResponse.status
      );

      return response.status(502).json({
        error:
          "سرویس مدل پاسخ موفقی ارسال نکرد."
      });
    }

    return response.json({
      answer: extractAnswer(data)
    });
  } catch (error) {
    if (error.name === "AbortError") {
      return response.status(504).json({
        error:
          "زمان انتظار برای پاسخ به پایان رسید."
      });
    }

    console.error(
      "Chat request failed:",
      error.message
    );

    return response.status(502).json({
      error:
        "ارتباط با سرویس مدل برقرار نشد."
    });
  } finally {
    clearTimeout(timeoutId);
  }
});

app.listen(port, () => {
  console.log(
    `Backend: http://localhost:${port}`
  );
});

تنظیم متغیرهای محیطی Backend

فایل backend/.env:

PORT=3001
DARVAREH_API_KEY=YOUR_DARVAREH_API_KEY
DARVAREH_MODEL_ID=YOUR_MODEL_ID

برای دریافت API Key می‌توانید در درواره ثبت‌نام کنید.

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

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

node_modules/
dist/
.env
backend/.env

اجرای کامل پروژه

Terminal اول برای Backend:

cd backend
npm run dev

Terminal دوم برای Frontend:

npm run dev

Frontend معمولاً روی آدرس اعلام‌شده توسط Vite اجرا می‌شود و درخواست /api/chat از طریق Proxy به Backend می‌رسد.

چرا API Key را در VITE_ قرار نمی‌دهیم؟

در Vite، متغیرهایی که برای Frontend در دسترس قرار می‌گیرند معمولاً با پیشوند VITE_ تعریف می‌شوند:

VITE_PUBLIC_API_URL=/api

این متغیرها در Bundle مرورگر قابل‌مشاهده‌اند. بنابراین این کار مناسب نیست:

VITE_DARVAREH_API_KEY=YOUR_DARVAREH_API_KEY

پیشوند VITE_ یک Secret Manager نیست. کلید باید فقط در Backend قرار گیرد:

DARVAREH_API_KEY=YOUR_DARVAREH_API_KEY

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

React Frontend
    ↓ بدون Secret
Backend
    ↓ همراه API Key
API درواره

StrictMode و اجرای دوباره Effect

در حالت Development ممکن است React Strict Mode بعضی از فرایندها را بیش از یک‌بار اجرا کند تا مشکلات Cleanup و Side Effectها مشخص شوند.

اگر یک Effect در Development دو بار اجرا می‌شود، فوراً StrictMode را حذف نکنید. ابتدا بررسی کنید:

  • آیا Effect دارای Cleanup صحیح است؟
  • آیا درخواست شبکه باید در Effect انجام شود؟
  • آیا عملیات Idempotent است؟
  • آیا State مشتق‌شده را بی‌دلیل در Effect تنظیم کرده‌اید؟
  • آیا اتصال خارجی پس از Unmount قطع می‌شود؟

در نسخه Production رفتار Development Strict Mode به همان شکل تکرار نمی‌شود، اما کد باید همچنان Cleanup صحیح داشته باشد.

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

تغییر مستقیم State

اشتباه:

messages.push(newMessage);
setMessages(messages);

صحیح:

setMessages((messages) => [
  ...messages,
  newMessage
]);

استفاده نادرست از Event Handler

اشتباه:

<button onClick={clearChat()}>
  پاک‌کردن
</button>

صحیح:

<button onClick={clearChat}>
  پاک‌کردن
</button>

استفاده از Index به‌عنوان key

اشتباه در فهرست قابل‌تغییر:

messages.map((message, index) => (
  <MessageItem
    key={index}
    {...message}
  />
));

بهتر:

messages.map((message) => (
  <MessageItem
    key={message.id}
    {...message}
  />
));

نگهداری State مشتق‌شده

غیرضروری:

const [count, setCount] = useState(0);

useEffect(() => {
  setCount(messages.length);
}, [messages]);

ساده‌تر:

const count = messages.length;

استفاده بیش از حد از useEffect

اگر عملیاتی با کلیک کاربر انجام می‌شود، معمولاً آن را در Event Handler قرار دهید:

async function handleSubmit(event) {
  event.preventDefault();
  await sendMessage(prompt);
}

به‌جای اینکه تغییر State را با Effect دنبال و سپس درخواست را ارسال کنید.

تعریف Component داخل Component

این ساختار ممکن است در هر Render نوع Component جدیدی بسازد:

function App() {
  function Message() {
    return <p>پیام</p>;
  }

  return <Message />;
}

بهتر است Component مستقل باشد:

function Message() {
  return <p>پیام</p>;
}

function App() {
  return <Message />;
}

قرار دادن تمام State در Context

Context برای داده‌های مشترک مفید است، اما تغییر مکرر Context می‌تواند Componentهای زیادی را درگیر Render کند. State را تا حد ممکن نزدیک محل مصرف نگه دارید.

ذخیره JSX در State

معمولاً داده را در State نگهداری کنید و JSX را از روی داده بسازید:

const [messages, setMessages] = useState([]);

نه اینکه عناصر React را مستقیماً ذخیره کنید.

قراردادن API Key در React

هیچ Secret واقعی نباید در این موارد قرار گیرد:

  • فایل JSX
  • فایل JavaScript مرورگر
  • متغیر VITE_
  • Local Storage
  • Session Storage
  • فایل عمومی Config
  • Repository عمومی

مدیریت State در پروژه بزرگ

برای پروژه کوچک، useState و useReducer کافی هستند.

با بزرگ‌شدن برنامه ممکن است به ابزارهای دیگری نیاز داشته باشید:

نیازگزینه
State محلیuseState
Workflow پیچیده محلیuseReducer
داده عمومی محدودContext
داده Server و Cacheکتابخانه Query
State سراسری پیچیدهکتابخانه State Management
State داخل URLRouter و Search Params
فرم بزرگکتابخانه Form

قبل از نصب ابزار جدید مشخص کنید State متعلق به کدام دسته است:

  • State رابط
  • State فرم
  • State سرور
  • State URL
  • State احراز هویت
  • State سراسری محصول

همه این موارد نباید در یک Store واحد قرار گیرند.

بهینه‌سازی Render در React

در بیشتر پروژه‌ها ابتدا کد ساده و صحیح بنویسید و سپس با اندازه‌گیری بهینه‌سازی کنید.

ابزارهای رایج:

  • React.memo
  • useMemo
  • useCallback
  • تقسیم Component
  • نزدیک نگه‌داشتن State به محل مصرف
  • Virtualization برای فهرست بسیار بزرگ
  • Lazy Loading
  • Code Splitting

useMemo و useCallback نباید به‌صورت خودکار در تمام Componentها استفاده شوند. آن‌ها نیز پیچیدگی و هزینه نگهداری دارند.

نمونه محاسبه پرهزینه:

const filteredMessages = useMemo(() => {
  return messages.filter((message) =>
    message.content.includes(searchQuery)
  );
}, [
  messages,
  searchQuery
]);

اگر محاسبه ساده است، Memoization ممکن است ضرورتی نداشته باشد.

Lazy Loading

برای بارگذاری Component سنگین در زمان نیاز:

import {
  lazy,
  Suspense
} from "react";

const SettingsPanel = lazy(() => {
  return import("./SettingsPanel.jsx");
});

function App() {
  return (
    <Suspense fallback={<p>در حال بارگذاری...</p>}>
      <SettingsPanel />
    </Suspense>
  );
}

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

تست Component

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

نمونه مفهومی:

import {
  render,
  screen
} from "@testing-library/react";

import userEvent from "@testing-library/user-event";

import {
  PromptForm
} from "./PromptForm.jsx";

test("sends a valid prompt", async () => {
  const user = userEvent.setup();
  const onSend = vi.fn();

  render(
    <PromptForm
      isLoading={false}
      onSend={onSend}
      onCancel={() => {}}
    />
  );

  await user.type(
    screen.getByLabelText("پیام شما"),
    "React چیست؟"
  );

  await user.click(
    screen.getByRole("button", {
      name: "ارسال پیام"
    })
  );

  expect(onSend).toHaveBeenCalledWith(
    "React چیست؟"
  );
});

بهتر است به‌جای جزئیات داخلی Component، رفتار قابل‌مشاهده کاربر را تست کنید:

  • آیا فرم ارسال می‌شود؟
  • آیا پیام خطا نمایش داده می‌شود؟
  • آیا دکمه هنگام Loading غیرفعال است؟
  • آیا پیام جدید روی صفحه دیده می‌شود؟
  • آیا امکان لغو درخواست وجود دارد؟

Build پروژه

برای ساخت نسخه Production:

npm run build

خروجی معمولاً داخل پوشه dist قرار می‌گیرد:

dist/
├── assets/
└── index.html

بررسی محلی Build:

npm run preview

preview جای سرور Production را نمی‌گیرد و برای بررسی خروجی Build استفاده می‌شود.

در استقرار نهایی باید مشخص کنید:

  • فایل‌های Frontend کجا سرو می‌شوند؟
  • Backend کجا اجرا می‌شود؟
  • مسیر /api چگونه Route می‌شود؟
  • HTTPS چگونه فعال می‌شود؟
  • Secretها کجا نگهداری می‌شوند؟
  • Log و Monitoring چگونه انجام می‌شوند؟
  • محدودیت درخواست چگونه اعمال می‌شود؟

چک‌لیست React برای پروژه واقعی

  • Componentها مسئولیت مشخص دارند.
  • Props فقط خوانده می‌شوند.
  • State مستقیماً تغییر نمی‌کند.
  • فهرست‌ها key پایدار دارند.
  • State مشتق‌شده بی‌دلیل ذخیره نشده است.
  • Effect فقط برای هماهنگی با سیستم خارجی استفاده می‌شود.
  • Effectهای لازم Cleanup دارند.
  • Hook داخل شرط یا حلقه فراخوانی نمی‌شود.
  • فرم ورودی Controlled و اعتبارسنجی شده است.
  • Loading، Error و Empty State وجود دارد.
  • درخواست قابل لغو است.
  • پاسخ HTTP با response.ok بررسی می‌شود.
  • محتوای خارجی بدون بررسی با innerHTML نمایش داده نمی‌شود.
  • API Key در Frontend وجود ندارد.
  • متغیرهای VITE_ حاوی Secret نیستند.
  • تاریخچه مکالمه محدود شده است.
  • Componentهای اصلی تست رفتاری دارند.
  • رابط در موبایل و Keyboard قابل‌استفاده است.
  • Build نهایی قبل از انتشار بررسی شده است.
  • Model ID از تنظیمات Backend خوانده می‌شود.
  • قیمت مدل در کد Hardcode نشده است.

نقشه راه یادگیری React

مرحله اول: پایه JavaScript

  • Function
  • Array و Object
  • map
  • Destructuring
  • Module
  • Promise
  • Async/Await

مرحله دوم: مبانی React

  • Component
  • JSX
  • Props
  • Event
  • Conditional Rendering
  • List و Key

مرحله سوم: State

  • useState
  • Controlled Form
  • Lifting State Up
  • تغییر Immutable Array و Object

مرحله چهارم: Hookها

  • useEffect
  • useRef
  • useReducer
  • useContext
  • Custom Hook

مرحله پنجم: ارتباط با Backend

  • Fetch API
  • Loading
  • Error
  • AbortController
  • Validation
  • Authentication

مرحله ششم: معماری

  • Service Layer
  • Component Composition
  • State Ownership
  • Routing
  • تست
  • TypeScript

مرحله هفتم: توسعه حرفه‌ای

  • Server State
  • Code Splitting
  • Performance Profiling
  • Accessibility
  • Frameworkهایی مانند Next.js
  • Deployment و Monitoring

سؤال‌های متداول

React چیست؟

React یک کتابخانه JavaScript برای ساخت رابط کاربری مبتنی بر Component است. با React می‌توان UI را براساس Props و State تعریف و مدیریت کرد.

آیا React زبان برنامه‌نویسی است؟

خیر. React کتابخانه JavaScript است. برای استفاده درست از آن باید JavaScript را یاد بگیرید.

JSX چیست؟

JSX Syntax شبیه HTML در داخل JavaScript است که برای تعریف رابط React استفاده می‌شود و در فرایند Build به JavaScript تبدیل می‌شود.

Component در React چیست؟

Component تابع یا واحد مستقلی از رابط کاربری است که می‌تواند Props دریافت کند و JSX برگرداند.

تفاوت Props و State چیست؟

Props از والد دریافت می‌شوند و فقط خواندنی هستند. State داخل Component یا Hook مدیریت می‌شود و با تغییر آن رابط دوباره Render می‌شود.

useState چیست؟

useState یک Hook برای اضافه‌کردن State به Function Component است و مقدار فعلی همراه با تابع به‌روزرسانی را برمی‌گرداند.

useEffect چیست؟

useEffect برای هماهنگ‌کردن Component با سیستم‌های خارجی مانند درخواست شبکه، Timer، Event Listener و اتصال‌های خارجی استفاده می‌شود.

آیا هر درخواست API باید داخل useEffect باشد؟

خیر. اگر درخواست در نتیجه عمل کاربر مانند ارسال فرم ایجاد می‌شود، معمولاً Event Handler محل مناسب‌تری است. Effect بیشتر برای همگام‌سازی براساس حضور یا تغییر Component استفاده می‌شود.

چرا State را نباید مستقیم تغییر دهیم؟

تغییر مستقیم Array یا Object می‌تواند تشخیص تغییر، Render و منطق Snapshot State را مختل کند. بهتر است مقدار جدید بسازید.

چرا key در فهرست React مهم است؟

Key به React کمک می‌کند هویت آیتم‌ها را میان Renderها تشخیص دهد و تغییر، حذف یا جابه‌جایی آن‌ها را درست مدیریت کند.

React بهتر است یا Vue؟

هر دو برای ساخت رابط مدرن مناسب‌اند. انتخاب به تجربه تیم، اکوسیستم، نیاز پروژه و معماری موردنظر بستگی دارد. بهتر بودن مطلقی برای تمام پروژه‌ها وجود ندارد.

React بهتر است یا Angular؟

React کتابخانه‌ای انعطاف‌پذیر برای UI است. Angular فریم‌ورکی جامع‌تر با ساختار و ابزارهای داخلی بیشتر است. اندازه پروژه و ترجیح تیم در انتخاب مؤثر است.

React بهتر است یا Next.js؟

Next.js از React استفاده می‌کند و امکاناتی مانند Routing و Rendering سمت سرور ارائه می‌دهد. برای یادگیری اصول رابط، React با Vite ساده‌تر است؛ برای محصول کامل وب ممکن است Next.js مناسب‌تر باشد.

آیا React برای سئو مناسب است؟

React می‌تواند رابط قابل‌خزش ایجاد کند، اما Rendering صرفاً Client-side ممکن است برای بعضی صفحات محتوایی ایده‌آل نباشد. برای صفحات عمومی و سئومحور می‌توان از Rendering سمت سرور، تولید Static یا فریم‌ورک مناسب استفاده کرد.

آیا می‌توان با React اپلیکیشن هوش مصنوعی ساخت؟

بله. React برای ساخت رابط چت، داشبورد، ابزار تولید محتوا و سایر محصولات هوش مصنوعی مناسب است. اتصال به مدل باید از طریق Backend امن انجام شود.

آیا می‌توان API Key درواره را در React قرار داد؟

خیر. کد React در مرورگر اجرا می‌شود و برای کاربر قابل‌مشاهده است. API Key باید فقط در Backend یا Secret Manager ذخیره شود.

Vite چیست؟

Vite ابزار توسعه و Build پروژه‌های وب مدرن است و Template آماده‌ای برای ایجاد پروژه React ارائه می‌کند.

آیا برای React باید Node.js نصب کنیم؟

برای اجرای ابزارهای توسعه، نصب Packageها و Build پروژه معمولاً به Node.js نیاز دارید. کد نهایی React در مرورگر اجرا می‌شود.

آیا React بدون TypeScript قابل‌استفاده است؟

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

جمع‌بندی

React رابط کاربری را به مجموعه‌ای از Componentهای کوچک و قابل‌ترکیب تبدیل می‌کند. هر Component می‌تواند Props دریافت کند، State داشته باشد و براساس داده، رابط مناسب را نمایش دهد.

مهم‌ترین مفاهیم React عبارت‌اند از:

  • Component
  • JSX
  • Props
  • State
  • Event
  • Conditional Rendering
  • List و Key
  • useState
  • useEffect
  • useRef
  • useReducer
  • Custom Hook
  • جریان یک‌طرفه داده

در پروژه عملی این مقاله، یک دستیار هوش مصنوعی واقعی با React و Vite ساختیم. State گفت‌وگو با Reducer مدیریت شد، منطق شبکه داخل Service قرار گرفت، Custom Hook مسئول Workflow گفت‌وگو شد و Componentهای جداگانه رابط را نمایش دادند.

برای محافظت از کلید API، درخواست مدل مستقیماً از React ارسال نشد. Frontend فقط با Backend برنامه ارتباط برقرار می‌کند و Backend با استفاده از متغیر محیطی به API درواره متصل می‌شود.

برای دریافت API Key و شروع ساخت محصول هوش مصنوعی خود می‌توانید در درواره ثبت‌نام کنید. برای مشاهده مدل‌ها، انتخاب Model ID مناسب و بررسی قیمت‌های به‌روز نیز صفحه مدل‌های درواره را ببینید.

منابع تکمیلی

مقالات مرتبط

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

Read more

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

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

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

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

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

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