Skip to main content

Remix

Remix - фреймворк для создания клиент-серверных веб-приложений на JavaScript (React) со встроенной поддержкой TypeScript.

Remix позволяет разрабатывать так называемые PESPA (Progressive Enhancement Single Page Apps - одностраничные приложения с возможностью прогрессивного улучшения). Это означает следующее:

  • почти весь код приложения "живет" на сервере;
  • приложение остается функциональным даже при отсутствии JS;
  • JS используется только для прогрессивного улучшения UX (User Experience - пользовательский опыт).

Подробнее о PESPA и других архитектурах веб-приложений можно почитать здесь.

Маршрутизация / Routing

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

Начнем с определения понятий:

  • вложенные роуты (nested routes) - связь (map) роутов с сегментами URL обеспечивает соответствие URL определенным компонентам и данным, известным перед рендерингом страницы;
  • URL - полный путь в поисковой строке браузера пользователя. Один URL может совпадать (соответствовать - match) с несколькими роутами. Обратите внимание: роут и URL - разные вещи в Remix;
  • роут (route) или модуль роута (route module) - модуль JS с определенными экспортами (loader, action, default function (компонент) и др.), который соответствует одному или нескольким сегментам URL. Поскольку роут соответствует сегменту URL, по одному пути могут рендерится несколько модулей. Иерархия компонентов соответствует сегментам URL (в основном);
  • путь (path) или путь роута (route path) - сегмент URL, которому соответствует отдельный модуль. Путь роута определяется названием файла в директории app/routes;
  • родительский макет (parent layout route) или родительский роут (parent route) - модуль, который рендерит макет для дочерних компонентов через компонент Outlet;
  • макет без пути (pathless layout route) или роут без пути (pathless route) - модуль, который не добавляет сегменты к URL, но добавляет компонент в иерархию UI (User Interface - пользовательский интерфейс) при совпадении его дочерних роутов;
  • дочерний роут (child route) - модуль, который рендерится внутри родительского Outlet при совпадении его пути с URL;
  • индексный роут (index route) - модуль, который имеет такой же путь, как его родительский роут, но рендерится в качестве дефолтного дочернего роута внутри Outlet;
  • динамический сегмент (dynamic segment) - сегмент пути роута, извлекаемый из URL и передаваемый в приложение, такой как идентификатор (ID) записи или слаг (slug) поста;
  • сплат (splat) - замыкающая звездочка (trailing wildcard) в пути роута, который благодаря этому совпадает со всеми сегментами URL (включая последующий /);
  • аутлет (outlet) - компонент, который рендерится внутри родительского модуля, предназначенный для рендеринга дочерних модулей. Другими словами, аутлет определяет локацию дочерних роутов.

Вложенный роутинг

Вложенный роутинг - это связь между сегментами URL и иерархией компонентов в UI. Сегменты URL определяют:

  • макеты, формирующие страницу;
  • загрузку JS-кода, используемого на странице;
  • загрузку данных, используемых на странице.

Определение роутов

Роуты определяются посредством создания файлов в директории app/routes. Вот как может выглядеть иерархия роутов приложения:

app
├── root.jsx
└── routes
├── accounts.jsx
├── dashboard.jsx
├── expenses.jsx
├── index.jsx
├── reports.jsx
├── sales
│ ├── customers.jsx
│ ├── deposits.jsx
│ ├── index.jsx
│ ├── invoices
│ │ ├── $invoiceId.jsx
│ │ └── index.jsx
│ ├── invoices.jsx
│ └── subscriptions.jsx
└── sales.jsx
  • root.jsx - это корневой роут, служащий макетом для всего приложения. Другие роуты рендерятся внутри его Outlet;
  • обратите внимание на файлы, названия которых совпадают с названиями директорий, в которых эти файлы находятся. Эти файлы предназначены для формирования иерархии макетов компонентов. Например, sales.jsx - это родительский роут для всех дочерних роутов внутри директории app/routes/sales. При совпадении с URL любого роута из этой директории, он будет рендерится внутри Outlet модуля sales.jsx;
  • роут index.jsx будет рендерится внутри Outlet при совпадении URL с путем директории (например, пути example.com/sales соответствует роут app/routes/sales/index.jsx).

Рендеринг иерархии макета роута

Предположим, что URL имеет вид /sales/invoices/123. С этим URL будут совпадать следующие роуты:

  • root.jsx;
  • routes/sales.jsx;
  • routes/sales/invoices.jsx;
  • routes/sales/invoices/$invoiceId.jsx.

При посещении этой страницы пользователем Remix отрендерит такую иерархию компонентов:

<Root>
<Sales>
<Invoices>
<InvoiceId />
</Invoices>
</Sales>
</Root>

Иерархия компонентов полностью соответствует иерархии файлов в директории app/routes:

app
├── root.jsx
└── routes
├── sales
│ ├── invoices
│ │ └── $invoiceId.jsx
│ └── invoices.jsx
├── sales.jsx
└── accounts.jsx

Иерархия компонентов для URL /accounts будет такой:

<Root>
<Accounts />
</Root>

Для рендеринга дочерних роутов внутри родительского используется Outlet. root.jsx рендерит основной макет, боковую панель и аутлет для дочерних роутов:

import { Outlet } from "@remix-run/react";

export default function Root() {
return (
<Document>
<Sidebar />
{/* ! */}
<Outlet />
</Document>
);
}

В свою очередь, sales.jsx рендерит аутлет для всех его дочерних роутов (app/routes/sales/*):

import { Outlet } from "@remix-run/react";

export default function Sales() {
return (
<div>
<h1>Sales</h1>
<SalesNav />
{/* ! */}
<Outlet />
</div>
);
}

Индексные роуты

Индексный роут - это дефолтный дочерний роут. При отсутствии других дочерних роутов рендерится индексный модуль. Например, URL exmaple.com/sales соответствует индексный роут app/routes/sales/index.jsx.

Индексные роуты не должны рендерить дочерние модули, они являются тупиком для URL. Например, вместо рендеринга глобальной панели навигации в app/routes/index.jsx, ее следует рендерить в app/root.jsx.

Параметр строки запроса ?index

Данный параметр позволяет отличать индексные роуты от их родительский модулей, которые рендерят макеты. Предположим, что у нас имеется такая иерархия роутов:

└── app
├── root.jsx
└── routes
├── sales
│ ├── invoices
│ │ └── index.jsx
│ └── invoices.jsx

Какому роуту будет соответствовать путь /sales/invoices? Роуту /sales/invoices.jsx или роуту /sales/invoices/index.jsx? Ответ: роуту /sales/invoices.jsx. Для совпадения с роутом /sales/invoices/index.jsx путь должен заканчиваться параметром строки запроса ?index:

└── app
├── root.jsx
└── routes
├── sales
│ ├── invoices
│ │ └── index.jsx <-- /sales/invoices?index
│ └── invoices.jsx <-- /sales/invoices

В некоторых случаях добавление ?index происходит автоматически (например, при отправке формы в индексном или его родительском роуте), в других - это делается вручную (например, при использовании fetcher.submit() или fetcher.load()).

Вложенные URL без вложенных макетов

Иногда может потребоваться добавить вложенный URL без добавления компонента в иерархию UI. Рассмотрим страницу редактирования счета:

  • мы хотим, чтобы URL имел вид /sales/invoices/$invoiceId/edit;
  • мы хотим, чтобы соответствующий компонент был прямым потомком корневого компонента.

Другими словами, мы не хотим этого:

<Root>
<Sales>
<Invoices>
<InvoiceId>
<EditInvoice />
</InvoiceId>
</Invoices>
</Sales>
</Root>

Мы хотим это:

<Root>
<EditInvoice />
</Root>

Для создания плоской иерархии UI используется плоское название файла - использование . в названии файла позволяет добавлять сегменты URL без добавления компонентов в иерархию UI:

└── app
├── root.jsx
└── routes
├── sales
│ ├── invoices
│ │ └── $invoiceId.jsx
│ └── invoices.jsx
├── sales.invoices.$invoiceId.edit.jsx 👈 не является вложенным
└── sales.jsx

Пути example.com/sales/invoices/123/edit будет соответствовать такая иерархия компонентов:

<Root>
<EditInvoice />
</Root>

А пути example.com/sales/invoices/123 (без /edit) такая:

<Root>
<Sales>
<Invoices>
<InvoiceId />
</Invoices>
</Sales>
</Root>

Макеты без пути

Иногда, наоборот, может потребоваться добавить компонент в иерархию UI без добавления сегментов в URL. Для этого используются макеты без пути.

Предположим, что мы хотим получить такую иерархию UI:

<Root>
<Auth>
<Login />
</Auth>
</Root>

В данном случае иерархия роутов может выглядеть так:

app
├── root.jsx
└── routes
├── auth
│ ├── login.jsx
│ ├── logout.jsx
│ └── signup.jsx
└── auth.jsx

У нас имеется правильная иерархия UI, но, возможно, мы не хотим, чтобы каждый URL содержал префикс /auth, например, вместо /auth/login мы хотим видеть просто /login.

Для удаления вложенности URL при сохранении вложенности UI достаточно добавить к названиям роута и директории 2 нижних подчеркивания:

app
├── root.jsx
└── routes
├── __auth
│ ├── login.jsx
│ ├── logout.jsx
│ └── signup.jsx
└── __auth.jsx

Динамические сегменты

Если название файла содержит префикс $, соответствующая часть пути роута становится динамическим сегментом. Это означает, что любое значение в URL для данного сегмента будет извлекаться из URL и передаваться в приложение.

Рассмотрим роут $invoiceId.jsx. При переходе по адресу /sales/invoices/123 строка 123 будет извлечена из URL и передана в loader, action и компонент в виде одноименного свойства объекта params:

import { useParams } from "@remix-run/react";

export async function loader({ params }) {
const id = params.invoiceId;
}

export async function action({ params }) {
const id = params.invoiceId;
}

export default function Invoice() {
const params = useParams();
const id = params.invoiceId;
}

Роуты могут содержать несколько параметров. Параметры могут быть директориями.

app
├── root.jsx
└── routes
├── projects
│ ├── $projectId
│ │ └── $taskId.jsx
│ └── $projectId.jsx
└── projects.jsx

В данном случае при переходе по адресу /projects/123/abc параметры буду следующими:

params.projectId; // "123"
params.taskId; // "abc"

Сплаты

Если название файла содержит только $ ($.jsx), то такой роут является сплатом. С таким роутом совпадает любое значение URL для оставшейся части URL до конца. В отличие от динамических сегментов, сплат не останавливается при достижении следующего /.

Рассмотрим такую иерархию роутов:

app
├── root.jsx
└── routes
├── files
│ ├── $.jsx
│ ├── mine.jsx
│ └── recent.jsx
└── files.jsx

При переходе по адресу example.com/files/images/work/flyer.jpg, сегменты URL, начиная с files будут перехвачены сплатом и доступны в приложении через params["*"]:

export async function loader({ params }) {
params["*"]; // "images/work/flyer.jpg"
}

Сплаты могут использоваться для реализации кастомных страниц 404 с данными из loader (при отсутствии кастомной страницы 404 рендерится корневой CatchBoundary, не позволяющий загружать данные для страницы при отсутствии совпадения с роутами).

Ресурсные роуты / Resource Routes

Ресурсные роуты не являются частью UI, но являются частью приложения. Они могут возвращать любые ответы.

Большая часть роутов в Remix является роутами UI, т.е. роутами, которые рендерят компоненты. Но роут не обязательно должен это делать. Роуты, которые не экспортируют дефолтные компоненты, являются ресурсными. Случаи их использования:

  • JSON API для мобильных приложений, которые повторно используют серверный код с Remix UI;
  • динамическая генерация PDF;
  • динамическая генерация иконок социальных сетей для постов блога или других страниц;
  • веб-хуки для сервисов вроде Stripe или GitHub;
  • CSS-файлы, которые динамически рендерят кастомные свойства для темы, выбранной пользователем.

Создание ресурсного роута

Ресурсный роут должен экспортировать либо loader (для обработки GET-запросов), либо action (для обработки других запросов).

Рассмотрим роут для рендеринга отчета (внимание на ссылку):

// app/routes/reports/$id.tsx
export async function loader({ params }: LoaderArgs) {
return json(await getReport(params.id));
}

export default function Report() {
const report = useLoaderData<typeof loader>();

return (
<div>
<h1>{report.name}</h1>
<Link to="pdf" reloadDocument>
PDF
</Link>
{/* ... */}
</div>
);
}

Ссылка ведет на PDF-версию страницы. Для ее создания необходимо реализовать ресурсный роут:

// app/routes/reports/$id/pdf.tsx
export async function loader({ params }: LoaderArgs) {
const report = await getReport(params.id);

const pdf = await generateReportPDF(report);

return new Response(pdf, {
status: 200,
headers: {
"Content-Type": "application/pdf",
},
});
}

Когда пользователь нажимает на ссылку, он получает отчет в формате PDF.

Обратите внимание: в качестве ссылки на ресурсный роут следует использовать либо компонент Link с атрибутом reloadDocument, либо HTML-элемент a. Link без reloadDocument считается ссылкой на роут UI.

Для того, чтобы добавить расширение файла к пути ресурсного роута, можно обернуть расширение или . в []:

# /reports/123/pdf
app/routes/reports/$id/pdf.ts

# /reports/123.pdf
app/routes/reports/$id[.pdf].ts

# /reports/123.pdf
app/routes/reports/$id[.]pdf.ts

Обработка разных методов

Для обработки GET-запросов из ресурсного роута экспортируется loader:

import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

export const loader = async ({ request }: LoaderArgs) => {
return json({ success: true }, 200);
};

Для обработки других запросов из ресурсного роута экспортируется action:

import type { ActionArgs } from "@remix-run/node";

export const action = async ({ request }: ActionArgs) => {
switch (request.method) {
case "POST": {
// ...
}
case "PUT": {
// ...
}
case "PATCH": {
// ...
}
case "DELETE": {
// ...
}
default: {
throw new Response("Неизвестный метод", {
status: 400
})
}
}
};

Веб-хуки

Пример веб-хука для получения уведомлений о создании нового коммита в репозитории GitHub:

import type { ActionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import crypto from "crypto";

export const action = async ({ request }: ActionArgs) => {
if (request.method !== "POST") {
return json({ message: "Метод запрещен" }, 405);
}

const payload = await request.json();

/* валидируем веб-хук */
const signature = request.headers.get(
"X-Hub-Signature-256"
);
const generatedSignature = `sha256=${crypto
.createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET)
.update(JSON.stringify(payload))
.digest("hex")}`;

if (signature !== generatedSignature) {
return json({ message: "Signature mismatch" }, 401);
}

/* обрабатываем веб-хук (например, помещаем фоновую задачу в очередь) */

return json({ success: true }, 200);
};

Интерфейс роутов / API Routes

Рассмотрим такой роут:

// routes/teams.tsx
export async function loader() {
return json(await getTeams());
}

export default function Teams() {
return (
<TeamsView teams={useLoaderData<typeof loader>()} />
);
}

При клике пользователем по <Link to="/teams" /> Remix запрашивает данные у сервера в loader и рендерит модуль. Таким образом, нам не нужен отдельный роут для взаимодействия с API.

Что если мы хотим получить данные из loader без участия пользователя? Хорошим примером такой ситуации является компонент Combobox, который получает записи из БД и предлагает их пользователю.

Для этого можно использовать хук useFetcher.

Предположим, что у нас есть такой роут для обработки поиска:

// routes/city-search.tsx
export async function loader({ request }: LoaderArgs) {
const url = new URL(request.url);

return json(
await searchCities(url.searchParams.get("q"))
);
}

Вызываем useFetcher() в соответствующем компоненте:

function CitySearchCombobox() {
const cities = useFetcher();

return (
<cities.Form method="get" action="/city-search">
<Combobox aria-label="Cities">
<div>
<ComboboxInput
name="q"
onChange={(event) =>
cities.submit(event.target.form)
}
/>
{cities.state === "submitting" ? (
<Spinner />
) : null}
</div>

{cities.data ? (
<ComboboxPopover className="shadow-popup">
{cities.data.error ? (
<p>Не удалось загрузить список городов</p>
) : cities.data.length ? (
<ComboboxList>
{cities.data.map((city) => (
<ComboboxOption
key={city.id}
value={city.name}
/>
))}
</ComboboxList>
) : (
<span>Подходящих городов не найдено</span>
)}
</ComboboxPopover>
) : null}
</Combobox>
</cities.Form>
);
}

Бэкенд для фронтенда / Backend for frontend

Несмотря на то, что Remix предназначен для разработки фуллстек-приложений, он отлично вписывается в архитектуру "Бэкенд для фронтенда". Эта архитектура предполагает использование Remix в качестве посредника между UI и существующими серверными сервисами.

Поскольку Remix "полифиллит" Web Fetch API, мы можем использовать fetch прямо в loader и action для получения данных от сервера:

import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import escapeHtml from "escape-html";

export async function loader({ request }: LoaderArgs) {
const apiUrl = "http://api.example.com/some-data.json";

const res = await fetch(apiUrl, {
headers: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
});

const data = await res.json();

const prunedData = data.map((record) => {
return {
id: record.id,
title: record.title,
formattedBody: escapeHtml(record.content),
};
});

return json(prunedData);
}

Такой подход позволяет:

  • упростить интеграцию со сторонними сервисами, а также хранить токены и секреты вне сборки для клиента;
  • сократить количество данных, передаваемых по сети, что может существенно повысить производительность приложения;
  • перенести большое количество кода на сервер, что также хорошо влияет на производительность приложения.

Загрузка данных / Data Loading

Одним из главных преимуществ Remix является упрощение взаимодействия с сервером для получения данных в компонентах. Remix автоматически:

  • рендерит страницы на сервере;
  • является устойчивым к изменениям сетевых условий;
  • загружает только те данные, которые необходимы для обновления страницы;
  • загружает данные, JS-модули, CSS и другие ресурсы параллельно при переходе с одной страницы на другую. Это позволяет избежать водопадов (waterfalls) рендеринг+получение данных, что приводит к более согласованному UI;
  • обеспечивает синхронизацию между данными в UI и на сервере путем повторного запуска action;
  • обеспечивает восстановление прокрутки;
  • обрабатывает серверные ошибки с помощью предохранителей (error boundaries);
  • обеспечивает хороший UX для "Не найдено" и "Не авторизован" с помощью перехватчиков (catch boundaries).

Основы

Каждый модуль может экспортировать компонент и функцию loader. Хук useLoaderData позволяет получать данные из loader в компоненте:

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export const loader = async () => {
return json([
{ id: "1", name: "Pants" },
{ id: "2", name: "Jacket" },
]);
};

export default function Products() {
const products = useLoaderData<typeof loader>();

return (
<div>
<h1>Список товаров</h1>
{products.map((product) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}

Компонент рендерится как на сервере, так и в браузере. loader выполняется только на сервере. Это означает, что массив товаров не включается в сборку для браузера. Это также означает, что мы можем безопасно использовать серверные API и SDK для базы данных, обработки платежей, CMS и т.д.

Если серверные модули включаются в клиентскую сборку, импорт этих модулей следует перенести в файл с названием, содержащим суффикс .server.ts ({something}.server.ts).

Параметры роута

Когда в названии файла содержится символ $, например, routes/users/$userId.tsx или routes/users/$userId/projects/$projectId.tsx, динамические сегменты (начинающиеся с $) извлекаются из URL и передаются в loader в качестве объекта params:

import type { LoaderArgs } from "@remix-run/node";

export const loader = async ({ params }: LoaderArgs) => {
console.log(params.userId);
console.log(params.projectId);
};

Эти параметры могут использоваться для поиска необходимых данных:

import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

export const loader = async ({ params }: LoaderArgs) => {
return json(
await fakeDb.project.findMany({
where: {
userId: params.userId,
projectId: params.projectId,
},
})
);
};

Типы параметров

Поскольку параметры содержатся в URL, а не в исходном коде, мы не можем быть уверены в том, что они всегда будут определены. Поэтому типом ключей параметров является string | undefined. Хорошей практикой является валидация параметров перед их использованием, например, с помощью invariant:

import type { LoaderArgs } from "@remix-run/node";
import invariant from "tiny-invariant";

export const loader = async ({ params }: LoaderArgs) => {
invariant(params.userId, "Ожидается params.userId");
invariant(params.projectId, "Ожидается params.projectId");

params.projectId; // <-- TS теперь знает, что типом `projectId` является `string`
};

Для обработки таких исключений используются предохранители (error boundaries).

Внешние API

Remix предоставляет полифилл для fetch на сервере, что облегчает получение данных из существующих JSON API. Вместо управления состоянием, обработки ошибок, решения проблем, связанных с гонкой условий (race conditions) и т.п., мы просто запрашиваем данные в loader (на сервере), а Remix делает все остальное:

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export async function loader() {
const res = await fetch("https://api.github.com/gists");

return json(await res.json());
}

export default function GistsRoute() {
const gists = useLoaderData<typeof loader>();

return (
<ul>
{gists.map((gist) => (
<li key={gist.id}>
<a href={gist.html_url}>{gist.id}</a>
</li>
))}
</ul>
);
}

База данных

Поскольку Remix выполняется на сервере, мы можем напрямую подключаться к БД в модулях. Пример подключения к Postgres с помощью Prisma:

// app/db.server.ts
import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();
export default db;
// app/routes/products/$categoryId.tsx
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import db from "~/db.server";

export const loader = async ({ params }: LoaderArgs) => {
return json(
await db.product.findMany({
where: {
categoryId: params.categoryId,
},
})
);
};

export default function ProductCategory() {
const products = useLoaderData<typeof loader>();

return (
<div>
<p>{products.length} Товаров</p>
{/* ... */}
</div>
);
}

Для типизации данных можно использовать типы, генерируемые Prisma Client, при вызове useLoaderData():

import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import db from "~/db.server";

async function getLoaderData(productId: string) {
const product = await db.product.findUnique({
where: {
id: productId,
},
select: {
id: true,
name: true,
imgSrc: true,
},
});

return product;
}

export const loader = async ({ params }: LoaderArgs) => {
return json(await getLoaderData(params.productId));
};

export default function Product() {
const product = useLoaderData<typeof loader>();

return (
<div>
<p>Товар {product.id}</p>
{/* ... */}
</div>
);
}

Не найдено

При отсутствии записи в БД достаточно выбросить ответ в loader и Remix остановит выполнение кода и передаст управление ближайшему перехватчику (catch boundary):

export const loader = async ({
params,
request,
}: LoaderArgs) => {
const product = await db.product.findOne({
where: { id: params.productId },
});

if (!product) {
// мы не можем отрендерить компонент,
// поэтому выбрасываем ответ для остановки выполнения кода
// и отображения страницы "Не найдено"
throw new Response("Not Found", { status: 404 });
}

const cart = await getCart(request);

return json({
product,
inCart: cart.includes(product.id),
});
};

Параметры строки запроса

Параметры строки запроса (поисковой строки) (URL Search Params) - это часть URL, следующая за ?. Эти параметры доступны в request.url:

import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

export const loader = async ({ request }: LoaderArgs) => {
const url = new URL(request.url);
const term = url.searchParams.get("term");

return json(await fakeProductSearch(term));
};

Здесь:

  • объект request содержит свойство url;
  • конструктор URL преобразует строку URL в объект;
  • url.searchParams - это экземпляр URLSearchParams, содержащий преобразованную в объект строку запроса.

Перезагрузка данных

При изменении строки запроса в случае с рендерингом нескольких вложенных роутов, все эти роуты перезагружаются. Это связано с тем, что параметры строки запроса могут использоваться любым loader в этих роутах. Для предотвращения рендеринга отдельных роутов можно использовать shouldReload (данный интерфейс пока является нестабильным, поэтому в рамках этого руководства не рассматривается).

Параметры строки запроса в компонентах

Иногда требуется читать и обновлять параметры строки запроса в компонентах, а не в loader или action. Существует несколько способов это делать.

Установка параметров строки запроса

Пример модификации строки запроса пользователем:

// app/products/shoes
export default function ProductFilters() {
return (
<Form method="get">
<label htmlFor="nike">Nike</label>
<input
type="checkbox"
id="nike"
name="brand"
value="nike"
/>

<label htmlFor="adidas">Adidas</label>
<input
type="checkbox"
id="adidas"
name="brand"
value="adidas"
/>

<button type="submit">Обновить</button>
</Form>
);
}

Если пользователь выбрал один бренд, например, Nike, то строка запроса будет иметь вид /products/shoes?brand=nike, если оба, то /products/shoes?brand=nike&brand=adidas.

Обратите внимание, что brand повторяется в строке запроса, поскольку оба чекбокса называются brand. Для доступа к этим значениям в loader предназначен метод searchParams.getAll:

import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

export async function loader({ request }: LoaderArgs) {
const url = new URL(request.url);
const brands = url.searchParams.getAll("brand");

return json(await getProducts({ brands }));
}

Дополнение URL строкой запроса

URL легко дополняется строкой запроса с помощью компонента Link:

<Link to="?brand=nike">Nike (только)</Link>

Чтение параметров строки запроса в компонентах

Для доступа к параметрам строки запроса в компонентах используется хук useSearchParams:

import { useSearchParams } from "@remix-run/react";

export default function ProductFilters() {
const [searchParams] = useSearchParams();
const brands = searchParams.getAll("brand");

return (
<Form method="get">
<label htmlFor="nike">Nike</label>
<input
type="checkbox"
id="nike"
name="brand"
value="nike"
defaultChecked={brands.includes("nike")}
/>

<label htmlFor="adidas">Adidas</label>
<input
type="checkbox"
id="adidas"
name="brand"
value="adidas"
defaultChecked={brands.includes("adidas")}
/>

<button type="submit">Обновить</button>
</Form>
);
}

С помощью хука useSubmit можно отправлять форму при изменении значения любого поля:

import {
useSubmit,
useSearchParams,
} from "@remix-run/react";

export default function ProductFilters() {
const submit = useSubmit();
const [searchParams] = useSearchParams();
const brands = searchParams.getAll("brand");

return (
<Form
method="get"
onChange={(e) => submit(e.currentTarget)}
>
{/* ... */}
</Form>
);
}

Параметры строки запроса и управляемые инпуты

Что если мы хотим синхронизировать состояние чекбоксов со строкой запроса? В случае с управляемыми инпутами (controlled inputs) сделать это не так просто, как может показаться на первый взгляд.

Предположим, что для изменения бренда в компоненте может использоваться как <input type="checkbox"> так и <Link />:

import { useSearchParams } from "@remix-run/react";

export default function ProductFilters() {
const [searchParams] = useSearchParams();
const brands = searchParams.getAll("brand");

return (
<Form method="get">
<p>
<label htmlFor="nike">Nike</label>
<input
type="checkbox"
id="nike"
name="brand"
value="nike"
defaultChecked={brands.includes("nike")}
/>
<Link to="?brand=nike">Nike</Link>
</p>

<button type="submit">Обновить</button>
</Form>
);
}

Если пользователь выбирает чекбокс и отправляет форму, URL обновляется вместе с состоянием чекбокса. Но если пользователь нажимает на ссылку, то обновляется только URL, а состояние чекбокса остается прежним. Что если переключиться с defaultChecked на checked?

<input
type="checkbox"
id="adidas"
name="brand"
value="adidas"
checked={brands.includes("adidas")}
/>

Возникает другая проблема: при клике по ссылке меняется как URL, так и состояние чекбокса, но сам чекбокс больше не работает, поскольку React не позволяет изменять состояние чекбокса до изменения контролирующего его URL.

Существует, как минимум, 2 решения.

Самым простым является автоматическая отправка формы при выборе чекбокса:

import {
useSubmit,
useSearchParams,
} from "@remix-run/react";

export default function ProductFilters() {
const submit = useSubmit();
const [searchParams] = useSearchParams();
const brands = searchParams.getAll("brand");

return (
<Form method="get">
<p>
<label htmlFor="nike">Nike</label>
<input
type="checkbox"
id="nike"
name="brand"
value="nike"
onChange={(e) => submit(e.currentTarget.form)}
checked={brands.includes("nike")}
/>
<Link to="?brand=nike">Nike</Link>
</p>

{/* ... */}
</Form>
);
}

Обратите внимание: при отправке формы в onChange() следует предотвращать распространение события submit с помощью stopPropagation() во избежание всплытия (bubble) события до формы и ее повторной отправки.

Второе решение предполагает наличие локального состояния для чекбокса:

  • инициализируем состояние с помощью параметров строки запроса;
  • обновляем состояние при выборе чекбокса пользователем;
  • обновляем состояние при изменении строки запроса.
import {
useSubmit,
useSearchParams,
} from "@remix-run/react";

export default function ProductFilters() {
const submit = useSubmit();
const [searchParams] = useSearchParams();
const brands = searchParams.getAll("brand");

const [nikeChecked, setNikeChecked] = React.useState(
// инициализируем состояние с помощью параметров строки запроса
brands.includes("nike")
);

// обновляем состояние при изменении строки запроса
// (отправка формы или нажатие ссылки)
React.useEffect(() => {
setNikeChecked(brands.includes("nike"));
}, [brands, searchParams]);

return (
<Form method="get">
<p>
<label htmlFor="nike">Nike</label>
<input
type="checkbox"
id="nike"
name="brand"
value="nike"
onChange={(e) => {
// обновляем состояние чекбокса без отправки формы
setNikeChecked(true);
}}
checked={nikeChecked}
/>
<Link to="?brand=nike">Nike</Link>
</p>

{/* ... */}
</Form>
);
}

Эту логику работы с чекбоксами можно абстрагировать следующим образом:

<div>
<SearchCheckbox name="brand" value="nike" />
<SearchCheckbox name="brand" value="reebok" />
<SearchCheckbox name="brand" value="adidas" />
</div>;

function SearchCheckbox({ name, value }) {
const [searchParams] = useSearchParams();
const all = searchParams.getAll(name);
const [checked, setChecked] = React.useState(
all.includes(value)
);

React.useEffect(() => {
setChecked(all.includes(value));
}, [all, searchParams, value]);

return (
<input
type="checkbox"
name={name}
value={value}
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
);
}

Оптимизации

Remix перезагружает все роуты в трех случаях:

  • вызов action (формы, useSubmit(), fetcher.submit());
  • изменение строки запроса;
  • переход пользователя на текущую страницу (когда пользователь кликает по ссылке, ведущей на страницу, на которой он уже находится).

Управление состоянием

Remix спроектирован таким образом, что исключает необходимость использования каких-либо библиотек для управления состоянием приложения, вроде React Query, SWR, Apollo, Relay, urql и т.д. Однако он вовсе не исключает возможность их использования в случаях, когда возможностей, предоставляемых Remix, недостаточно для реализации функционала приложения.

Заметки

loader выполняется на сервере с помощью fetch, предоставляемого браузером, поэтому данные сериализуются с помощью JSON.stringify(). Это означает, что данные, возвращаемые загрузчиком, должны быть сериализуемыми. Пример частично несериализуемых данных:

export async function loader() {
return {
date: new Date(),
someMethod() {
return "некоторое значение";
},
};
}

export default function RouteComp() {
const data = useLoaderData<typeof loader>();
console.log(data);
// '{"date":"..."}'
}

Для доступа к данным, возвращаемым загрузчиком, нельзя использовать loader:

export const loader = async () => {
return json(await fakeDb.products.findMany());
};

export default function RouteComp() {
// не работает
const data = loader();
}

Запись данных / Data Writes

Запись данных в Remix основана на двух фундаментальных веб-интерфейсах: <form> и HTTP. Несмотря на использование прогрессивного улучшения для реализации оптимистичного обновления UI, индикаторов загрузки и отображения результатов валидации, модель программирования по-прежнему основывается на формах HTML.

При отправке формы Remix:

  • вызывает action() для формы;
  • перезагружает все данные (loader()) для всех роутов страницы.

Существует несколько способов вызвать action() и выполнить повторную валидацию роутов:

  • <Form>
  • useSubmit()
  • useFetcher()

В данном разделе мы будем говорить только о формах.

Формы

Нативные формы поддерживают 2 глагола (verbs) HTTP: GET и POST. Эти глаголы сообщают Remix о наших намерениях. В случае с GET Remix определяет, какие части страницы меняются и запрашивает данные только для этих частей, а данные для частей, оставшихся прежними, доставляются из кэша. В случае с POST Remix перезагружает все данные для обеспечения их согласованности с серверными данными.

GET

GET - это обычная навигация (navigation), когда данные формы конвертируются в строку запроса URL. Рассмотрим такую форму:

<form method="get" action="/search">
<label>Искать <input name="term" type="text" /></label>
<button type="submit">Поиск</button>
</form>

Когда пользователь заполняет поле term и нажимаем "Поиск", браузер преобразуется данные формы в строку запроса и добавляет ее к URL, указанному в action. Предположим, что пользователь ввел Remix. Тогда браузер выполнит перенаправление к /search?term=remix. Если названием инпута будет q, браузер выполнит перенаправление к /search?q=remix.

Пример с бОльшим количеством полей:

<form method="get" action="/search">
<fieldset>
<legend>Brand</legend>
<label>
<input name="brand" value="nike" type="checkbox" />
Nike
</label>
<label>
<input name="brand" value="reebok" type="checkbox" />
Reebok
</label>
<label>
<input name="color" value="white" type="checkbox" />
Белый
</label>
<label>
<input name="color" value="black" type="checkbox" />
Черный
</label>
<button type="submit">Поиск</button>
</fieldset>
</form>

В зависимости от того, какие чекбоксы выбрал пользователь, браузер будет выполнять перенаправления к таким URL:

/search?brand=nike&color=black
/search?brand=nike&brand=reebok&color=white

POST

Для создания, обновления или удаления данных используется метод POST. И речь идет не только о больших формах, например, для редактирования профиля пользователя, но и о кнопках типа "Нравится", обернутых в форму. Рассмотрим такую форму для создания нового проекта:

<form method="post" action="/projects">
<label>Название: <input name="name" type="text" /></label>
<label>Описание: <textarea name="description"></textarea></label>
<button type="submit">Создать</button>
</form>

Когда пользователь нажимает "Создать", браузер преобразует данные формы в тело запроса (request body) и отправляет запрос на сервер. После этого данные становятся доступными для обработчика запросов на сервере, что позволяет создать ноую запись в БД. Обработчик должен вернуть ответ. Вероятно, после создания записи нам следует перенаправить пользователя на страницу с только что созданным проектом:

export async function action({ request }: ActionArgs) {
const body = await request.formData();
// создаем проект
const project = await createProject(body);
// выполняем перенаправление
return redirect(`/projects/${project.id}`);
}

Собственно, это все, что требуется для выполнения мутации данных в Remix.

Пример продвинутой мутации

В этом примере мы реализуем следующее:

  • опциональное использование JS;
  • валидация формы;
  • обработка ошибок;
  • индикаторы загрузки (прогрессивное улучшение);
  • отображение ошибок (прогрессивное улучшение).

Обратите внимание: для мутации данных в примере будет использоваться компонент Form. Использование этого компонента вместо нативного элемента form частично отключает дефолтное поведение браузера по обработке отправки формы. Если вы не планируете реализовывать кастомные индикаторы загрузки и оптимистичное обновление UI, передайте Form проп reloadDocument. Это сделает Form и form полностью идентичными.

Форма

Допустим, что в app/routes/projects/new.tsx у нас имеется такая форма:

import { Form } from "@remix-run/react";

export default function NewProject() {
return (
<Form method="post" action="/projects/new">
<p>
<label>
Название: <input name="name" type="text" />
</label>
</p>
<p>
<label>
Описание:
<br />
<textarea name="description" />
</label>
</p>
<p>
<button type="submit">Создать</button>
</p>
</Form>
);
}

Добавляем action() для этой формы (POST-запросы обрабатываются action(), а GET-запросы - loader()):

import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";

export const action = async ({ request }: ActionArgs) => {
const formData = await request.formData();
// создаем проект
const project = await createProject(formData);
// выполняем перенаправление
return redirect(`/projects/${project.id}`);
};

export default function NewProject() {
// ...
}

Валидация

Предположим, что наш API возвращает ошибки валидации следующим образом:

const [errors, project] = await createProject(formData);

При наличии ошибок мы возвращаемся к форме и показываем их:

export const action = async ({ request }: ActionArgs) => {
const formData = await request.formData();
const [errors, project] = await createProject(formData);

if (errors) {
const values = Object.fromEntries(formData);
return json({ errors, values });
}

return redirect(`/projects/${project.id}`);
};

Для доступа к данным, возвращаемым функцией action используется хук useActionData:

import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { useActionData } from "@remix-run/react";

export const action = async ({ request }: ActionArgs) => {
// ...
};

export default function NewProject() {
const actionData = useActionData<typeof action>();

return (
<form method="post" action="/projects/new">
<p>
<label>
Название:{" "}
<input
name="name"
type="text"
defaultValue={actionData?.values.name}
/>
</label>
</p>

{actionData?.errors.name ? (
<p style={{ color: "red" }}>
{actionData.errors.name}
</p>
) : null}

<p>
<label>
Описание:
<br />
<textarea
name="description"
defaultValue={actionData?.values.description}
/>
</label>
</p>

{actionData?.errors.description ? (
<p style={{ color: "red" }}>
{actionData.errors.description}
</p>
) : null}

<p>
<button type="submit">Создать</button>
</p>
</form>
);
}

Обратите внимание на атрибут defaultValue. При отсутствии этого атрибута форма будет очищаться при каждой отправке.

Состояние ожидания

Для доступа к состоянию ожидания или перехода (transition) используется хук useTransition:

import { redirect } from "@remix-run/node";
import {
useActionData,
Form,
useTransition,
} from "@remix-run/react";

// ...

export default function NewProject() {
const transition = useTransition();
const actionData = useActionData<typeof action>();

return (
<Form method="post">
<fieldset
disabled={transition.state === "submitting"}
>
<p>
<label>
Название:{" "}
<input
name="name"
type="text"
defaultValue={
actionData
? actionData.values.name
: undefined
}
/>
</label>
</p>

{actionData && actionData.errors.name ? (
<p style={{ color: "red" }}>
{actionData.errors.name}
</p>
) : null}

<p>
<label>
Описание:
<br />
<textarea
name="description"
defaultValue={
actionData
? actionData.values.description
: undefined
}
/>
</label>
</p>

{actionData && actionData.errors.description ? (
<p style={{ color: "red" }}>
{actionData.errors.description}
</p>
) : null}

<p>
<button type="submit">
{transition.state === "submitting"
? "Создание..."
: "Создать"}
</button>
</p>
</fieldset>
</Form>
);
}

Теперь при нажатии "Создать" пользователем происходит блокировка полей формы и текст кнопки меняется на "Создание...".

Кроме данных о состоянии отправки формы, содержащихся в transition.submission, мы можем получить доступ к данным формы через transition.formData.

Сообщения об ошибках

Реализуем простой компонент для анимирования высоты и прозрачности сообщений об ошибках:

type Props = {
error?: string;
isSubmitting: boolean;
}

function ValidationMessage({ error, isSubmitting }: Props) {
const [show, setShow] = useState(!!error);

useEffect(() => {
const id = setTimeout(() => {
const hasError = !!error;
setShow(hasError && !isSubmitting);
});
return () => clearTimeout(id);
}, [error, isSubmitting]);

return (
<div
style={{
opacity: show ? 1 : 0,
height: show ? "1em" : 0,
color: "red",
transition: "all 300ms ease-in-out",
}}
>
{error}
</div>
);
}

Оборачиваем сообщения об ошибках в этот компонент и меняем цвет границ инпутов на красный при наличии ошибки:

export default function NewProject() {
const transition = useTransition();
const actionData = useActionData<typeof action>();

return (
<Form method="post">
<fieldset
disabled={transition.state === "submitting"}
>
<p>
<label>
Название:{" "}
<input
name="name"
type="text"
defaultValue={
actionData
? actionData.values.name
: undefined
}
style={{
borderColor: actionData?.errors.name
? "red"
: "",
}}
/>
</label>
</p>

{actionData?.errors.name ? (
<ValidationMessage
isSubmitting={transition.state === "submitting"}
error={actionData?.errors?.name}
/>
) : null}

<p>
<label>
Описание:
<br />
<textarea
name="description"
defaultValue={actionData?.values.description}
style={{
borderColor: actionData?.errors.description
? "red"
: "",
}}
/>
</label>
</p>

<ValidationMessage
isSubmitting={transition.state === "submitting"}
error={actionData?.errors.description}
/>

<p>
<button type="submit">
{transition.state === "submitting"
? "Создание..."
: "Создать"}
</button>
</p>
</fieldset>
</Form>
);
}

Оптимистичное обновление UI / Optimistic UI

Оптимистичное обновление UI (оптимистичный UI) - это паттерн, позволяющий обновлять UI моментально с помощью "фейковых" (в том смысле, что они отличаются от серверных) данных без отображения индикаторов загрузки на время, необходимое серверу для обновления реальных данных. Как правило, на клиенте у нас для этого имеется достаточно данных. Если в процессе обновления данных на сервере возникла ошибка, мы восстанавливаем предыдущее состояние UI и сообщаем о проблеме пользователю. Однако в большинстве случаев обновление данных на сервере проходит успешно.

В Remix оптимистичное обновление UI реализуется с помощью хуков useTransition и useFetcher.

Стратегия

  1. Пользователь отправляет форму (или мы делаем это сами с помощью useSubmit() или fetcher.submit()).
  2. Remix выполняет отправку формы и предоставляет в наше распоряжение данные формы в transition.submission или fetcher.submission.
  3. Приложение использует submission.formData для рендеринга оптимистичной версии страницы, т.е. той страницы, которая должна рендериться в случае успешной отправки формы.
  4. Remix автоматически ревалидирует все данные:
    • при успешной отправке формы пользователь ничего не замечает;
    • при неудачной отправке восстанавливается исходное состояние страницы, а пользователь получает сообщение об ошибке.

Пример

Рассмотрим процесс создания нового проекта.

Роут проекта загружает проект и рендерит его:

// app/routes/project/$id.tsx
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import { ProjectView } from "~/components/project";

export async function loader({ params }: LoaderArgs) {
return json(await findProject(params.id));
}

export default function ProjectRoute() {
const project = useLoaderData<typeof loader>();

return <ProjectView project={project} />;
}

Критически важным здесь является то, что роут проекта рендерит повторно используемый компонент ProjectView, который в дальнейшем будет использоваться для оптимистичного обновления UI. Этот компонент может выглядеть так:

// app/components/project.tsx
export type { Project } from "~/types";

type Props = {
project: Project
}

export function ProjectView({ project }: Props) {
return (
<div>
<h2>{project.title}</h2>
<p>{project.description}</p>
<ul>
{project.tasks.map((task) => (
<li key={task.id}>{task.name}</li>
))}
</ul>
</div>
);
}

Реализуем роут создания проекта:

// app/routes/projects/new.tsx
import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";

import { createProject } from "~/utils";

export const action = async ({ request }: ActionArgs) => {
const body = await request.formData();
const newProject = Object.fromEntries(body);

const project = await createProject(newProject);

return redirect(`/projects/${project.id}`);
};

export default function NewProject() {
return (
<>
<h2>Новый проект</h2>
<Form method="post">
<label>
Название: <input name="title" type="text" />
</label>
<label htmlFor="description">Описание:</label>
<textarea name="description" id="description" />
<button type="submit">Создать</button>
</Form>
</>
);
}

useTransition() позволяет легко реализовать индикатор загрузки на время отправки формы, создания новой записи в БД и ответа сервера в виде перенаправления на страницу проекта:

import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
// !
import { Form, useTransition } from "@remix-run/react";

import { createProject } from "~/utils";

export const action = async ({ request }: ActionArgs) => {
const body = await request.formData();
const newProject = Object.fromEntries(body);
const project = await createProject(newProject);
return redirect(`/projects/${project.id}`);
};

export default function NewProject() {
// !
const transition = useTransition();

return (
<>
<h2>Новый проект</h2>
<Form method="post">
<label>
Название: <input name="title" type="text" />
</label>
<label htmlFor="description">Описание:</label>
<textarea name="description" id="description" />
<button
type="submit"
// !
disabled={transition.submission}
>
{transition.submission
? "Создание..."
: "Создать"}
</button>
</Form>
</>
);
}

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

import type { ActionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form, useTransition } from "@remix-run/react";

// !
import { ProjectView } from "~/components/project";
import { createProject } from "~/utils";

export const action = async ({ request }: ActionArgs) => {
const body = await request.formData();
const newProject = Object.fromEntries(body);
const project = await createProject(newProject);
return redirect(`/projects/${project.id}`);
};

export default function NewProject() {
const transition = useTransition();
// !
return transition.submission ? (
<ProjectView
project={Object.fromEntries(
transition.submission.formData
)}
/>
) : (
<>
<h2>Новый проект</h2>
<Form method="post">
<label>
Название: <input name="title" type="text" />
</label>
<label htmlFor="description">Описание:</label>
{/* ! */}
<textarea name="description" id="description" />
<button type="submit">Создать</button>
</Form>
</>
);
}

Теперь после того как пользователь нажал "Создать", UI обновляется мгновенно. После успешного создания проекта, пользователь перенаправляется на его страницу. Поскольку в обоих случаях для рендеринга UI используется компонент ProjectView, все, что может заметить пользователь, это изменение URL.

Самой сложной частью оптимистичного обновления UI является обработка потенциальных ошибок и уведомление пользователя о том, что операция провалилась. Здесь существует 2 варианта: позволить Remix обработать ошибку автоматически посредством рендеринга ближайшего ErrorBoundary или вернуть ошибку из loader(). Рассмотрим второй вариант:

import type { ActionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
Form,
useActionData,
useTransition,
} from "@remix-run/react";

import { ProjectView } from "~/components/project";
import { createProject } from "~/utils";

export const action = async ({ request }: ActionArgs) => {
const body = await request.formData();
const newProject = Object.fromEntries(body);

try {
const project = await createProject(newProject);

return redirect(`/projects/${project.id}`);
} catch (e: unknown) {
console.error(e);

return json("Во время создания проекта произошла ошибка", {
status: 500,
});
}
};

export default function NewProject() {
const error = useActionData<typeof action>();
const transition = useTransition();

return transition.submission ? (
<ProjectView
project={Object.fromEntries(
transition.submission.formData
)}
/>
) : (
<>
<h2>Новый проект</h2>
<Form method="post">
<label>
Название: <input name="title" type="text" />
</label>
<label htmlFor="description">Описание:</label>
<textarea name="description" id="description" />
<button type="submit">Создать</button>
</Form>
{error ? <p>{error}</p> : null}
</>
);
}

Теперь при возникновении ошибки пользователь возвращается к форме и видит соответствующее сообщение.

Валидация на стороне клиента

Во избежание лишних ошибок, связанных с отсутствием или неверным форматом данных, необходимых для создания проекта, может быть полезным валидировать значения полей формы на клиенте. К счастью, это легко реализовать с помощью встроенных механизмов HTML-элементов:

import type { ActionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
Form,
useActionData,
useTransition,
} from "@remix-run/react";

import { ProjectView } from "~/components/project";
import { createProject } from "~/utils";

export const action = async ({ request }: ActionArgs) => {
const body = await request.formData();
const newProject = Object.fromEntries(body);
try {
const project = await createProject(newProject);
return redirect(`/projects/${project.id}`);
} catch (e: unknown) {
console.error(e);
return json("Во время создания проекта произошла ошибка", {
status: 500,
});
}
};

export default function NewProject() {
const error = useActionData<typeof action>();
const transition = useTransition();

return transition.submission ? (
<ProjectView
project={Object.fromEntries(
transition.submission.formData
)}
/>
) : (
<>
<h2>Новый проект</h2>
<Form method="post">
<label>
Название:{" "}
<input
// минимальная длина названия проекта - 3 символа
minLength={3}
name="title"
// название проекта является обязательным
required
type="text"
/>
</label>
<label htmlFor="description">Описание:</label>
<textarea name="description" id="description" />
<button type="submit">Создать</button>
</Form>
{error ? <p>{error}</p> : null}
</>
);
}

К слову, шаблон поля для ввода email может выглядеть так:

<input
type="email"
name="email"
// !
pattern="[^@\s]+@[^@\s]+\.[^@\s]+"
required
/>

Ограничения модулей / Module Constraints

Поскольку приложение Remix выполняется как на сервере, так и в браузере, следует проявлять осторожность в отношении побочных эффектов модулей (module side effects).

  • серверный код - Remix не сможет удалить серверный код из сборки для браузера при наличии побочных эффектов, использующих серверный код;
  • клиентский код - Remix рендерит страницы на сервере, поэтому модули на верхнем уровне не должны содержать код, обращающийся в браузерным API.

Удаление серверного кода из сборки для браузера

Для удаления серверного кода из сборки для клиента Remix делает следующее:

  • создает "проксирующий" модуль для роута;
  • этот модуль импортирует только браузерные экспорты.

Рассмотрим модуль, экспортирующий функции loader, meta и компонент:

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import PostsView from "../PostsView";
import { prisma } from "../db";

export async function loader() {
return json(await prisma.post.findMany());
}

export function meta() {
return { title: "Список постов" };
}

export default function Posts() {
const posts = useLoaderData<typeof loader>();
return <PostsView posts={posts} />;
}

Серверу требуются весь код модуля, а браузеру - только компонент и meta(). Если prisma попадет в клиентскую сборку, то модуль сломается, поскольку prisma использует большое количество API Node.js.

Прокси-модуль для этого роута выглядит так:

export { meta, default } from "./routes/posts.tsx";

А код для клиентской сборки так:

import { useLoaderData } from "@remix-run/react";

import PostsView from "../PostsView";

export function meta() {
return { title: "Список постов" };
}

export default function Posts() {
const posts = useLoaderData<typeof loader>();

return <PostsView posts={posts} />;
}

Побочные эффекты модулей

Простыми словами, побочный эффект - это код, который что-то делает, а побочный эффект модуля - это код, который что-то делает при загрузке модуля.

Например, добавление такой строки кода в предыдущий пример сломает модуль:

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import PostsView from "../PostsView";
import { prisma } from "../db";
// !
console.log(prisma);

export async function loader() {
return json(await prisma.post.findMany());
}

export function meta() {
return { title: "Список постов" };
}

export default function Posts() {
const posts = useLoaderData<typeof loader>();
return <PostsView posts={posts} />;
}

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

import { useLoaderData } from "@remix-run/react";

import PostsView from "../PostsView";
// !
import { prisma } from "../db"; // 😬
// !
console.log(prisma); // 🥶

export function meta() {
return { title: "Список постов" };
}

export default function Posts() {
const posts = useLoaderData<typeof loader>();
return <PostsView posts={posts} />;
}

loader() была удалена, но импорт prisma остался.

В данном случае проблему легко устранить путем перемещения логгирования в loader():

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import PostsView from "../PostsView";
import { prisma } from "../db";

export async function loader() {
// !
console.log(prisma);
return json(await prisma.post.findMany());
}

export function meta() {
return { title: "Список постов" };
}

export default function Posts() {
const posts = useLoaderData<typeof loader>();
return <PostsView posts={posts} />;
}

Теперь импорт prisma будет удален вместе с loader().

Если вы столкнулись с ситуацией, когда серверный код попадает в сборку для браузера при отсутствии побочных эффектов модулей, перенесите соответствующий код в файл, название которого содержит суффикс server, например, db.server.ts. Это сообщит Remix, что такой код используется только на сервере.

Функции более высокого порядка

При работе с Remix велик соблазн абстрагировать loader() с помощью функций более высокого порядка (higher order functions), например, так:

import { redirect } from "@remix-run/node";

export function removeTrailingSlash(loader) {
return function (arg) {
const { request } = arg;
const url = new URL(request.url);
if (
url.pathname !== "/" &&
url.pathname.endsWith("/")
) {
return redirect(request.url.slice(0, -1), {
status: 308,
});
}
return loader(arg);
};
}

И использовать их следующим образом:

import { removeTrailingSlash } from "~/http";

export const loader = removeTrailingSlash(({ request }) => {
return { some: "data" };
});

Это делает loader() побочным эффектом модуля, поэтому он не будет удален из клиентской сборки.

Для того, чтобы реализовать ранний возврат ответа, достаточно выбросить ответ в loader():

import { redirect } from "@remix-run/node";

export function removeTrailingSlash(url) {
if (url.pathname !== "/" && url.pathname.endsWith("/")) {
throw redirect(request.url.slice(0, -1), {
status: 308,
});
}
}

Пример использования:

import { json } from "@remix-run/node";

import { removeTrailingSlash } from "~/http";

export const loader = async ({ request }: LoaderArgs) => {
removeTrailingSlash(request.url);
return json({ some: "data" });
};

Это позволит Remix исключить loader из сборки для браузера. Кроме того, такой код легче читать. Сравните следующие блоки кода:

// 1
export const loader = async ({ request }: LoaderArgs) => {
return removeTrailingSlash(request.url, () => {
return withSession(request, (session) => {
return requireUser(session, (user) => {
return json(user);
});
});
});
};

// 2
export const loader = async ({ request }: LoaderArgs) => {
removeTrailingSlash(request.url);
const session = await getSession(request);
const user = await requireUser(session);
return json(user);
};

Какой вам нравится больше? Полагаю, ответ очевиден. В данном случае можно провести аналогию между использованием async/await вместо промисов и коллбэков.

Браузерный код на сервере

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

Следующий пример сломает приложение:

import { loadStripe } from "@stripe/stripe-js";

// переменная `window` не определена, поскольку код выполняется на сервере
const stripe = await loadStripe(window.ENV.stripe);

export async function redirectToStripeCheckout(sessionId) {
return stripe.redirectToCheckout({ sessionId });
}

Инициализация браузерных API

Существует несколько способов инициализации браузерных интерфейсов при импорте модуля.

Защитник документа (document guard)

import firebase from "firebase/app";

// !
if (typeof document !== "undefined") {
firebase.initializeApp(document.ENV.firebase);
}

export { firebase };

Если переменная document определена, значит средой выполнения кода является браузер. Проверка "определенности" document является более надежным способом идентификации браузера, чем window, потому что в Deno, например, имеется глобальная переменная window.

Ленивая инициализация

Суть данного подхода состоит в том, что браузерный API инициализируется при использовании библиотеки (вызове функции):

import { loadStripe } from "@stripe/stripe-js";

export async function redirectToStripeCheckout(sessionId) {
// !
const stripe = await loadStripe(window.ENV.stripe);
return stripe.redirectToCheckout({ sessionId });
}

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

import { loadStripe } from "@stripe/stripe-js";

let _stripe;
async function getStripe() {
if (!_stripe) {
_stripe = await loadStripe(window.ENV.stripe);
}
return _stripe;
}

export async function redirectToStripeCheckout(sessionId) {
const stripe = await getStripe();
return stripe.redirectToCheckout({ sessionId });
}

Рендеринг с браузерными API

Другим распространенным случаем является обращение к браузерным интерфейсам в процессе рендеринга.

Следующим пример сломает приложение, поскольку сервер попытается использовать локальное хранилище (local storage):

function useLocalStorage(key) {
const [state, setState] = useState(
localStorage.getItem(key)
);

const setWithLocalStorage = (nextState) => {
setState(nextState);
};

return [state, setWithLocalStorage];
}

Для решения проблемы достаточно перенести инициализацию состояния в хук useEffect:

function useLocalStorage(key) {
const [state, setState] = useState(null);

useEffect(() => {
setState(localStorage.getItem(key));
}, [key]);

const setWithLocalStorage = (nextState) => {
setState(nextState);
};

return [state, setWithLocalStorage];
}

Напоследок, рассмотрим кастомный хук, позволяющий безопасно использовать хук useLayoutEffect:

import React from "react";

const canUseDOM = typeof document !== "undefined";

const useLayoutEffect = canUseDOM
? React.useLayoutEffect
: () => {};

Обработка ошибок / Error Handling

Remix автоматически перехватывает (catch) ошибку и рендерит ближайший (к месту ее возникновения) ErrorBoundary (предохранитель) в случаях, когда ошибка возникла в процессе:

  • рендеринга в браузере;
  • рендеринга на сервере;
  • в loader() при первоначальном запросе на серверный рендеринг документа;
  • в action() при первоначальном запросе на серверный рендеринг документа;
  • в loader() при переходе на стороне клиента в браузере (Remix сериализует ошибку и отправляет ее по сети в браузер);
  • в action() при переходе на стороне клиента в браузере.

Корневой предохранитель

При использовании стартового шаблона в файле root.{tsx|jsx} имеется такой предохранитель:

export function ErrorBoundary({ error }) {
console.error(error);

return (
<html>
<head>
<title>О, нет!</title>
<Meta />
<Links />
</head>
<body>
{/* UI, который будет рендериться при возникновении ошибки */}
<Scripts />
</body>
</html>
);
}

При рендеринге корневого предохранителя перемонтируется весь документ - это объясняет необходимость рендеринга компонентов Meta, Links и Scripts.

Вложенные предохранители

Каждый роут может содержать собственный предохранитель. Преимущество такого подхода состоит в том, что при возникновении ошибки в роуте с предохранителем только определенная часть UI окажется сломанной, остальная страница будет выглядеть как задумывалось.

Рассмотрим такую иерархию роутов:

├── sales
│ ├── invoices
│ │ └── $invoiceId.js
│ └── invoices.js
└── sales.js

Если $invoiceId.js экспортирует ErrorBoundary и ошибка возникает в его компоненте, loader() или action(), сломается только раздел счета (invoice), остальная часть страницы будет успешно отрендерена.

Если роут не содержит собственного предохранителя, ошибка "всплывает" до ближайшего родительского предохранителя, поэтому нет необходимости экспортировать ErrorBoundary из каждого модуля.

Обработка ошибки 404 / Not Found Handling

Случаи отправки статус-кода 404:

  • URL не совпадает ни с одним роутом;
  • loader() не обнаружил необходимых данных.

Первый случай обрабатывается Remix автоматически, обработка второго случая - задача разработчика.

При отсутствии данных, запрашиваемых пользователем, необходимо выбросить ответ:

export async function loader({ params }: LoaderArgs) {
const page = await db.page.findOne({
where: { slug: params.slug },
});

if (!page) {
throw new Response("Не найдено", {
status: 404,
});
}

return json(page);
}

Remix перехватывает такой ответ и передает управление ближайшему перехватчику (catch boundary). Перехватчик похож на предохранитель (error boundary), только вместо компонента ErrorBoundary из модуля должен экспортироваться компонент CatchBoundary.

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

Корневой перехватчик

Корневой перехватчик может выглядеть так:

export function CatchBoundary() {
const caught = useCatch();

return (
<html>
<head>
<title>Упс!</title>
<Meta />
<Links />
</head>
<body>
<h1>
{caught.status} {caught.statusText}
</h1>
<Scripts />
</body>
</html>
);
}

Вложенные перехватчики

Каждый роут может экспортировать собственного перехватчика:

import type { LoaderArgs } from "@remix-run/node";
import {
Form,
useLoaderData,
useParams,
} from "@remix-run/react";

export async function loader({ params }: LoaderArgs) {
const page = await db.page.findOne({
where: { slug: params.slug },
});

if (!page) {
throw new Response("Не найдено", {
status: 404,
});
}

return json(page);
}

export function CatchBoundary() {
const params = useParams();

return (
<div>
<h2>Запрашиваемая страница отсутствует</h2>
<Form action="../create">
<button
type="submit"
name="slug"
value={params.slug}
>
Создать {params.slug}?
</button>
</Form>
</div>
);
}

export default function Page() {
return <PageView page={useLoaderData<typeof loader>()} />;
}

Понятно, что так могут обрабатываться любые ответы, а не только 404.

Переменные среды окружения / Environment Variables

Remix имеет встроенную поддержку dotenv в режиме разработки, поэтому при выполнении команды remix dev переменные среды окружения доступны через process.env.

Создаем файл .env в корне проекта:

touch .env

Редактируем этот файл:

SOME_SECRET=super-secret

Получаем доступ к этой переменной в loader() или action():

export async function loader() {
console.log(process.env.SOME_SECRET);
}

При выполнении команды remix serve переменные среды окружения в process.env не загружаются, они должны устанавливаться производственным сервером. Способы их установки зависят от используемого хостинга.

При необходимости доступа к переменным на клиенте можно сделать следующее:

  • вернуть ENV из корневого loader():
export async function loader() {
return json({
ENV: {
STRIPE_PUBLIC_KEY: process.env.STRIPE_PUBLIC_KEY,
FAUNA_DB_URL: process.env.FAUNA_DB_URL,
},
});
}

export function Root() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
);
}
  • записать ENV в глобальный объект window:
export async function loader() {
return json({
ENV: {
STRIPE_PUBLIC_KEY: process.env.STRIPE_PUBLIC_KEY,
},
});
}

export function Root() {
const data = useLoaderData<typeof loader>();

return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(
data.ENV
)}`,
}}
/>
<Scripts />
</body>
</html>
);
}
import { loadStripe } from "@stripe/stripe-js";

export async function redirectToStripeCheckout(
sessionId
) {
const stripe = await loadStripe(
window.ENV.STRIPE_PUBLIC_KEY
);
return stripe.redirectToCheckout({ sessionId });
}

Стилизация / Styling

Основным способом стилизации веб-приложений является добавление <link rel="stylesheet"> на страницу. В Remix это делается посредством экспорта из роута функции links. Когда роут является активным, таблица стилей добавляется на страницу. Когда роут больше не является активным, таблица стилей удаляется со страницы.

export function links() {
return [
{
rel: "stylesheet",
href: "https://unpkg.com/modern-css-reset@1.4.0/dist/reset.min.css",
},
];
}

links() вложенных роутов объединяются и рендерятся по порядку (начиная с корневого роута) в виде тегов link с помощью компонента Link в разделе head в файле app/root.jsx:

import { Links } from "@remix-run/react";

export default function Root() {
return (
<html>
<head>
<Links />
{/* ... */}
</head>
{/* ... */}
</html>
);
}

Файлы CSS могут импортироваться в модули напрямую. В этом случае Remix:

  • копирует файл в директорию для клиентской сборки;
  • создает отпечаток (fingerprint) файла для долгосрочного кэширования;
  • возвращает URL для использования в модуле в процессе рендеринга.
import styles from "~/styles/global.css";

export function links() {
return [{ rel: "stylesheet", href: styles }];
}

Преимущества использования <link> для загрузки стилей заключаются в следующем:

  • URL может кэшироваться браузером и CDN;
  • один и тот же URL может использоваться разными страницами приложения;
  • таблица стилей может загружаться одновременно с JS;
  • ресурсы CSS могут запрашиваться предварительно с помощью <link rel="prefetch" />;
  • изменения в компонентах не ломают кэш для стилей;
  • изменения в стилях не ломают кэш для JS.

Обычные таблицы стилей

Стили роутов

Каждый роут может добавлять собственные стили на страницу:

// app/routes/dashboard.tsx
import styles from "~/styles/dashboard.css";

export function links() {
return [{ rel: "stylesheet", href: styles }];
}
// app/routes/accounts.tsx
import styles from "~/styles/accounts.css";

export function links() {
return [{ rel: "stylesheet", href: styles }];
}
// app/routes/sales.tsx
import styles from "~/styles/sales.css";

export function links() {
return [{ rel: "stylesheet", href: styles }];
}

Соотношение URL и загружаемых стилей может выгялдеть так:

  • /dashboard - dashboard.css;
  • /dashboard/accounts - dashboard.css и accounts.css;
  • /dashboard/sales - dashboard.css и sales.css.

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

Распределенные стили

В любом приложении имеются общие (распределенные - shared) компоненты: формы, кнопки, макеты и др. В Remix для стилизации таких компонентов рекомендуется использовать один из 2 подходов.

Глобальная таблица стилей

Создаем файл shared.css (или global.css):

/* app/styles/shared.css */
/* используем названия классов */
.PrimaryButton {
/* ... */
}

.TileGrid {
/* ... */
}

/* или data-атрибуты */
[data-primary-button] {
/* ... */
}

[data-tile-grid] {
/* ... */
}

Импортируем этот файл в app/root.tsx:

// app/root.tsx
import styles from "~/styles/shared.css";

export function links() {
return [{ rel: "stylesheet", href: styles }];
}

Совместное использование стилей

Создаем CSS-файл для каждого компонента и загружаем стили в роуте, который его использует.

Предположим, что у нас имеется компонент Button в app/components/button/index.js со стилями в app/components/button/styles.css, а также компонент PrimaryButton, расширяющий Button.

/* app/components/button/styles.css */
.btn {
border: solid 1px;
background: white;
color: #454545;
}
// app/components/button/index.jsx
import styles from "./styles.css";

export const links = () => [
{ rel: "stylesheet", href: styles },
];

export const Button = React.forwardRef(
({ children, ...props }, ref) => {
return <button {...props} ref={ref} className="btn" />;
}
);
Button.displayName = "Button";

Обратите внимание, что Button экспортирует links(), хотя не является роутом.

/* app/components/button-primary/styles.css */
.btn-primary {
background: blue;
color: white;
}
// app/components/button-primary/index.jsx
import { Button, links as buttonLinks } from "../button";
import styles from "./styles.css";

export const links = () => [
...buttonLinks(),
{ rel: "stylesheet", href: styles },
];

export const PrimaryButton = React.forwardRef(
({ children, ...props }, ref) => {
return (
<Button {...props} ref={ref} className="btn btn-primary" />
);
}
);
PrimaryButton.displayName = "PrimaryButton";

Обратите внимание, что PrimaryButton включает стили для Button.

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

import styles from "~/styles/index.css";

import {
PrimaryButton,
links as primaryButtonLinks,
} from "~/components/button-primary";

export function links() {
return [
...primaryButtonLinks(),
{ rel: "stylesheet", href: styles },
];
}

Предварительная загрузка ресурсов

С помощью тега link могут загружаться не только стили, но и другие ресурсы, используемые на странице, например, изображения:

/* app/components/copy-to-clipboard/style.css */
.copy-to-clipboard {
background: url("/icons/clipboard.svg");
}
// app/components/copy-to-clipboard/index.jsx
import styles from "./styles.css";

export const links = () => [
{
rel: "preload",
href: "/icons/clipboard.svg",
as: "image",
type: "image/svg+xml",
},
{ rel: "stylesheet", href: styles },
];

export const CopyToClipboard = React.forwardRef(
({ children, ...props }, ref) => {
return (
<Button {...props} ref={ref} className="copy-to-clipboard" />
);
}
);
CopyToClipboard.displayName = "CopyToClipboard";

Медиа-запросы

Атрибут media позволяет загружать определенные стили на основе ширины экрана пользователя и т.п.:

export function links() {
return [
{
rel: