React Router مدت‌هاست که به عنوان یک راه‌حل محبوب برای مسیریابی در برنامه‌های تک‌صفحه‌ای (SPA) شناخته می‌شود. تیم توسعه‌دهنده Remix با بهبودهای پیوسته در این کتابخانه، React Router و Remix را به هم نزدیک‌تر کرده‌اند که در نهایت منجر به ادغام آن‌ها در ۷ React Router شده است. با این نسخه‌ی جدید، SSR در ۷ React Router نیز امکان‌پذیر شده و این کتابخانه می‌تواند به عنوان یک کتابخانه مسیریابی یا یک فریم‌ورک کامل استفاده شود که تمام قابلیت‌های Remix را دربرمی‌گیرد. همچنین، این نسخه شامل نسخه ۱۹ React به عنوان وابستگی نیز می‌باشد.

در این مقاله، نحوه ساخت یک برنامه رندرینگ سمت سرور (SSR) با React Router 7 را با ساخت یک اپلیکیشن book tracking بررسی خواهیم کرد. در این مسیر، از ابزارهایی مانند Radix Primitives، React Icons و Tailwind CSS استفاده می‌کنیم. داشتن دانش قبلی در مورد React.js، تایپ اسکریپت و مفاهیم پایه‌ای دریافت داده مانند actionها و loaderها مفید است، اما ضروری نیست. کد نهایی پروژه در این لینک قابل دسترس می‌باشد.

نحوه‌ی راه‌اندازی فریم‌ورک React Router

حداقل نسخه‌ مورد نیاز برای اجرای React Router، نسخه ۲۰ Node.js است. بنابراین، باید مطمئن شویم که دستگاه ما از این نسخه یا بالاتر استفاده می‌کند:

node --version

سپس، فریم‌ورک React Router را با اجرای دستور زیر نصب می‌کنیم:

npx create-react-router@latest react-router-ssr

در اینجا، نام پروژه نمونه ما react-router-ssr است. پس از اجرای دستور فوق، CLI از ما می‌پرسد که آیا می‌خواهیم یک git repo برای پروژه ایجاد کنیم یا خیر. در صورت تمایل، گزینه «yes» را انتخاب می‌کنیم. همچنین، از ما می‌پرسد که آیا می‌خواهیم وابستگی‌ها را با استفاده از npm نصب نماییم یا نه. پس از انتخاب گزینه‌های مورد نظر، پوشه‌ای با نام پروژه ما ایجاد می‌شود. به دایرکتوری پروژه می‌رویم و سرور توسعه برنامه را اجرا می‌کنیم:

cd react-router-ssr
npm run dev

سپس، مرورگر خود را باز کرده و به آدرس http://localhost:5173 می‌رویم. باید صفحه اصلی برنامه را مشاهده نماییم.

از آنجایی که این آموزش شامل استقرار برنامه با Docker نیست، می‌توانیم تمام فایل‌های مرتبط با Docker را برای داشتن یک کدبیس تمیزتر حذف کنیم. این فایل‌ها شامل ..dockerignore، Dockerfile، Dockerfile.bun و Dockerfile.pnpm هستند که در قالب پروژه برای مواردی که نیاز به استقرار با Docker است، گنجانده شده‌اند.

نحوه ساخت صفحات SSR

به منظور استفاده از ۷ React Router برای SSR، باید مطمئن شویم که گزینه ssr در فایل پیکربندی React Router روی true تنظیم شده باشد. به طور پیش‌فرض، این مقدار روی true قرار دارد. فایل react-router.config.ts را در ویرایشگر کد خود باز کرده و بررسی می‌کنیم:

//router.config.ts

import type { Config } from '@react-router/dev/config';

export default {
  // Config options...
  // Server-side render by default, to enable SPA mode set this to `false`
  ssr: true,
} satisfies Config;

این آموزش از تم «light mode» برای برنامه استفاده می‌کند، بنابراین باید dark mode را در Tailwind CSS غیرفعال نماییم. فایل app/app.css را باز کرده و تمام استایل‌های مربوط به «dark mode» را غیرفعال می‌کنیم:

// routes/app.css

@tailwind base;
@tailwind components;
@tailwind utilities;

html,
body {
  /* @apply bg-white dark:bg-gray-950; */
  @media (prefers-color-scheme: dark) {
    /* color-scheme: dark; */
  }
}

سپس، اولین صفحه SSR خود را می‌سازیم. تمام routeها را در پوشه app/routes/ تعریف می‌کنیم، اما home.tsx به عنوان صفحه اصلی عمل خواهد کرد. همچنین، مسیرهای دیگری وجود خواهند داشت که از آن به عنوان layout استفاده می‌کنند. فایل app/routes/home.tsx را ایجاد می‌کنیم.

در فایل app/routes/home.tsx، کامپوننت <Home /> را export می‌کنیم که شامل موارد زیر می‌باشد:

// app/routes/home.tsx

import { Outlet } from 'react-router';
import { Fragment } from 'react/jsx-runtime';
import Header from '~/components/Header';
import Footer from '~/components/Footer';

export default function Home() {
  return (
    <Fragment>
      <Header />
      <main className='max-w-screen-lg mx-auto my-4'>
        <Outlet />
      </main>
      <Footer />
    </Fragment>
  );
}

این فایل دو کامپوننت React (<Header /> و <Footer />) را که در ادامه ایجاد خواهیم کرد، و کامپوننت <Outlet /> از React Router را import می‌کند. <Outlet /> کامپوننت‌های هر مسیر child را که از home.tsx به عنوان layout استفاده می‌کند، رندر می‌نماید.

برای نمایش چیزی در صفحه، باید کامپوننت‌های سفارشی import شده را ایجاد کنیم. با اصلاح پوشه app/welcome که همراه با layout است، شروع می‌کنیم:

سپس، در پوشه app/components، دو فایل جدید به نام‌های Header.tsx و Footer.tsx ایجاد می‌کنیم.

کامپوننت <Header /> یک <header> را نمایش می‌دهد که برای بیشتر بخش‌های برنامه ثابت است. کد آن به صورت زیر می‌باشد:

// app/components/Header.tsx

import { Link } from 'react-router';
import BookForm from './BookForm';

export default function Header() {
  return (
    <header className='flex justify-between items-center px-8 py-4'>
      <h1 className='text-3xl font-medium'>
        <Link to='/'>Book Tracker App</Link>
      </h1>
      <BookForm />
    </header>
  );
}

فایل Header.tsx کامپوننت <Link /> را از React Router ایمپورت کرده است که در واقع یک تگ <a> بهینه شده برای این فریم‌ورک می‌باشد. همچنین، کامپوننت <BookForm /> را که هنوز وجود ندارد، import کرده‌ایم. در نهایت، این فایل از استایل‌های Tailwind CSS استفاده می‌کند تا المنت‌های HTML در صفحه ظاهر مناسبی داشته باشند.

ایجاد کامپوننت <BookForm />

اکنون باید کامپوننت <BookForm /> را ایجاد کنیم. اما قبل از آن، لازم است که کامپوننت headless dialog از Radix را نصب کنیم. از این کامپوننت برای ساخت یک فرم دیالوگ جهت اضافه کردن کتاب جدید به سیستم ردیابی استفاده خواهیم کرد. همچنین، React Icons را نصب می‌کنیم، چون در برخی از کامپوننت‌های آینده به آن نیاز داریم.

npm install @radix-ui/react-dialog react-icons

بعد از نصب این پکیج‌ها، یک فایل جدید در مسیر app/components با نام BookForm.tsx می‌سازیم و کد زیر را در آن قرار می‌دهیم:

// app/components/BookForm.tsx

import { useState } from 'react';
import { Form } from 'react-router';
import * as Dialog from '@radix-ui/react-dialog';
import Button from './Button';

export default function BookForm() {
  const [isOpen, setIsOpen] = useState<boolean>(false);

  return (
    <Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
      <Dialog.Trigger asChild>
        <Button>Add Book</Button>
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className='bg-black/50 fixed inset-0' />
        <Dialog.Content className='bg-white fixed top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 px-8 py-4 w-5/6 max-w-sm'>
          <Dialog.Title className='font-medium text-xl py-2'>
            Add New Book
          </Dialog.Title>
          <Dialog.Description>Start tracking a new book</Dialog.Description>
          <Form
            method='post'
            onSubmit={() => setIsOpen(false)}
            action='/?index'
            className='mt-2'
          >
            <div>
              <label htmlFor='title'>Book Title</label>
              <br />
              <input
                name='title'
                type='text'
                className='border border-black'
                id='title'
                required
              />
            </div>
            <div>
              <label htmlFor='author'>Author</label>
              <br />
              <input
                name='author'
                type='text'
                id='author'
                className='border border-black'
                required
              />
            </div>
            <div>
              <label htmlFor='isbn'>ISBN (Optional)</label>
              <br />
              <input
                name='isbn'
                type='text'
                id='isbn'
                className='border border-black'
              />
            </div>
            <div className='mt-4 text-right'>
              <Dialog.Close asChild>
                <Button variant='cancel'>Cancel</Button>
              </Dialog.Close>
              <Button type='submit'>Save</Button>
            </div>
          </Form>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

کامپوننت BookForm.tsx از هوک useState برای کنترل نمایش و بسته شدن دیالوگ استفاده می‌کند. همچنین، از Tailwind CSS برای استایل‌دهی و از React Icons برای نمایش آیکون (+) بهره می‌برد.

ایجاد کامپوننت <Button />

در بخش قبل در کد BookForm.tsx، ما یک کامپوننت <Button /> را import کرده‌ایم، اما هنوز آن را نساخته‌ایم. اکنون باید این کامپوننت را در مسیر app/components/Button.tsx بسازیم.

کد کامپوننت <Button /> به صورت زیر می‌باشد:

// app/components/Button.tsx

import type { ComponentProps, ReactNode } from 'react';

interface Props extends ComponentProps<'button'> {
  children?: ReactNode;
  variant?: 'cancel' | 'delete' | 'normal';
}

export default function Button({
  children,
  variant = 'normal',
  ...otherProps
}: Props) {
  const variantStyles: Record<NonNullable<typeof variant>, string> = {
    cancel: 'text-red-700',
    normal: 'text-white bg-purple-700 hover:bg-purple-800',
    delete: 'text-white bg-red-700 hover:bg-red-800',
  };
  return (
    <button
      className={`rounded-full px-4 py-2 text-center text-sm ${variantStyles[variant]}`}
      {...otherProps}
    >
      {children}
    </button>
  );
}

کامپوننت <Button /> یک دکمه با قابلیت استفاده مجدد را فراهم می‌کند که دارای سه نوع مختلف (normal، cancel و delete) است. هر کدام از این حالت‌ها دارای استایل خاص خود هستند.

ایجاد کامپوننت <Footer />

در نهایت، برای تکمیل ساختار اولیه برنامه، باید کامپوننت <Footer /> را برای مسیر home.tsx ایجاد کنیم.

کد کامپوننت <Footer /> به شکل زیر می‌باشد:

// app/components/Footer.tsx

import { Link } from 'react-router';

export default function Footer() {
  return (
    <footer className='text-center my-5'>
      <Link to='/about' className='text-purple-700'>
        About the App
      </Link>
    </footer>
  );
}

این کامپوننت یک footer ساده ایجاد می‌کند که شامل متن کپی‌رایت می‌باشد.

تولید سایت استاتیک (SSG) در نسخه ۷ React Router

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

  1. تولید سایت داینامیک (Dynamic Site Generation): در این روش، سرور برای هر درخواست جدید، صفحه را به صورت داینامیک تولید می‌کند.
  2. تولید سایت استاتیک (Static Site Generation – SSG): در این روش، صفحات از قبل ایجاد شده و روی سرور ذخیره می‌شوند. برای صفحات SSG، محتوای صفحه ثابت است و بدون توجه به اینکه چه کسی درخواست را ارسال کرده، یکسان باقی می‌ماند.

تفاوت SSR و SSG

در SSR داینامیک، سرور هنگام درخواست، صفحه را ایجاد کرده و سپس HTML را به سمت کلاینت (مرورگر) ارسال می‌کند تا در آنجا hydrate شود.
اما در SSG، تمامی فایل‌های موردنیاز یک صفحه (HTML، CSS، JavaScript) در زمان build ایجاد می‌شوند. به همین دلیل، هنگام درخواست، سریع‌تر به کلاینت ارسال می‌گردند، زیرا نیازی به پردازش اضافی روی سرور ندارند.

کدام روش بهتر است؟

هر دو روش مزایا و معایب خاص خود را دارند.
SSG مناسب است اگر:

SSR داینامیک مناسب است اگر:

به عنوان مثال، در این پروژه‌ای که داریم مسیر /about را به صورت SSG ایجاد می‌کنیم.
React Router 7 این امکان را به توسعه‌دهندگان می‌دهد که از هر دو روش (SSR و SSG) در یک پروژه استفاده کنند.

برای این کار، باید پیکربندی React Router را ویرایش کرده و مشخص کنیم که مسیر /about باید به صورت استاتیک (pre-rendered) ایجاد شود. در این صورت، برنامه فقط مسیر (یا صفحه) /about را از قبل رندر می‌کند:

// react-router.config.ts

import type { Config } from '@react-router/dev/config';

export default {
  // Config options...
  // Server-side render by default, to enable SPA mode set this to `false`
  ssr: true,
  async prerender() {
    return ['about'];
  },
} satisfies Config;

ایجاد صفحه About

در مسیر app/routes/about.tsx فایل جدید را ایجاد می‌کنیم. این فایل حاوی محتوای استاتیک مربوط به صفحه «درباره ما» خواهد بود:

// app/routes/about.tsx

import { Fragment } from 'react/jsx-runtime';
import { Link } from 'react-router';

export default function About() {
  return (
    <Fragment>
      <h1 className='px-8 py-4 text-3xl font-medium'>
        <Link to='/'>Book Tracker App</Link>
      </h1>
      <main className='max-w-screen-lg mx-auto my-4'>
        <p className='mb-2 mx-5'>
          This app was built for readers who love the simplicity of tracking
          what they’ve read and what they want to read next. With just the
          essentials, it’s designed to keep your reading list organized without
          the distractions of unnecessary features.
        </p>
        <p className='mb-2 mx-5'>
          We believe the joy of reading should stay front and center. Whether
          it’s noting down the books you’ve finished or keeping a simple list of
          what’s next, this app focuses on helping you stay connected to your
          reading journey in the most straightforward way possible.
        </p>
        <p className='mb-2 mx-5'>
          Sometimes less is more, and that’s the philosophy behind this app. By
          keeping things minimal, it offers a clean and easy way to manage your
          reading habits so you can spend less time tracking and more time
          diving into your next great book.
        </p>
      </main>
    </Fragment>
  );
}

مسیریابی با React Router

در این بخش، نحوه پیکربندی مسیرها در فریم‌ورک React Router را بررسی خواهیم کرد.

قبل از اینکه بتوانیم صفحه About را در مرورگر مشاهده کنیم، باید React Router را تنظیم نماییم تا هنگام پیمایش به مسیر /about، ماژول مسیر مربوطه (about.tsx) را نمایش دهد.

این پیکربندی در فایل app/routes.ts انجام می‌شود، جایی که تمام سلسله‌مراتب مسیرهای برنامه تعریف شده است:

// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [
  index('routes/home.tsx'),
  route('about', 'routes/about.tsx'),
] satisfies RouteConfig;

برای انجام این کار، تابع route را از React Router ایمپورت کرده و مسیر /about را به آن معرفی می‌کنیم.

  1. آرگومان اول route، آدرس URL مورد نظر برای مطابقت است.
  2. آرگومان دوم، ماژول مسیری است که هنگام مطابقت URL نمایش داده می‌شود.

با انجام این پیکربندی، اکنون می‌توانیم به صفحه استاتیک About هدایت شویم.

ایجاد Build پروژه

برای build کردن پروژه و تولید صفحه About به صورت استاتیک، این دستور را در ترمینال اجرا می‌کنیم:

npm run build

پس از اجرای این دستور، یک فولدر جدید به نام build/ ایجاد می‌شود که شامل صفحه About به صورت استاتیک خواهد بود.

اما مسیرهای /home و /about تنها مسیرهای این پروژه نیستند؛ بنابراین، باید تمام مسیرهای اپلیکیشن را تنظیم کنیم:

// app/routes.ts
import {
  type RouteConfig,
  index,
  route,
  layout,
} from '@react-router/dev/routes';

export default [
  layout('routes/home.tsx', [
    index('routes/book-list.tsx'),
    route('book/:bookId', 'routes/book.tsx'),
  ]),
  route('about', 'routes/about.tsx'),
] satisfies RouteConfig;

همان‌طور که در پیکربندی مسیرها مشاهده می‌کنیم، از یک تابع layout استفاده شده است که دو آرگومان دارد:

  1. مکان ماژول مسیر template
  2. یک آرایه از مسیرهای تودرتو

وقتی کاربر به یکی از مسیرهای تودرتو هدایت شود:

  1. ابتدا مسیر layout parent نمایش داده می‌شود.
  2. سپس React Router از کامپوننت <Outlet /> استفاده می‌کند تا محتوای مربوط به مسیر خاصی که کاربر به آن هدایت شده را نمایش دهد.

دریافت و بارگذاری داده‌ها در مسیرهای SSR

توابع loader مفهومی منحصربه‌فرد در React Router هستند. این توابع از ماژول‌های مسیر export می‌شوند و داده‌های لازم برای رندر شدن یک مسیر را return می‌کنند. این توابع فقط باید در ماژول‌های مسیر استفاده شوند و در جای دیگری نباید مورد استفاده قرار بگیرند.

در این اپلیکیشن، مسیری ایجاد می‌کنیم که تمام کتاب‌های دنبال‌شده توسط کاربر را نمایش دهد. این ماژول مسیر جدید از loaderها برای بارگذاری داده‌ها در زمان موردنیاز استفاده می‌کند (در این مثال، منظور ما داده‌های مربوط به کتاب‌های ذخیره شده می‌باشد). SSR در React Router 7 نیز از همین loaderها برای دریافت داده‌ها در سمت سرور استفاده می‌کند تا صفحات سریع‌تر و بهینه‌تر رندر شوند. برای این کار، ابتدا یک راه‌حل ذخیره‌سازی داده ایجاد می‌کنیم که در این مثال، یک آرایه جاوااسکریپت ساده برای نمایش داده‌ها است.

فایل app/model.ts را می‌سازیم:

// app/model.ts

interface Book {
  id: number;
  title: string;
  author: string;
  isFinished: boolean;
  isbn?: string;
  rating?: 1 | 2 | 3 | 4 | 5;
}

interface Data {
  books: Book[];
}

const storage: Data = {
  books: [
    {
      id: 0,
      title: `Numbers Don't Lie: 71 Stories to Help Us Understand the Modern World`,
      author: 'Vaclav Smil',
      isbn: `978-0241454411`,
      isFinished: true,
      rating: 1,
    },
  ],
};
export { type Book, storage };

لیست کتاب‌ها

در مرحله بعد، یک مسیر جدید می‌سازیم تا همه کتاب‌ها را از آبجکت storage نمایش دهد. برای این کار، یک ماژول مسیر به نام book-list.tsx ایجاد می‌کنیم:

// app/routes/book-list.tsx

import type { Route } from './+types/book-list';
import BookCard from '~/components/BookCard';
import { storage } from '~/model';

export async function loader({}: Route.LoaderArgs) {
  return storage;
}

export default function BookList({ loaderData }: Route.ComponentProps) {
  return (
    <div className='mx-5'>
      {loaderData.books
        .slice()
        .reverse()
        .map((book) => (
          <BookCard key={book.id} {...book} />
        ))}
    </div>
  );
}

همانطور که مشاهده می‌کنیم، این ماژول مسیر، یک تابع loader را export می‌کند. سپس کامپوننت اصلی، مسیر داده‌ای که تابع loader باز می‌گرداند را در loaderData دریافت می‌کند. اما قبل از اینکه خروجی این تغییرات را مشاهده کنیم، باید چند کار دیگر نیز انجام دهیم. برای این منظور، کامپوننت import شده BookCard که هنوز وجود ندارد را می‌سازیم:

// app/components/BookCard.tsx

import { Link } from 'react-router';
import { IoCheckmarkCircle } from 'react-icons/io5';
import type { Book } from '~/model';

export default function BookCard({
  id,
  title,
  author,
  isFinished,
  isbn,
  rating,
}: Book) {
  return (
    <Link
      to={`book/${id}`}
      className='block flex px-5 py-4 max-w-lg mb-2.5 border border-black hover:shadow-md'
    >
      <div className='w-12 shrink-0'>
        {isbn ? (
          <img
            className='w-full h-16'
            src={`https://covers.openlibrary.org/b/isbn/${isbn}-S.jpg`}
            alt={`Cover for ${title}`}
          />
        ) : (
          <span className='w-full h-16 block bg-gray-200'></span>
        )}
      </div>
      <div className='flex flex-col ml-4 grow'>
        <span className='font-medium'>{title}</span>
        <span>{author}</span>
        <div className='flex justify-between'>
          <span>Rating: {rating ? `${rating}/5` : 'None'}</span>
          {isFinished && (
            <span className='flex items-center gap-1'>
              Finished <IoCheckmarkCircle className='text-green-600' />
            </span>
          )}
        </div>
      </div>
    </Link>
  );
}

کامپوننت <BookCard /> یک کارت قابل کلیک است. این کارت شامل مهم‌ترین اطلاعات در مورد یک ورودی کتاب مانند عنوان، نویسنده و احتمالاً کاور می‌باشد و اطلاعات دیگری نیز ممکن است در آن گنجانده شده باشد.

پس از آن، فایل app/routes.tsx را باز کرده و مسیر دیگر را کامنت می‌کنیم. این کار باعث می‌شود که React Router خطا ندهد؛ زیرا، هنوز برای آن ماژول مسیری تعریف نکرده‌ایم:

// app/routes.tsx

...
export default [
  layout('routes/home.tsx', [
    index('routes/book-list.tsx'),
    // route('book/:bookId', 'routes/book.tsx'),
  ]),
  route('about', 'routes/about.tsx'),
] satisfies RouteConfig;

با انجام این کارها، ما باید یک صفحه اصلی داشته باشیم که داده‌ها را از storage در فایل app/model.ts می‌خواند. این بدان معنی است که هر کتابی که به storage اضافه می‌شود باید در مسیر book-list.tsx نمایش داده شود.

صفحه جزئیات کتاب

هر زمان که یک کاربر روی کارت کتاب کلیک می‌کند، اپلیکیشن باید به صفحه جدیدی هدایت شود که جزئیات آن کتاب را نمایش دهد. برای تنظیم این ویژگی، ابتدا مسیر مربوط به صفحه /book/:bookId را از حالت کامنت خارج می‌کنیم:

// app/routes.ts

...
export default [
  layout('routes/home.tsx', [
    index('routes/book-list.tsx'),
    route('book/:bookId', 'routes/book.tsx'),
  ]),
  route('about', 'routes/about.tsx'),
] satisfies RouteConfig;

سپس، ماژول مسیر مرتبط را می‌سازیم. فایل آن app/routes/book.tsx خواهد بود و شامل loader است که جزئیات هر کتابی را که کاربر روی آن کلیک کرده است، return می‌کند:

// app/routes/book.tsx

import { useState, type ChangeEvent } from 'react';
import { Link, Form } from 'react-router';
import { IoArrowBackCircle, IoStarOutline, IoStar } from 'react-icons/io5';
import type { Route } from './+types/book';
import Button from '~/components/Button';
import { storage, type Book } from '~/model';

export async function loader({ params }: Route.LoaderArgs) {
  const { bookId } = params;
  const book: Book | undefined = storage.books.find(({ id }) => +bookId === id);
  return book;
}

export default function Book({ loaderData }: Route.ComponentProps) {
  const [isFinished, setIsFinished] = useState<boolean>(
    loaderData?.isFinished || false
  );
  const [rating, setRating] = useState<number>(Number(loaderData?.rating));
  return (
    <div className='mx-5'>
      <Link to='/' className='text-purple-700 flex items-center gap-1 w-fit'>
        <IoArrowBackCircle /> Back to home
      </Link>
      <div className='flex mt-5 max-w-md'>
        <div className='w-48 h-72 shrink-0'>
          {loaderData?.isbn ? (
            <img
              className='w-full h-full'
              src={`https://covers.openlibrary.org/b/isbn/${loaderData.isbn}-L.jpg`}
              alt={`Cover for ${loaderData.title}`}
            />
          ) : (
            <span className='block w-full h-full bg-gray-200'></span>
          )}
        </div>
        <div className='flex flex-col ml-5 grow'>
          <span className='font-medium text-xl'>{loaderData?.title}</span>
          <span>{loaderData?.author}</span>
          <Form method='post'>
            <span className='my-5 block'>
              <input
                type='checkbox'
                name='isFinished'
                id='finished'
                checked={isFinished}
                onChange={(e: ChangeEvent<HTMLInputElement>) =>
                  setIsFinished(e.target.checked)
                }
              />
              <label htmlFor='finished' className='ml-2'>
                Finished
              </label>
            </span>
            <div className='mb-5'>
              <span>Your Rating:</span>
              <span className='text-3xl flex'>
                {[۱, ۲, ۳, ۴, ۵].map((num) => {
                  return (
                    <span key={num} className='flex'>
                      <input
                        className='hidden'
                        type='radio'
                        name='rating'
                        id={`rating-${num}`}
                        value={num}
                        checked={rating === num}
                        onChange={(e: ChangeEvent<HTMLInputElement>) =>
                          setRating(+e.target.value)
                        }
                      />
                      <label htmlFor={`rating-${num}`}>
                        {num <= rating ? <IoStar /> : <IoStarOutline />}
                      </label>
                    </span>
                  );
                })}
              </span>
            </div>
            <div className='text-right'>
              <Button type='submit'>Save</Button>
              <Button variant='delete' type='button'>
                Delete Book
              </Button>
            </div>
          </Form>
        </div>
      </div>
    </div>
  );
}

این فایل چندین عملکرد کلیدی دارد. ابتدا، بعد از importها، یک loader وجود دارد که آبجکت storage را جستجو می‌کند و آبجکت کتاب مربوط به شناسه موجود در پارامترهای URL را بازیابی می‌نماید. به عنوان مثال، اگر کاربر به /book/0 برود، loader جزئیات کتاب با شناسه ۰ را بارگذاری خواهد کرد. علاوه بر این، ماژول مسیر به کاربران اجازه می‌دهد تا جزئیات کتاب را تغییر دهند. کاربران می‌توانند مشخص کنند که آیا کتاب را تمام کرده‌اند یا نه، امتیاز از پنج ستاره بدهند و تغییرات خود را ذخیره کنند. همچنین این امکان را دارند که کتاب را کاملاً حذف نمایند.

در ادامه قصد داریم تا به بررسی افزودن و حذف کتاب از book tracker بپردازیم.

سرور اکشن‌ها در React Router

مشابه loaderها، اکشن‌ها نیز فقط در ماژول‌های route اجرا می‌شوند. این ماژول‌ها همان فایل‌هایی هستند که در دایرکتوری app/routes/ قرار دارند.

اکشن‌ها، توابعی هستند که وظیفه پردازش داده‌های ارسال‌شده از فرم‌ها را در یک مسیر مشخص بر عهده دارند:

ساختار کلی اکشن‌ها

اکشن‌ها پارامترهایی را دریافت می‌کنند که شامل موارد زیر هستند:

  1. پارامترهای URL (به عنوان params)
  2. داده‌های ارسال‌شده به مسیر (به عنوان request)

پارامتر request در اینجا نمونه‌ای از Request Web API است. این بدان معناست که می‌توانیم از تمام قابلیت‌های API در این درخواست استفاده کنیم.
همچنین این پارامترها از Route.ActionArgs گرفته می‌شوند که هر ماژول مسیر، نسخه‌ای منحصربه‌فرد از آن را در .react-router دارد.

اضافه کردن یک کتاب جدید به ذخیره‌سازی با Server Actionها

اولین کاری که در این مقاله با Server Actionها انجام می‌دهیم، افزودن یک کتاب جدید به storage است. برای این کار، تابع action زیر را به ماژول book-list.tsx اضافه می‌کنیم:

// app/routes/book-list.tsx
...
export async function action({ request }: Route.ActionArgs) {
  let formData = await request.formData();
  let title = formData.get('title') as string | null;
  let author = formData.get('author') as string | null;
  let isbn = formData.get('isbn') as string | undefined;
  if (title && author) {
    storage.books.push({
      id: storage.books.length,
      title,
      author,
      isbn: isbn || undefined,
      isFinished: false,
    });
  }

  return storage;
}

...

پس از پیاده‌سازی این تابع، می‌توانیم کتاب‌های جدید را به برنامه اضافه نماییم. بعد از پر کردن فرم، کتاب جدید باید در مسیر book-list.tsx ظاهر شود.

ویرایش و حذف کتاب با Server Actionها

بعد از اضافه کردن کتاب، حالا باید امکان ویرایش و حذف ورودی‌های کتاب را نیز فراهم کنیم.
برای این کار، یک اکشن در مسیر book.tsx اضافه می‌کنیم.

این اکشن:

// app/routes/book.tsx

import { useState, type ChangeEvent } from 'react';
import { Link, Form, redirect, useSubmit } from 'react-router';
import { IoArrowBackCircle, IoStarOutline, IoStar } from 'react-icons/io5';
import type { Route } from './+types/book';
import Button from '~/components/Button';
import { storage, type Book } from '~/model';

export async function action({ params, request }: Route.ActionArgs) {
  let formData = await request.formData();
  let { bookId } = params;
  let newRating = (Number(formData.get('rating')) ||
    undefined) as Book['rating'];
  let isFinished = Boolean(formData.get('isFinished'));
  if (request.method === 'DELETE') {
    storage.books = storage.books.filter(({ id }) => +bookId !== id);
  } else if (newRating && storage.books[+bookId]) {
    Object.assign(storage.books[+bookId], {
      isFinished,
      rating: newRating,
    });
  }
  return redirect('/');
}

export async function loader({ params }: Route.LoaderArgs) {
  const { bookId } = params;
  const book: Book | undefined = storage.books.find(({ id }) => +bookId === id);
  return book;
}

export default function Book({ loaderData }: Route.ComponentProps) {
  const [isFinished, setIsFinished] = useState<boolean>(
    loaderData?.isFinished || false
  );
  const [rating, setRating] = useState<number>(Number(loaderData?.rating));

  const submit = useSubmit();

  function deleteBook(bookId: number | undefined = loaderData?.id) {
    const confirmation = confirm('Are you sure you want to delete this book?');
    confirmation && bookId &&
      submit(
        { id: bookId },
        {
          method: 'delete',
        }
      );
  }

  return (
    <div className='mx-5'>
      <Link to='/' className='text-purple-700 flex items-center gap-1 w-fit'>
        <IoArrowBackCircle /> Back to home
      </Link>
      <div className='flex mt-5 max-w-md'>
        <div className='w-48 h-72 shrink-0'>
          {loaderData?.isbn ? (
            <img
              className='w-full h-full'
              src={`https://covers.openlibrary.org/b/isbn/${loaderData.isbn}-L.jpg`}
              alt={`Cover for ${loaderData.title}`}
            />
          ) : (
            <span className='block w-full h-full bg-gray-200'></span>
          )}
        </div>
        <div className='flex flex-col ml-5 grow'>
          <span className='font-medium text-xl'>{loaderData?.title}</span>
          <span>{loaderData?.author}</span>
          <Form method='post'>
            <span className='my-5 block'>
              <input
                type='checkbox'
                name='isFinished'
                id='finished'
                checked={isFinished}
                onChange={(e: ChangeEvent<HTMLInputElement>) =>
                  setIsFinished(e.target.checked)
                }
              />
              <label htmlFor='finished' className='ml-2'>
                Finished
              </label>
            </span>
            <div className='mb-5'>
              <span>Your Rating:</span>
              <span className='text-3xl flex'>
                {[۱, ۲, ۳, ۴, ۵].map((num) => {
                  return (
                    <span key={num} className='flex'>
                      <input
                        className='hidden'
                        type='radio'
                        name='rating'
                        id={`rating-${num}`}
                        value={num}
                        checked={rating === num}
                        onChange={(e: ChangeEvent<HTMLInputElement>) =>
                          setRating(+e.target.value)
                        }
                      />
                      <label htmlFor={`rating-${num}`}>
                        {num <= rating ? <IoStar /> : <IoStarOutline />}
                      </label>
                    </span>
                  );
                })}
              </span>
            </div>
            <div className='text-right'>
              <Button type='submit'>Save</Button>
              <Button
                variant='delete'
                type='button'
                onClick={() => deleteBook()}
              >
                Delete Book
              </Button>
            </div>
          </Form>
        </div>
      </div>
    </div>
  );
}

اکنون، کاربران می‌توانند جزئیات مربوط به هر کتاب را ذخیره کنند و هر کتاب را از برنامه (یا storage) حذف نمایند.

مدیریت Status Codeها در React Router

status codeهای HTTP مشخص می‌کنند که وضعیت یک درخواست کلاینت به سرور چگونه است.
برخی از رایج‌ترین status codeها عبارتند از:

مدیریت status codeها در React Router

به طور پیش‌فرض، هر صفحه‌ای که درخواست شود، یک status code 200 دریافت می‌کند (به معنای موفقیت‌آمیز بودن درخواست). اگر مسیر مورد نظر وجود نداشته باشد، کد ۴۰۴ برگردانده می‌شود. اما React Router به توسعه‌دهندگان این امکان را می‌دهد که status codeهای سفارشی را ارسال کنند. SSR در React Router 7 نیز از این قابلیت برای کنترل بهتر پاسخ‌های سرور و ارائه تجربه کاربری بهینه‌تر بهره می‌برد. با این کار، API مربوط به کلاینت بهبود پیدا می‌کند و کاربران از وضعیت دقیق درخواست‌هایشان مطلع می‌شوند.

برای ارسال status code 201 هنگام ایجاد یک کتاب جدید، اپلیکیشن را به صورت زیر تغییر می‌دهیم:

// app/routes/book-list.tsx

// Imports
import { data } from 'react-router';
...

export async function action({ request }: Route.ActionArgs) {
  let formData = await request.formData();
  let title = formData.get('title') as string | null;
  let author = formData.get('author') as string | null;
  let isbn = formData.get('isbn') as string | undefined;
  if (title && author) {
    storage.books.push({
      id: storage.books.length,
      title,
      author,
      isbn: isbn || undefined,
      isFinished: false,
    });
  }
  return data(storage, { status: 201 });
}

...

سپس، زمانی که کاربر به یک مسیر book/:bookId نامعتبر هدایت می‌شود بایدکد ۴۰۴ را return کنیم:

// app/routes/book.tsx

// Imports
...
import { Link, Form, redirect, useSubmit, data } from 'react-router';
...

// Route module loader
export async function loader({ params }: Route.LoaderArgs) {
  const { bookId } = params;
  const book: Book | undefined = storage.books.find(({ id }) => +bookId === id);

  if (!book) throw data(null, { status: 404 });

  return book;
}

اکنون می‌توانیم هر status code دیگری که مناسب است را در مسیرهای مختلف پروژه اضافه نماییم. با این کار، پروژه ما حرفه‌ای‌تر، ارتباطات API واضح‌تر و تجربه کاربری بهتر خواهد شد.

افزودن اطلاعات متا به تگ <head> در React Router

تگ <head> یکی از مهم‌ترین تگ‌ها برای سئو و بهینه‌سازی صفحات وب است.
فریم‌ورک React Router این امکان را فراهم می‌کند که تگ‌های <meta> را برای هر تعداد صفحه‌ای که بخواهیم، به‌روزرسانی نماییم.

تگ‌های <meta> شامل اطلاعات metadata می‌باشند، مانند:

اکنون می‌توانیم تگ‌های متا را به صفحات پروژه اضافه کنیم. برای انجام این کار، باید یک تابع meta در ماژول مسیر export نماییم:

// app/routes/home.tsx

// Imports
...
import type { Route } from './+types/home';
...


export function meta({}: Route.MetaArgs) {
  return [
    { title: 'Book Tracker App' },
    { name: 'description', content: 'Book Tracker Application' },
  ];
}

...

همچنین، برای افزودن <meta> برای صفحه About:

// app/routes/about.tsx

// Imports
...
import type { Route } from './+types/book';
...

export function meta({}: Route.MetaArgs) {
  return [
    { title: 'About Book Tracker App' },
    { name: 'description', content: 'About this Application' },
  ];
}

در نهایت، برای افزودن <meta> برای مسیر book.tsx:

// app/routes/book.tsx

// Imports
...
import type { Route } from './+types/book';
...

export function meta({ data }: Route.MetaArgs) {
  return [{ title: `Edit "${data.title}"` }];
}

نکته‌ای که باید به آن توجه داشته باشیم این است که آرگومان data در تابع meta همان مقداری است که loader این مسیر برمی‌گرداند و در متادیتا استفاده می‌شود.

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

افزودن linkهای HTML به تگ <head> در React Router

تگ <link> برای تعریف ارتباط بین صفحه و منابع خارجی استفاده می‌شود. این تگ معمولا برای وارد کردن فایل‌های CSS، استفاده از Faviconهای و لینک دادن به فونت‌های خارجی مورد استفاده قرار می‌گیرد.

افزودن <link> در React Router

React Router به توسعه‌دهندگان اجازه می‌دهد که به صورت جداگانه، تگ‌های <links> را به هر صفحه اضافه کنند. این قابلیت به ویژه در مواردی مثل استفاده از Favicon‌های سفارشی برای هر مسیر بسیار مفید می‌باشد. همچنین، SSR در React Router 7 این امکان را فراهم می‌کند که لینک‌های ضروری از پیش در سرور تعیین شوند تا هنگام بارگذاری صفحه، تجربه‌ای سریع‌تر و بهینه‌تر ارائه شود.

برای افزودن تگ <link>، در ماژول route یک تابع links را export می‌کنیم:

nlighter-language="typescript">export function links() {
  return [
    {
      rel: 'icon',
      href: '/favicon.png',
      type: 'image/png',
    },
  ];
}

درون این تابع، یک آرایه export می‌کنیم.  هر آیتم این آرایه یک آبجکت است که شامل ویژگی‌های یک <link> در HTML می‌باشد. مقادیر این ویژگی‌ها برابر با مقدار attributeهای واقعی در یک تگ <link> استاندارد HTML هستند.

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

مدیریت هدرهای HTTP در React Router

هدرهای HTTP در React Router به سرور اجازه می‌دهند که همراه با محتوای درخواستی، اطلاعات بیشتری را به کلاینت ارسال کند.
این هدرها برای ارسال کوکی‌ها به مرورگر، تنظیم Caching و بسیاری از موارد دیگر مورد استفاده قرار می‌گیرند.

برای اضافه کردن هدرهای سفارشی، می‌توانیم یک تابع headers را در ماژول route خود export نماییم. به عنوان مثال:

// Route module

export function headers(){
  return {
    "Content-Disposition": "inline",
    ...
    "Header Name": "Header value"
  }
}

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

جمع‌بندی

در این مقاله درباره SSR در React Router 7 صحبت کردیم که ترکیبی از React Router و Remix است و امکان ساخت اپلیکیشن‌های SSR و SSG را فراهم می‌کند.

برای درک بهتر این مفاهیم، یک اپلیکیشن مدیریت کتاب ایجاد کردیم و بهبودهای مهمی مثل type safety بهتر، تجربه توسعه راحت‌تر و قابلیت‌های جدید نسخه ۱۹ React را مورد بررسی قرار دادیم.

با استفاده از این راهنما، می‌توانیم SSR، SSG و ویژگی‌هایی مثل loaderها، Actionها و Meta Tagها را در پروژه‌های خود پیاده‌سازی نماییم.

همچنین، دسترسی به کد نهایی پروژه از طریق این لینک امکان‌پذیر می‌باشد.