Рендеринг на стороне сервера с помощью React Router v7

React Router уже давно является популярным решением для маршрутизации в SPA-центрах, разработанным командой разработчиков Remix. Постепенные улучшения в библиотеке маршрутизации приблизили функциональность React Router и Remix, что в конечном итоге привело к их объединению в React Router v7. В этом последнем выпуске React Router можно использовать либо как библиотеку маршрутизации, либо как полнофункциональный фреймворк, объединяющий все функциональные возможности Remix. Он также включает React v19 в качестве зависимости.

В этой статье показано, как создать SSR-приложение с помощью React Router v7, создав приложение для отслеживания книг с использованием таких инструментов, как примитивы Radix, значки React и Tailwind CSS. Предварительное знание React.js, TypeScript и базовых концепций извлечения данных, таких как действия и загрузчики, полезно, но не обязательно. Окончательный исходный код проекта можно найти здесь.

Как настроить платформу React Router framework

Node.js версия 20 - это минимальное требование для запуска React Router, поэтому убедитесь, что на вашем устройстве установлена эта версия или что-то более высокое:

node --version

Затем установите платформу React Router framework, запустив npm create vite. "React Router V7" доступен в качестве одной из опций в разделе шаблоны React Vite. При выборе этого параметра вы будете перенаправлены к командной строке React Router framework для завершения установки.

По этой причине в этом руководстве мы сразу перейдем к использованию интерфейса CLI React Router. Здесь пример проекта называется react-router-ssr.

Откройте свой терминал и выполните следующую команду:

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

Интерфейс командной строки спросит, хотите ли вы инициализировать git-репозиторий для проекта. Отметьте "Да", если хотите. Он также спросит, хотите ли вы установить зависимости с помощью npm. Здесь отмечены оба варианта:

CLI Dependencies

В результате будет создана папка с тем именем, которое вы дали своему проекту. Перейдите в этот каталог, затем запустите сервер разработки приложения:

cd react-router-ssr
npm run dev

После этого откройте свой браузер и перейдите по ссылке http://localhost:5173, где вас должна поприветствовать домашняя страница, которая выглядит следующим образом:

React Router Homepage

Таким образом, вы успешно установили платформу React Router Framework.

Поскольку в этом руководстве не предусмотрено развертывание приложения с помощью Docker, вы можете безопасно удалить все файлы, связанные с Docker, из исходного кода для получения более чистой кодовой базы. Эти файлы - .dockerignore, Dockerfile, Dockerfile.bun и Dockerfile.pnpm - включены в шаблон для случаев, когда требуется развертывание Docker.

Как создавать страницы SSR

Чтобы использовать React Router v7 для 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;

В этом руководстве используется тема "Светлый режим" для приложения, поэтому вам нужно отключить темный режим в Tailwind CSS. Откройте файл app/app.css и закомментируйте все стили "Темного режима":

// 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. Вы определите все свои маршруты (модули маршрутов) в папке app/routes/, но первой страницей будет home.tsx. Также будут другие маршруты, которые будут использовать его в качестве фрейма. Создайте файл app/routes/home.tsx.

Внутри app/routes/home.tsx экспортируйте компонент <Home />, содержащий следующее:

// 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, которые вы создадите позже (<Верхний /> и <Нижний /> колонтитулы), а также компонент <Outlet /> из React Router. <Outlet /> отображает компоненты любого вложенного маршрута, который использует home.tsx в качестве макета.

Чтобы отобразить что-либо на странице, вам потребуется создать импортированные пользовательские компоненты. Начните с изменения папки приложения/приветствия, которая поставляется вместе с шаблоном:

  • Либо удалите папку app/welcome и создайте новую папку с именем app/components,
  • Или переименуйте папку приветствия в components и удалите все файлы внутри нее

Затем в папке app/components создайте два новых файла: Header.tsx и Footer.tsx.

Компонент <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, который является оптимизированным тегом навигатора для фреймворка. Он также импортировал компонент <BookForm />, который еще не существует. Наконец, в файл добавлены некоторые попутные CSS-стили, чтобы HTML-элементы хорошо смотрелись на странице.

Затем создайте компонент <BookForm />. Но для этого вам сначала нужно установить компонент Radix's headless dialog. В конечном итоге вы будете использовать его для создания диалоговой формы для добавления новой книги в track. Это также подходящее время для установки значков React, так как позже они понадобятся вам для некоторых компонентов:

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 от React'а для управления диалоговым окном и Tailwind CSS для стилизации всего. Обратите внимание, что, в свою очередь, в файл был импортирован компонент <Кнопка />, который еще не существует.

Затем создайте компонент <Кнопка />:

// 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>
  );
}

Как видно из этого компонента кнопки, он поддерживает некоторые параметры, такие как дочерние элементы и вариант. У него также есть три варианта (отмена, обычный и удаление) с их собственным уникальным стилем.

Наконец, для маршрута home.tsx создайте компонент <Нижний колонтитул />:

// 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>
  );
}

Таким образом, у вас должна быть базовая структура для запуска вашего приложения:

Book Tracker App Basic Structure

Генерация статического сайта в React Router v7

SSR можно условно разделить на два метода: динамическую генерацию сайта, когда сервер генерирует страницы для каждого отдельного запроса, и статическую генерацию сайта (SSG), когда страницы уже сгенерированы и хранятся на сервере. Для SSG-страниц содержимое страницы остается неизменным (статичным) независимо от того, кто его запрашивает.

Динамический SSR использует серверную логику для создания страниц по запросу. Сервер отправляет разметку для этих страниц на клиентскую сторону (в браузер), где они впоследствии обновляются. Однако на статических сайтах все файлы, необходимые для страницы (HTML, CSS, JavaScript), генерируются во время сборки. Затем они отправляются клиенту быстрее, поскольку серверу не нужно генерировать их динамически.

У использования любого из этих подходов есть свои плюсы и минусы. Хорошее практическое правило - использовать SSG, когда вы хотите, чтобы все пользователи видели одно и то же (например, записи в блоге, страницы контактов и о себе) и чтобы эта страница не нуждалась в частых обновлениях. С другой стороны, если содержимое страницы часто меняется или разным пользователям требуется доступ к разным, уникальным для них ресурсам, то лучше всего использовать динамический SSR. Также стоит отметить, что SSG-страницы легко развертывать, поскольку их можно обслуживать с помощью CDN.

В примере проекта маршрут /about будет сгенерирован с помощью SSG. React Router v7 позволяет разработчикам создавать приложение, которое сочетает в себе эти два метода визуализации в одном приложении, если они того захотят.

Откройте конфигурацию маршрутизатора React и настройте предварительную визуализацию маршрутов (или статическую генерацию). В этом случае приложение будет предварительно отображать только маршрут /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;

Создайте файл app/routes/about.tsx. Он будет содержать статический контент, который будет отображаться на странице About:

// 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 framework.

Перед просмотром страницы About, которую вы только что создали в браузере, вам необходимо настроить React Router на отображение этого модуля маршрута (about.tsx) всякий раз, когда посетитель переходит на страницу /about. Эта настройка выполняется в 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. Первый аргумент route - это URL-адрес, который требуется сопоставить, а второй аргумент - это модуль route, который будет отображаться при сопоставлении этого URL-адреса. После всего этого вы сможете перейти на статическую страницу About:

Static About Page

Запустите npm run build на терминале, когда вы хотите создать свое приложение - чтобы объединить приложение и создать статическую страницу о нем внутри сборки / папки.

Но маршруты /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;

Как вы можете видеть здесь, маршруты используют функцию компоновки, которая имеет два аргумента:

  • Расположение модуля шаблонного маршрута
  • Массив его вложенных маршрутов

Всякий раз, когда пользователь переходит к любому из вложенных маршрутов, React Router сначала отображает маршрут родительской компоновки. После этого он использует компонент <Outlet /> для заполнения данных, уникальных для маршрута, по которому пользователь перешел.

Получение и загрузка данных в SSR-маршрутах

функции загрузки - это уникальная концепция React Router. Это функции, экспортируемые из модулей route, которые возвращают данные, необходимые для отображения маршрута. Предполагается, что они также будут использоваться только в модулях route и нигде больше.

В примере приложения вы создаете маршрут, в котором перечислены все доступные книги, которые отслеживает пользователь. Этот новый модуль маршрута будет использовать загрузчики для извлечения данных всякий раз, когда потребуется загрузить маршрут (в данном случае сохраненные данные о книгах). Для этого сначала создайте решение для хранения данных, которое представляет собой просто массив JavaScript для иллюстрации.

Создайте файл 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 };

Список книг

Затем создайте новый маршрут для отображения всех книг в объекте хранилища. Для этого создайте модуль маршрута с именем 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>
  );
}

Как вы можете видеть, этот модуль route экспортирует функцию загрузки. Затем основной компонент Route получает то, что возвращает функция загрузки в loaderData. Но прежде чем вы увидите результаты этих изменений, вам нужно выполнить несколько дополнительных действий.

Создайте импортированный компонент 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>
  );
}

Компонент <Книжная карточка /> представляет собой интерактивную карточку. Он содержит, помимо прочего, наиболее важную информацию о книге, такую как название, автор и, возможно, обложка.

После этого откройте файл app/routes.tsx и закомментируйте другой маршрут. Это делается для того, чтобы React Router не выдавал ошибок, поскольку для этого определенного маршрута еще нет модуля route.:

// 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;

Когда все это будет сделано, у вас должна появиться домашняя страница, которая считывает данные из хранилища в app /model.ts:

Homepage That Reads Data From The Storage Object

Это означает, что любая книга, добавленная в хранилище, должна отображаться в маршруте 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;

Затем создайте соответствующий модуль route. Файл будет app/routes/book.tsx, и он будет содержать загрузчик, который возвращает сведения о любой книге, на которую нажимает пользователь:

// 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'>
                {[1, 2, 3, 4, 5].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>
  );
}

Этот файл содержит несколько ключевых функций. Во-первых, после импорта выполняется загрузка, которая выполняет поиск в объекте хранилища и извлекает объект книги, соответствующий идентификатору в параметрах URL. Например, если пользователь переходит по ссылке /book/0, загрузчик извлекает данные о книге с идентификатором 0. Кроме того, модуль route позволяет пользователям изменять данные о книге. Пользователи могут отметить, дочитали ли они книгу до конца, присвоить ей оценку в пять звезд и сохранить внесенные изменения. У них также есть возможность полностью удалить книгу.

Когда все это будет сделано, приложение теперь должно выглядеть следующим образом:

Book Tracking App With Basic Loaders Set

Теперь основные загрузчики всего нашего приложения настроены. Пришло время перейти к добавлению и удалению книг из книжного трекера.

Реагировать на действия сервера-маршрутизатора

Как и в случае с загрузчиками, действия могут выполняться только в модулях маршрута - модули маршрута представляют собой файлы в каталоге app/routes/. Действия - это функции, которые обрабатывают отправку форм в определенном маршруте. Действия, которые должны выполняться в браузере, экспортируются как clientAction, в то время как действия, которые выполняются на сервере, экспортируются как action.

Действие принимает такие параметры, как параметры URL (как params) и отправленные данные для маршрута (как request). запрос здесь реализован как экземпляр веб-API Request, поэтому он работает со всеми функциональными возможностями API. Все эти параметры берутся из Route.ActionArgs указывает, что у каждого модуля route есть уникальная версия внутри .react-router.

Первое, для чего в этом руководстве будут использованы действия сервера, - это добавление новой книги в хранилище. Добавьте эту функцию в модуль 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;
}

...

С помощью этой функции вы сможете добавлять новые книги в приложение:

Form Filled With Information For A New Book

После заполнения формы новая книга должна появиться в списке покупок на сайте book-list.tsx.:

New Book Added To Book Tracker App

Следующая функция, для выполнения которой в этом руководстве будут использованы действия сервера, - это обеспечение того, чтобы пользователь мог редактировать и удалять записи в книге. Для этого добавьте действие в маршрут 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'>
                {[1, 2, 3, 4, 5].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>
  );
}

Теперь пользователь должен иметь возможность сохранять информацию о каждой записи в книге. Он также должен иметь возможность удалять любую запись в книге из приложения (или хранилища).:

Saving Book Entry Details And Deleting Them From The App

На этом все основные функции приложения завершены.

Обработка кодов состояния в React Router

Коды состояния - это свойство ответов от сервера, которое показывает статус запроса клиента. Оно может возвращать:

  • 200, что означает "ОК")
  • 201, что означает, что запрос был выполнен успешно и запись была создана
  • 404, что означает, что запрошенный серверный ресурс не был найден
  • И более

В React Router каждая запрошенная страница возвращается с кодом состояния 200, который является общим способом сообщить, что запрос был выполнен успешно. Он также возвращает код состояния 404, когда URL-адрес не имеет соответствующего модуля маршрутизации. Однако платформа React Router framework также позволяет разработчику отправлять клиенту пользовательские коды состояния. Их использование обеспечивает улучшенный и более коммуникабельный API для клиента. Клиент получает точную информацию о статусе своих запросов.

Использование этой функции в React Router v6 требует использования функции data из react-router. Функция принимает данные для возврата в качестве первого аргумента (loaderData или ActionData). Второй аргумент содержит пользовательский код состояния для запроса.

Измените приложение, указав в ответ соответствующие коды состояния. Сначала верните значение 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 });
}

...

Следующий шаг - вернуть 404 (не найден), когда пользователь переходит к несуществующему маршруту book/:BookID:

// 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;
}

Эти примеры иллюстрируют, как можно легко добавлять коды статуса. Вы можете добавить столько кодов статуса, сколько, по вашему мнению, подходит для маршрутов.

Как добавить HTML-метаинформацию в тег <head> в React Router

HTML-тег <head> является очень важным тегом для оптимизации веб-страницы. Платформа React Router позволяет разработчикам обновлять теги <meta> в теге <head> для любого количества страниц, которое они захотят. Эти теги <meta> содержат метаданные (заголовок, описание, ключевые слова, порт просмотра) конкретной страницы.

В примере проекта добавьте мета-теги на страницы. Обратите внимание, что для этого вам нужно экспортировать мета-функцию в модулях route:

// 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> для страницы "О компании":

// 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}"` }];
}

Обратите внимание на объект деструктурированных данных, который является аргументом для мета-функции. Здесь данные представляют все, что вернул загрузчик этого маршрута.

После внесения этих изменений приложение теперь должно иметь обновленную мета-информацию на панели вкладок браузера.

Как добавить HTML-ссылки в тег <head> в React Router

HTML-теги <link> определяют связь между страницей и внешним ресурсом. В основном они используются для импорта CSS-файлов и значков. React Router позволяет разработчикам добавлять <ссылки> на отдельные страницы. Это может быть полезно для таких функций, как добавление пользовательских значков к маршруту.

В модуле маршрута экспортируйте функцию ссылок:

export function links() {
  return [
    {
      rel: 'icon',
      href: '/favicon.png',
      type: 'image/png',
    },
  ];
}

Внутри функции экспортируйте массив. Каждый отдельный элемент массива должен быть объектом, содержащим свойства, которые являются атрибутами тега <link>. Значения этих свойств должны быть значениями соответствующих атрибутов в теге HTML <link>.

Обработка HTTP-заголовков в React Router

HTTP-заголовки в React Router позволяют серверу передавать дополнительные данные клиенту (вместе с запрошенной полезной нагрузкой). Они используются для отправки файлов cookie в браузер, настройки кэширования и многого другого. Вы можете добавлять заголовки в свои модули маршрутизации, экспортируя функцию headers. Например:

// Route module

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

Теперь клиент получит ответ с вашими пользовательскими заголовками.

Сравнение React Router v7 с Remix

Вместо выпуска Remix v3 команда разработчиков фреймворка объединила Remix с React Router, в результате чего появилась React Router v7. С выпуском React v19 официальная документация по React теперь рекомендует использовать фреймворк, чтобы в полной мере использовать преимущества новой версии. В нем, в частности, упоминается Remix, который теперь интегрирован как React Router v7, как один из предлагаемых фреймворков для разработчиков.

Несмотря на эту интеграцию, между React Router v7 и Remix есть заметные различия, помимо того, что одна из них является последней основной версией другой. Вот некоторые из этих различий:

  • Настройка маршрутизации: Платформа Remix framework по умолчанию работает с маршрутизацией на основе файлов. Разработчики все еще могут использовать пользовательские методы маршрутизации - например, конфигурационный файл - однако для этого им придется установить плагин. С другой стороны, в React Router v7 метод маршрутизации по умолчанию представляет собой настройку маршрутов внутри файла app/routes.ts
  • Загрузка данных: Загрузка данных загрузчика в модуль маршрута раньше выполнялась с помощью функции useLoaderData() Хук в Remix. Данные о действиях также были получены с помощью функции useActionData() Крюк. Хотя вы все еще можете сделать это в React Router v7, платформа рекомендует вместо этого использовать props компонента Route как для загрузки, так и для действий. Маршрут.ComponentProps type - это объект, содержащий loaderData и ActionData, которые вы можете деструктурировать и использовать в своих компонентах route. Это, безусловно, улучшение, поскольку обеспечивает лучшую безопасность типов в приложениях
  • Функциональность SSG: Remix v2 поддерживал динамический рендеринг на стороне сервера (SSR), но не имел функциональности для создания статического сайта (SSG). В то время команда объяснила, что они не рекомендуют SSG из-за его недостатков, хотя его предлагали почти все другие платформы React SSR. Однако в последней версии это изменилось, и теперь в нее включена поддержка SSG. Похоже, давление со стороны сверстников все еще оказывает свое влияние
  • Безопасность типов: Это значительное улучшение в React Router v7 по сравнению с Remix. Сервер разработки генерирует типы для каждого модуля route. Эти типы находятся в папке .react-router/. Из-за этого создаются типы для реквизитов компонентов маршрута, аргументов загрузчика, аргументов действия, аргументов мета-функции и многого другого. Это значительно повышает безопасность исходного кода приложения
  • Опыт разработчика: В целом, React Router v7 обеспечивает улучшенные возможности для разработчиков по сравнению с Remix. Он также интуитивно понятен, что делает процесс работы с ним менее запутанным

Между этими двумя фреймворками есть и другие различия, помимо перечисленных выше. Однако фреймворк React Router определенно является улучшением по сравнению с фреймворком Remix.

Сворачивание

В этой статье рассматривается рендеринг на стороне сервера (SSR) с помощью React Router v7, который объединяет React Router и Remix в полнофункциональный фреймворк для создания современных SSR-приложений и приложений для создания статических сайтов (SSG). Мы продемонстрировали эти концепции, создав приложение для отслеживания книг и отметив улучшения в работе разработчиков, безопасности ввода и функциях React v19, а также сравнив React Router v7 с Remix.

Следуя этому руководству, разработчики могут научиться внедрять SSR, SSG и расширенные функциональные возможности, такие как загрузчики, действия и мета-теги, в приложениях React.

Окончательный код для примера проекта можно найти здесь.