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
.
Стратегия
- Пользователь отправляет форму (или мы делаем это сами с помощью
useSubmit()
илиfetcher.submit()
). - Remix выполняет отправку формы и предоставляет в наше распоряжение данные формы в
transition.submission
илиfetcher.submission
. - Приложение использует
submission.formData
для рендеринга оптимистичной версии страницы, т.е. той страницы, которая должна рендериться в случае успешной отправки формы. - 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}
</>
);
}
Теперь при возникновении ошибки пользователь возвращается к форме и видит соответствующее сообщение.