Привет, друзья!


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


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


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

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


Очевидно, что разработчики Remix вдохновлялись Next.js и Svelte.


К слову, здесь вы найдете полное руководство по Next.js.


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


Это часть номер два.


Вот ссылка на часть номер раз.


Содержание



Интерфейс модуля роута / Route Module API


Как правило, роут используется для рендеринга определенной части пользовательского интерфейса (User Interface, UI) приложения, такого как компонент React с серверными хуками жизненного цикла.


Функция, экспортируемая по умолчанию


Это компонент, который рендерится при совпадении с роутом (его путем или адресом):


export default function SomeRouteComponent() {
  return (
    <div>
      <h1>Посмотрите-ка!</h1>
      <p>Я использую React уже 7 лет.</p>
    </div>
  );
}

loader


Каждый роут может определять функцию загрузки — loader, которая вызывается на сервере перед рендерингом для предоставления данных роуту. loader() можно рассматривать как обработчик запроса GET, поэтому мы не должны читать в ней тело запроса (request body) — это задача функции action (см. ниже).


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

export const loader: LoaderFunction = async () => {
  // функция `json` конвертирует сериализуемый объект в ответ в формате JSON
  return json({ ok: true });
};

Данная функция выполняется только на сервере. При начальном рендеринге она предоставляет данные для документа HTML. При навигации в браузере Remix вызывает ее с помощью fetch(). Это означает, что в action() можно напрямую обращаться к базе данных, использовать секреты серверных интерфейсов и т.д. Код, который не используется для рендеринга UI, удаляется из сборки для браузера.


Пример использования объектно-реляционного отображения (Object-Relational Mapping, ORM) Prisma:


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

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

export const loader = async () => {
  // получаем данные всех пользователей из БД
  return json(await prisma.user.findMany());
};

export default function Users() {
  const users = useLoaderData();

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Remix предоставляет ("полифилит") fetch() для сервера, который можно использовать в loader().


Аргументы, передаваемые loader()


params


Параметры строки запроса роута передаются loader(). Например, если у нас есть loader() по адресу data/invoices/$invoiceId.tsx, Remix извлечет invoiceId и передаст его loader(). Это может быть полезным для получения определенных данных из API или БД:


// предположим, что пользователь посетил /invoices/123
export const loader: LoaderFunction = async ({
  params,
}) => {
  console.log(params.invoiceId); // "123"
};

request


Экземпляр Fetch Request с информацией о запросе.


Данный аргумент может использоваться для чтения заголовков HTTP запроса, URL или URLSearchParams, например:


export const loader: LoaderFunction = async ({
  request,
}) => {
  // читаем куки
  const cookie = request.headers.get("Cookie");

  // разбираем параметры строки запроса
  const searchParams = new URLSearchParams(request.url);
  const search = searchParams.get("search");
};

context


Контекст, передаваемый функции getLoadContext адаптера сервера. Это способ построить мост между API запроса/ответа адаптера и приложением Remix.


Допустим, наш сервер Express выглядит так:


const {
  createRequestHandler,
} = require("@remix-run/express");

app.all(
  "*",
  createRequestHandler({
    getLoadContext(req, res) {
      // это становится контекстом `loader()`
      return { expressUser: req.user };
    },
  })
);

Получаем доступ к контексту в loader():


export const loader: LoaderFunction = async ({
  context,
}) => {
  const { expressUser } = context;
  // ...
};

Ответ, возвращаемый loader()


loader() должна возвращать Fetch Response:


export const loader: LoaderFunction = async () => {
  const users = await db.users.findMany();
  const body = JSON.stringify(users);

  return new Response(body, {
    headers: {
      "Content-Type": "application/json",
    },
  });
};

Утилита json упрощает этот процесс:


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

export const loader: LoaderFunction = async () => {
  const users = await fakeDb.users.findMany();

  return json(users);
};

json() позволяет добавлять в ответ дополнительную информацию, такую как заголовки HTTP или статус-код:


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

export const loader: LoaderFunction = async ({
  params,
}) => {
  const user = await fakeDb.project.findOne({
    where: { id: params.id },
  });

  if (!user) {
    return json("Пользователь не найден", { status: 404 });
  }

  return json(user);
};

Выброс ответа в loader()


Вместо возврата ответа в loader(), его можно выбросить (throw). Это позволяет "прорваться" сквозь стек вызовов (call stack) и отобразить альтернативный UI с контекстуальными данными с помощью CatchBoundary (см. ниже).


Пример создания утилиты, выбрасывающей ответ в loader(), что останавливает процесс выполнения кода и приводит к рендерингу резервного UI:


// app/db.ts
import { json } from "@remix-run/node";
import type { ThrownResponse } from "@remix-run/react";

export type InvoiceNotFoundResponse = ThrownResponse<
  404,
  string
>;

export function getInvoice(id, user) {
  const invoice = db.invoice.find({ where: { id } });

  if (!invoice) {
    throw json("Счет не найден", { status: 404 });
  }

  return invoice;
}

// app/http.ts
import { redirect } from "@remix-run/node";
import { getSession } from "./session";

export async function requireUserSession(request) {
  const session = await getSession(
    request.headers.get("cookie")
  );

  if (!session) {
    // поскольку утилиты `redirect` и `json` возвращают ответы,
    // их также можно выбрасывать
    throw redirect("/login", 302);
  }

  return session.get("user");
}

// app/routes/invoice/$invoiceId.tsx
import { useCatch, useLoaderData } from "@remix-run/react";
import type { ThrownResponse } from "@remix-run/react";

import { requireUserSession } from "~/http";
import { getInvoice } from "~/db";
import type {
  Invoice,
  InvoiceNotFoundResponse,
} from "~/db";

type InvoiceCatchData = {
  invoiceOwnerEmail: string;
};

type ThrownResponses =
  | InvoiceNotFoundResponse
  | ThrownResponse<401, InvoiceCatchData>;

export const loader = async ({ request, params }) => {
  const user = await requireUserSession(request);
  const invoice: Invoice = getInvoice(params.invoiceId);

  if (!invoice.userIds.includes(user.id)) {
    const data: InvoiceCatchData = {
      invoiceOwnerEmail: invoice.owner.email,
    };

    throw json(data, { status: 401 });
  }

  return json(invoice);
};

export default function InvoiceRoute() {
  const invoice = useLoaderData<Invoice>();

  return <InvoiceView invoice={invoice} />;
}

export function CatchBoundary() {
  // сигнатура `caught` - `{ status, statusText, data }`
  const caught = useCatch<ThrownResponses>();

  switch (caught.status) {
    case 401:
      return (
        <div>
          <p>У вас нет доступа к этому счету.</p>
          <p>
            Свяжитесь с {caught.data.invoiceOwnerEmail} для получения доступа.
          </p>
        </div>
      );
    case 404:
      return <div>Счет не найден</div>;
  }

  // если сделать так, то ошибка будет перехвачена ближайшим `ErrorBoundary` (см. ниже)
  // throw new Error("Неизвестный статус в catch boundary");
  return (
    <div>
      Что-то пошло не так: {caught.status}{" "}
      {caught.statusText}
    </div>
  );
}

action


Как и loader(), action — это серверная функция для обработки мутаций данных и других операций. Если к роуту выполняется не GET-запрос (POST, PUT, PATCH, DELETE), то action() вызывается перед loader().


action() имеет такой же интерфейс, что и loader(). Разница в том, когда они вызываются.


Это позволяет размещать совместно (co-locate) все, что связано с данными, в одном модуле роута: чтение данных, компонент, отвечающий за рендеринг данных, и запись данных.


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

import { fakeGetTodos, fakeCreateTodo } from "~/utils/db";
import { TodoList } from "~/components/TodoList";

export async function loader() {
  return json(await fakeGetTodos());
}

export async function action({ request }) {
  const body = await request.formData();

  const todo = await fakeCreateTodo({
    title: body.get("title"),
  });

  return redirect(`/todos/${todo.id}`);
}

export default function Todos() {
  const data = useLoaderData();

  return (
    <div>
      <TodoList todos={data} />
      <Form method="post">
        <input type="text" name="title" />
        <button type="submit">Создать задачу</button>
      </Form>
    </div>
  );
}

Обратите внимание: форма без пропа action (<Form method="post">) будет отправлять данные роуту, в котором она находится.


headers


Каждый роут может определять собственные заголовки HTTP. Одним из популярных заголовков является Cache-Control, указывающий браузеру и сети доставки контента (Content Delivery Network, CDN) где и на протяжении какого времени кэшировать страницу.


export function headers({
  actionHeaders,
  loaderHeaders,
  parentHeaders,
}) {
  return {
    "X-Stretchy-Pants": "забавы ради",
    "Cache-Control": "max-age=300, s-maxage=3600",
  };
}

Заголовки loader() и action() передаются headers():


export function headers({ loaderHeaders }) {
  return {
    "Cache-Control": loaderHeaders.get("Cache-Control"),
  };
}

actionHeaders и loaderHeaders являются экземплярами класса Headers.


В случае с вложенными роутами, "побеждает" headers() последнего (самого глубоко вложенного) роута. Заголовки родительского роута доступны в аргументе parentHeaders, передаваемом headers() дочернего роута.


Глобальные заголовки HTTP можно определить в файле entry.server.ts:


import { renderToString } from "react-dom/server";
import { RemixServer } from "@remix-run/react";
import type { EntryContext } from "@remix-run/node";

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  const markup = renderToString(
    <RemixServer context={remixContext} url={request.url} />
  );

  responseHeaders.set("Content-Type", "text/html");
  // !
  responseHeaders.set("X-Powered-By", "Hugs");

  return new Response("<!DOCTYPE html>" + markup, {
    status: responseStatusCode,
    headers: responseHeaders,
  });
}

meta


Функция meta позволяет определять мета-тэги HTML-документа. Рекомендуется устанавливать заголовок и описание в каждом роуте, за исключением роутов макета (layout route) (для таких роутов мета-тэги устанавливаются их основными или индексными роутами (index routes)).


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

export const meta: MetaFunction = () => {
  return {
    title: "Нечто клевое",
    description: "Превью в результатах поиска.",
  };
};

Мета-тэги вложенных роутов объединяются.


Контекст страницы в meta()


meta() принимает объект со следующими свойствами:


  • data — данные, экспортируемые loader();
  • location — объект подобный window.location с информацией о текущем роуте;
  • params — объект, содержащий параметры строки запроса роута;
  • parentsData — хэш-таблица данных, экспортируемых loader() текущего и всех родительских роутов.


Функция links определяет элементы link, которые добавляются на страницу при посещении страницы пользователем:


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

export const links: LinksFunction = () => {
  return [
    {
      rel: "icon",
      href: "/favicon.png",
      type: "image/png",
    },
    {
      rel: "stylesheet",
      href: "https://example.com/some/styles.css",
    },
    { page: "/users/123" },
    {
      rel: "preload",
      href: "/images/banner.jpg",
      as: "image",
    },
  ];
};

Использование links() является стандартным способом стилизации контента в Remix.


CatchBoundary


CatchBoundary — это компонент React, который рендерится, когда loader() или action() выбрасывают ответ.


CatchBoundary работает как компонент роута. Он имеет доступ к статус-коду и выброшенному ответу через хук useCatch:


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

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

  return (
    <div>
      <h1>Перехват</h1>
      <p>Статус: {caught.status}</p>
      <pre>
        `{JSON.stringify(caught.data, null, 2)}`
      </pre>
    </div>
  );
}

ErrorBoundary


ErrorBoundary — это компонент React, который рендерится при возникновении любой ошибки в роуте, как при рендеринге, так и при получении данных. Он позволяет перехватывать необработанные исключения (uncaught exceptions).


ErrorBoundary работает как обычные предохранители React с некоторыми дополнительными возможностями.


Данный компонент получает один проп — возникшую ошибку:


export function ErrorBoundary({ error }) {
  return (
    <div>
      <h1>Ошибка</h1>
      <p>{error.message}</p>
      <p>Трассировка стека:</p>
      <pre>{error.stack}</pre>
    </div>
  );
}

handle


Функция handle позволяет взаимодействовать с хуком useMatches (см. ниже). Она может возвращать любые значения:


export const handle = {
  its: "all yours",
};

Импорт статических ресурсов


Любой файл, находящийся в директории app, может импортироваться в модуль. В этом случае Remix делает следующее:


  • копирует файл в директорию сборки для браузера;
  • создает отпечаток (fingerprint) файла для долгосрочного кэширования (long-term caching);
  • возвращает публичный URL, используемый модулей при рендеринге.

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

import styles from "./styles/app.css";
import banner from "./images/banner.jpg";

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

export default function Page() {
  return (
    <div>
      <h1>Некая страница</h1>
      <img src={banner} />
    </div>
  );
}

Компоненты / Components



Эти компоненты используются один раз в корневом роуте (root.tsx):


import type {
  LinksFunction,
  MetaFunction,
} from "@remix-run/node";
import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

import globalStylesheetUrl from "./global-styles.css";

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

export const meta: MetaFunction = () => ({
  charset: "utf-8",
  title: "Мое восхитительное приложение",
  viewport: "width=device-width,initial-scale=1",
});

export default function App() {
  return (
    <html lang="en">
      <head>
        {/* Все экспорты `meta()` всех роутов */}
        <Meta />

        {/* Все экспорты `links()` всех роутов */}
        <Links />
      </head>
      <body>
        {/* Дочерние роуты */}
        <Outlet />

        {/* Управляет позицией прокрутки при переходах на клиенте */}
        <ScrollRestoration />

        {/* Теги `script` */}
        <Scripts />

        {/* Выполняет автоматическую перезагрузку страницы при изменении кода в режиме разработки */}
        <LiveReload />
      </body>
    </html>
  );
}


Этот компонент рендерит тег a, который является основным средством навигации в приложении. Он оборачивает компонент Link из React Router, предоставляя дополнительный функционал, связанный с предварительным получением ресурсов (resource prefetching).


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

export default function GlobalNav() {
  return (
    <nav>
      <Link to="/dashboard">Панель управления</Link>{" "}
      <Link to="/account">Аккаунт</Link>{" "}
      <Link to="/support">Поддержка</Link>
    </nav>
  );
}

Предварительное получение ресурсов определяется пропом prefetch:


<>
  <Link /> {/* по умолчанию "none" */}
  <Link prefetch="none" />
  <Link prefetch="intent" />
  <Link prefetch="render" />
</>

  • none — поведение по умолчанию. Предварительное получение данных не выполняется. Рекомендуется использовать для ссылок на страницы, использующие данные пользовательской сессии, которые браузер не сможет получить предварительно;
  • intent — предварительное получение данных выполняется, когда пользователь выражает намерение перейти на страницу. Это намерение выражается наведением курсора или установкой фокуса на ссылку. Рекомендуемый способ;
  • render — предварительное получение данных выполняется при рендеринге ссылки.


Данный компонент рендерит все теги <link rel="prefetch"> и <link rel="modulepreload"/> для всех ресурсов указанной страницы (путь к странице должен быть абсолютным):


<PrefetchPageLinks page="/absolute/path/to/page" />


NavLink — это специальный тип Link, содержащий информацию об "активности" ссылки. Это может быть полезным при разработке меню навигации, такого как хлебные крошки или набор вкладок, для отображения выбранного элемента.


По умолчанию активному NavLink добавляется класс active. Проп style рассматриваемого компонента принимает функцию, позволяющую кастомизировать контент ссылки на основе ее состояния.


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

function NavList() {
  // эти стили будут применяться только к выбранной ссылке
  const activeStyle = {
    textDecoration: "underline",
  };
  const activeClassName = "underline";

  return (
    <nav>
      <ul>
        <li>
          <NavLink
            to="messages"
            style={({ isActive }) =>
              isActive ? activeStyle : undefined
            }
          >
            Сообщения
          </NavLink>
        </li>
        <li>
          <NavLink
            to="tasks"
            className={({ isActive }) =>
              isActive ? activeClassName : undefined
            }
          >
            Задачи
          </NavLink>
        </li>
      </ul>
    </nav>
  );
}

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


<NavLink to="/" end>
  Главная
</NavLink>

Form


Form — это декларативный способ модификации данных, те.е их создания, обновления и удаления:


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

function NewEvent() {
  return (
    <Form method="post" action="/events">
      <input type="text" name="title" />
      <input type="text" name="description" />
    </Form>
  );
}

  • операции с данными будут выполняться независимо от присутствия JavaScript на странице;
  • после отправки формы все loader() на странице перезагружаются. Это позволяет обеспечить соответствие данных и UI;
  • Form автоматически сериализует данные (по аналогии с тем, как это делает браузер при отсутствии JS);
  • хук useTransition (см. ниже) позволяет реализовывать оптимистичное обновление UI.

Пропы Form


action


В большинстве случаев этот проп не требуется. Формы без пропа action (<Form method="post">) автоматически отправляют данные роуту, в котором они находятся. Это облегчает совместное размещение компонента, а также функций чтения и записи данных.


action() позволяет отправлять данные другому роуту:


<Form action="/projects/new" method="post" />

В отличие от loader(), которые вызываются при отправке запроса GET во всех роутах, совпадающих с URL, action() при отправке запроса POST вызывается только в самом глубоко вложенном совпадающем роуте до тех пор, пока речь не идет об индексном роуте. В последнем случае вызывается action() родительского роута.


Если мы хотим отправить данные основному роуту, следует использовать параметр строки запроса index в action(): <Form action="/accounts?index" method="post" />.


method


Определяет метод запроса HTTP: GET, POST, PUT, PATCH, DELETE. Дефолтным методом является GET.


<Form method="post" />

Нативный элемент form поддерживает только запросы GET и POST. При отсутствии JS на странице Remix преобразует все не GET-запросы в POST-запросы. Эту проблему можно решить с помощью скрытого инпута <input type="hidden" name="_method" value="delete" />. При наличии JS об этом можно не беспокоиться.


encType


По умолчанию имеет значение application/x-www-form-urlencoded, для загрузки файлов следует использовать multipart/form-data.


replace


Указывает форме заменить текущую сущность в стеке истории вместо добавления новой. Может быть полезным, когда предполагается множественная отправка формы, чтобы пользователь мог вернуться на предыдущую страницу, нажав кнопку "Назад" один раз.


reloadDocument


Если имеет значение true, форма отправляется браузером, а не JS. Данный проп является обязательным для Form, отправляющей данные ресурсному роуту.


Хуки / Hooks


useLoaderData


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


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

export async function loader() {
  return json(await fakeDb.invoices.findAll());
}

export default function Invoices() {
  const invoices = useLoaderData();
  // ...
}

useActionData


Этот хук позволяет получить доступ к разобранным данным, возвращаемым action() текущего роута. Если форма еще не отправлялась, useActionData() возвращает undefined:


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

export async function action({ request }) {
  const body = await request.formData();

  const name = body.get("visitorsName");

  return json({ message: `Привет, ${name}` });
}

export default function Invoices() {
  const data = useActionData();

  return (
    <Form method="post">
      <p>
        <label>
          Как Вас зовут?
          <input type="text" name="visitorsName" />
        </label>
      </p>
      <p>{data ? data.message : "Waiting..."}</p>
    </Form>
  );
}

Типичным случаем использования useActionData() является валидация формы:


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

export async function action({ request }) {
  const form = await request.formData();
  const email = form.get("email");
  const password = form.get("password");
  const errors = {};

  // валидируем поля
  if (typeof email !== "string" || !email.includes("@")) {
    errors.email = "Это не похоже на адрес электронной почты";
  }

  if (typeof password !== "string" || password.length < 6) {
    errors.password = "Пароль должен состоять минимум из 6 символов";
  }

  // возвращаем ошибки
  if (Object.keys(errors).length) {
    return json(errors, { status: 422 });
  }

  // создаем пользователя и выполняем перенаправление
  await createUser(form);
  return redirect("/dashboard");
}

export default function Signup() {
  const errors = useActionData();

  return (
    <>
      <h1>Регистрация</h1>
      <Form method="post">
        <p>
          <input type="text" name="email" />
          {errors?.email ? (
            <span>{errors.email}</span>
          ) : null}
        </p>
        <p>
          <input type="text" name="password" />
          {errors?.password ? (
            <span>{errors.password}</span>
          ) : null}
        </p>
        <p>
          <button type="submit">Зарегистрироваться</button>
        </p>
      </Form>
    </>
  );
}

useSubmit


Этот хук возвращает функцию, которая позволяет отправлять форму программно. Он похож на хук useNavigate из React Router для формы. Это может быть полезным, например, для сохранения данных пользователя при заполнении любого поля:


import { json } from "@remix-run/node";
import { useSubmit, useTransition } from "@remix-run/react";

export async function loader() {
  return json(await getUserPreferences());
}

export async function action({ request }) {
  await updatePreferences(await request.formData());

  return redirect("/prefs");
}

function UserPreferences() {
  const submit = useSubmit();
  const transition = useTransition();

  function handleChange(event) {
    submit(event.currentTarget, { replace: true });
  }

  return (
    <Form method="post" onChange={handleChange}>
      <label>
        <input type="checkbox" name="darkMode" value="on" />{" "}
        Темная тема
      </label>
      {transition.state === "submitting" ? (
        <p>Сохранение...</p>
      ) : null}
    </Form>
  );
}

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


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

function AdminPage() {
  useSessionTimeout();

  return <div>{/* ... */}</div>;
}

function useSessionTimeout() {
  const submit = useSubmit();
  const transition = useTransition();

  useEffect(() => {
    const timer = setTimeout(() => {
      submit(null, { method: "post", action: "/logout" });
    }, 5 * 60_000);

    return () => clearTimeout(timer);
  }, [submit, transition]);
}

useTransition


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


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

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

function SomeComponent() {
  const transition = useTransition();
  // transition.state;
  // transition.type;
  // transition.submission;
  // transition.location;
}

state


Состояние перехода:


  • idle — переход отсутствует;
  • submitting — форма отправлена, вызвана либо loader() (при запросе GET), либо action() (при другом запросе);
  • loading — вызвана loader() следующего роута для рендеринга следующей страницы.

Переход при навигации:


idle → loading → idle

Переход при отправке формы методом GET:


idle → submitting → idle

Переход при отправке формы другим методом:


idle → submitting → loading → idle

function SubmitButton() {
  const transition = useTransition();

  const text =
    transition.state === "submitting"
      ? "Сохранение..."
      : transition.state === "loading"
        ? "Сохранено"
        : "Вперед";

  return <button type="submit">{text}</button>;
}

type


Тип перехода, который зависит от состояния перехода:


  • state === 'idle':
    • idle;
  • state === 'submitting':
    • loaderSubmission — форма отправлена методом GET, вызвана loader();
    • actionSubmission — форма отправлена другим методом, вызвана action();
  • state === 'loading':
    • loaderSubmissionRedirectloader() выполнила перенаправление, загружается следующий роут;
    • actionRedirectaction() выполнила перенаправление, загружается следующий роут;
    • actionReloadaction() вернула данные, все loader() на странице перезагружаются;
    • fetchActionRedirectfetcher в action() выполнил перенаправление, загружается следующий роут;
    • normalRedirectloader() обычной навигации (или перенаправления) выполнила перенаправление, загружается новый роут;
    • normalLoad — обычная загрузка обычной навигации.

function SubmitButton() {
  const transition = useTransition();

  const loadTexts = {
    actionRedirect: "Данные сохранены, выполняется перенаправление...",
    actionReload: "Данные сохранены, загружаются свежие данные...",
  };

  const text =
    transition.state === "submitting"
      ? "Сохранение..."
      : transition.state === "loading"
      ? loadTexts[transition.type] || "Загрузка..."
      : "Вперед";

  return <button type="submit">{text}</button>;
}

submission


Индикатор выполнения перехода состояния.


location


Информация о следующей локации. Пример компонента Link, который "знает", когда его страница загружается и собирается стать активной:


import { Link, useResolvedPath } from "@remix-run/react";

function PendingLink({ to, children }) {
  const transition = useTransition();
  const path = useResolvedPath(to);

  const isPending =
    transition.state === "loading" &&
    transition.location.pathname === path.pathname;

  return (
    <Link
      data-pending={isPending ? "true" : null}
      to={to}
      children={children}
    />
  );
}

useFetcher


Этот хук позволяет подключить UI к action() и loader(), т.е. осуществить взаимодействие с сервером без навигации, которая выполняется элементами a и form или их эквивалентами в Remix — компонентами Link и Form.


Это может быть полезным в следующих случаях:


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

Обратите внимание: рекомендуемым способом загрузки и обновления данных являются рассмотренные ранее интерфейсы:


  • useLoaderData();
  • Form;
  • useActionData();
  • useTransition().

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

function SomeComponent() {
  const fetcher = useFetcher();

  const formOptions = {/* ... */}

  // выполняем запрос
  <fetcher.Form {...formOptions} />;

  useEffect(() => {
    fetcher.submit(data, options);
    fetcher.load(href);
  }, [fetcher]);

  // формируем UI
  // fetcher.state;
  // fetcher.type;
  // fetcher.submission;
  // fetcher.data;
}

Несколько заметок о том, как это работает:


  • автоматическая обработка отмены запроса на уровне браузера;
  • при отправке формы не GET-методом, сначала вызывается action();
    • после завершения action() все loader() на странице перезагружаются для получения свежих данных с целью синхронизации UI с серверным состоянием;
  • когда одновременно выполняется несколько запросов:
    • доставляются (commit) самые свежие доступные данные после выполнения;
    • самые свежие данные не перезаписываются, независимо от порядка ответов;
  • обработка необработанных ошибок ближайшим ErrorBoundary;
  • если вызванный action/loader возвращает redirect(), выполняется перенаправление.

Свойства fetcher


state


  • idle — ничего не запрашивается;
  • submitting — форма отправлена. Если она отправлена методом GET, вызывается loader(), если другим методом — action();
  • loadingloader() роута перезагружаются после выполнения action().

type


  • state === 'idle':
    • init — начальное состояние fetcher;
    • donefetcher ничего не делает в данный момент, но выполнение предыдущего запроса завершено, данные доступны в fetcher.data;
  • state === 'submitting':
    • actionSubmission — см. useTransition();
    • loaderSubmission — см. useTransition();
  • state === 'loading':
    • actionReload — см. useTransition();
    • actionRedirect — см. useTransition();
    • normalLoadloader() вызван без отправки формы (fetcher.load()).

submission


При использовании <fetcher.Form> или fetcher.submit() данное свойство может применяться для формирования оптимистичного UI. Оно недоступно, когда fetcher находится в состоянии idle или loading.


data


Здесь хранится ответ loader() или action(). После записи данные сохраняются даже при перезагрузках и повторных отправках формы.


Form


Аналог Form, но без навигации:


function SomeComponent() {
  const fetcher = useFetcher();

  return (
    <fetcher.Form method="post" action="/some/route">
      <input type="text" />
    </fetcher.Form>
  );
}

submit()


Аналог useSubmit(), но без навигации:


function SomeComponent() {
  const fetcher = useFetcher();

  const onClick = () =>
    fetcher.submit({ some: "value" }, { method: "post" });

  // ...
}

load()


Загружает данные из loader():


function SomeComponent() {
  const fetcher = useFetcher();

  useEffect(() => {
    if (fetcher.type === "init") {
      fetcher.load("/some/route");
    }
  }, [fetcher]);

  // данные из `loader()`
  // fetcher.data
}

Примеры


Форма подписки на новостную рассылку


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


// routes/newsletter/subscribe.ts
export async function action({ request }) {
  const email = (await request.formData()).get("email");

  try {
    await subscribe(email);
    return json({ ok: true });
  } catch (error) {
    return json({ error: error.message });
  }
}

Затем рендерим в root.tsx приложения такой компонент:


// ...

function NewsletterSignup() {
  const newsletter = useFetcher();
  const ref = useRef();

  useEffect(() => {
    if (newsletter.type === "done" && newsletter.data.ok) {
      ref.current.reset();
    }
  }, [newsletter]);

  return (
    <newsletter.Form
      ref={ref}
      method="post"
      action="/newsletter/subscribe"
    >
      <p>
        <input type="text" name="email" />{" "}
        <button
          type="submit"
          disabled={newsletter.state === "submitting"}
        >
          Подписаться
        </button>
      </p>

      {newsletter.type === "done" ? (
        newsletter.data.ok ? (
          <p>Спасибо за подписку!</p>
        ) : newsletter.data.error ? (
          <p data-error>{newsletter.data.error}</p>
        ) : null
      ) : null}
    </newsletter.Form>
  );
}

Обратите внимание: это будет работать только при наличии JS на странице.


Автоматическая пометка статьи как прочитанной


Предположим, что мы хотим помечать статью как прочитанную и выполнять прокрутку вниз после того, как пользователь находится на странице какое-то время:


function useMarkAsRead({ articleId, userId }) {
  const marker = useFetcher();

  useSpentSomeTimeHereAndScrolledToTheBottom(() => {
    marker.submit(
      { userId },
      {
        method: "post",
        action: `/article/${articleId}/mark-as-read`,
      }
    );
  });
}

Отображение данных пользователя при наведении указателя на его аватар


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


export async function loader({ params }) {
  return json(
    await fakeDb.user.find({ where: { id: params.id } })
  );
}

function UserAvatar({ partialUser }) {
  const userDetails = useFetcher();
  const [showDetails, setShowDetails] = useState(false);

  useEffect(() => {
    if (showDetails && userDetails.type === "init") {
      userDetails.load(`/users/${user.id}/details`);
    }
  }, [showDetails, userDetails]);

  return (
    <div
      onMouseEnter={() => setShowDetails(true)}
      onMouseLeave={() => setShowDetails(false)}
    >
      <img src={partialUser.profileImageUrl} />
      {showDetails ? (
        userDetails.type === "done" ? (
          <UserPopup user={userDetails.data} />
        ) : (
          <UserPopupLoading />
        )
      ) : null}
    </div>
  );
}

useMatches


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


function SomeComponent() {
  const matches = useMatches();

  // ...
}

matches имеет следующую форму:


[
  { id, pathname, data, params, handle }, // корневой роут
  { id, pathname, data, params, handle }, // роут макета
  { id, pathname, data, params, handle }, // дочерний роут
  // etc.
];

Тот факт, что Remix знает все роуты и данные дерева элементов React, позволяет добавлять теги meta, link и script в начало документа, даже при условии, что они определяются во вложенных роутах.


Использование useMatches() совместно с handle() позволяет разрабатывать собственные абстракции, похожие на <Meta>, <Links> и <Scripts>.


Рассмотрим пример создания хлебных крошек.


Мы можем возвращать из handle() что угодно. В данном случае мы возвращаем breadcrumb().


  1. Добавляем обработку хлебных крошек в родительский роут:

// routes/parent.tsx
export const handle = {
  breadcrumb: () => <Link to="/parent">Родительский роут</Link>,
};

  1. Делаем тоже самое в дочернем роуте:

// routes/parent/child.tsx
export const handle = {
  breadcrumb: () => (
    <Link to="/parent/child">Дочерний роут</Link>
  ),
};

  1. Собираем хлебные крошки в корневом роуте с помощью useMatches():

// root.tsx
import {
  Links,
  Scripts,
  useLoaderData,
  useMatches,
} from "@remix-run/react";

export default function Root() {
  const matches = useMatches();

  return (
    <html lang="en">
      <head>
        <Links />
      </head>
      <body>
        <header>
          <ol>
            {matches
              // пропускаем роуты, не имеющие хлебных крошек
              .filter(
                (match) =>
                  match.handle && match.handle.breadcrumb
              )
              // рендерим хлебные крошки
              .map((match, index) => (
                <li key={index}>
                  {match.handle.breadcrumb(match)}
                </li>
              ))}
          </ol>
        </header>

        <Outlet />
      </body>
    </html>
  );

Доступ к данным роута можно получить через match.data.


useBeforeUnload


Этот хук представляет собой абстракцию над window.onbeforeunload. Он позволяет сохранять важную информацию перед выгрузкой документа:


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

function SomeForm() {
  const [state, setState] = React.useState(null);

  // сохраняем информацию перед выгрузкой страницы
  useBeforeUnload(
    React.useCallback(() => {
      localStorage.setItem('stuff', JSON.stringify(state));
    }, [state])
  );

  // читаем сохраненную информацию после загрузки страницы
  React.useEffect(() => {
    if (state === null && localStorage.getItem('stuff')) {
      setState(JSON.parse(localStorage.getItem('stuff')));
    }
  }, [state]);

  return <>{/*... */}</>;
}

Утилиты HTTP / HTTP Helpers


json


Утилита для создания ответов application/json. Предполагается, что используется кодировка utf-8:


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

export const loader = async () => {
  // это является сокращением...
  return json({ any: "thing" });

  // для этого
  return new Response(JSON.stringify({ any: "thing" }), {
    headers: {
      "Content-Type": "application/json; charset=utf-8",
    },
  });
};

json() позволяет указывать статус-код ответа и дополнительные заголовки HTTP:


export const loader = async () => {
  return json(
    { not: "coffee" },
    {
      status: 418,
      headers: {
        "Cache-Control": "no-store",
      },
    }
  );
};

redirect


Утилита для отправки ответов 30x — выполнения перенаправлений:


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

export const action = async () => {
  const userSession = await getUserSession();

  if (!userSession) {
    return redirect("/login");
  }

  return json({ ok: true });
};

По умолчанию отправляется статус-код 302. Это можно изменить:


redirect(path, 301);
redirect(path, 303);

Мы также можем отправлять ResponseInit для установки заголовков HTTP, например, о сессии:


redirect(path, {
  headers: {
    "Set-Cookie": await commitSession(session),
  },
});

redirect(path, {
  status: 302,
  headers: {
    "Set-Cookie": await commitSession(session),
  },
});

Разумеется, вместо redirect() можно возвращать обычный Response:


// это является сокращением...
return redirect("/else/where", 303);

// для этого
return new Response(null, {
  status: 303,
  headers: {
    Location: "/else/where",
  },
});

Куки / Cookies


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


Интерфейс Cookie предоставляет логический повторно используемый контейнер для метаданных куки.


Использование куки


Хотя куки можно создавать вручную, рекомендуется использовать хранилище сессии (см. ниже).


В Remix работа с куки, как правило, осуществляется в loader() или action().


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


Создаем куки:


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

export const userPrefs = createCookie("user-prefs", {
  maxAge: 604_800, // одна неделя
});

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


Обратите внимание: в настоящее время рекомендуется создавать все куки в файле app/cookies.js и импортировать их в соответствующие модули. Это позволяет Remix исключать куки из сборки для браузера.


import { json, redirect } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
// !
import { userPrefs } from "~/cookies";

export async function loader({ request }) {
  const cookieHeader = request.headers.get("Cookie");
  // !
  const cookie =
    (await userPrefs.parse(cookieHeader)) || {};
  return json({ showBanner: cookie.showBanner });
}

export async function action({ request }) {
  const cookieHeader = request.headers.get("Cookie");
  // !
  const cookie =
    (await userPrefs.parse(cookieHeader)) || {};
  const bodyParams = await request.formData();

  if (bodyParams.get("bannerVisibility") === "hidden") {
    cookie.showBanner = false;
  }

  return redirect("/", {
    headers: {
      "Set-Cookie": await userPrefs.serialize(cookie),
    },
  });
}

export default function Home() {
  const { showBanner } = useLoaderData();

  return (
    <div>
      {showBanner ? (
        <div>
          <Link to="/sale">Не пропустите распродажу!</Link>
          <Form method="post">
            <input
              type="hidden"
              name="bannerVisibility"
              value="hidden"
            />
            <button type="submit">Скрыть</button>
          </Form>
        </div>
      ) : null}
      <h1>Добро пожаловать!</h1>
    </div>
  );
}

Атрибуты куки


Куки имеют несколько атрибутов, определяющих их время жизни, доступность, путь и др. Эти атрибуты могут устанавливаться как в createCookie(name, options), так и в serialize() при генерации заголовка Set-Cookie.


const cookie = createCookie("user-prefs", {
  // дефолтные настройки для этой куки
  domain: "remix.run",
  path: "/",
  sameSite: "lax",
  httpOnly: true,
  secure: true,
  expires: new Date(Date.now() + 60_000),
  maxAge: 60,
});

// используем настройки по умолчанию
cookie.serialize(userPrefs);

// или перезаписываем некоторые из них
cookie.serialize(userPrefs, { sameSite: "strict" });

Подписание куки


Подписание (signing) позволяет автоматически проверять/подтверждать содержимое куки при ее получении. Данная техника применяется в отношении чувствительной информации, такой как данные об аутентификации.


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


const cookie = createCookie("user-prefs", {
  secrets: ["s3cret1"],
});

Ротация секретов выполняется посредством добавления нового секрета в начало массива secrets. Куки, подписанные с помощью старых секретов будут успешно декодироваться в cookie.parse(), а новые секреты будут использоваться для подписания куки, создаваемых в cookie.serialize():


// app/cookies.js
const cookie = createCookie("user-prefs", {
  secrets: ["n3wsecr3t", "olds3cret"],
});

// в роуте
export async function loader({ request }) {
  const oldCookie = request.headers.get("Cookie");
  // `oldCookie` может быть подписан с помощью `olds3cret`, но будет успешно разобран
  const value = await cookie.parse(oldCookie);

  new Response("...", {
    headers: {
      // `Set-Cookie` подписывается с помощью `n3wsecr3t`
      "Set-Cookie": await cookie.serialize(value),
    },
  });
}

createCookie


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


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

const cookie = createCookie("cookie-name", {
  // все эти дефолтные настройки могут быть перезаписаны в процессе выполнения кода (runtime)
  domain: "remix.run",
  expires: new Date(Date.now() + 60_000),
  httpOnly: true,
  maxAge: 60,
  path: "/",
  sameSite: "lax",
  secrets: ["s3cret1"],
  secure: true,
});

isCookie


Эта утилита возвращает true, если объект является контейнером для куки:


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

const cookie = createCookie("user-prefs");
console.log(isCookie(cookie)); // true


Контейнер, возвращаемый createCookie(), содержит несколько полезных свойств и методов.


name


Название куки, используется в заголовках Cookie и Set-Cookie.


parse()


Извлекает и возвращает значение куки, содержащегося в заголовке Cookie:


const value = await cookie.parse(
  request.headers.get("Cookie")
);

serialize()


Сериализует значение и объединяет его с настройками куки для создания заголовка Set-Cookie, подходящего для использования в исходящем Response:


new Response("...", {
  headers: {
    "Set-Cookie": await cookie.serialize({
      showBanner: true,
    }),
  },
});

isSigned


Возвращает true, если куки подписана с помощью secrets.


expires


Дата (объект Date), когда истекает срок действия куки. Обратите внимание: если куки содержит maxAge и expires, значением expires будет текущая дата + maxAge, поскольку Max-Age имеет приоритет перед Expires.


Сессии / Sessions


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


В Remix сессии управляются на основе роутов в методах loader и action с помощью "хранилища сессии" (которое реализует интерфейс SessionStorage). Хранилище сессии умеет парсить и генерировать куки, а также записывать данные сессии в базу данных или файловую систему.


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


  • createCookieSessionStorage();
  • createMemorySessionStorage();
  • createFileSessionStorage();
  • createSessionStorage() для создания кастомного хранилища.

Использование сессий


Пример создания хранилища сессии, основанного на куки:


// app/sessions.js
import { createCookieSessionStorage } from "@remix-run/node";

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    // куки из `createCookie()` или настройки для ее создания
    cookie: {
      name: "__session",

      // все эти настройки являются опциональными
      domain: "remix.run",
      httpOnly: true,
      maxAge: 60,
      path: "/",
      sameSite: "lax",
      secrets: ["s3cret1"],
      secure: true,
    },
  });

export { getSession, commitSession, destroySession };

Хранилище сессии рекомендуется создавать в файле app/session.js.


Входными и выходными данными хранилища сессии являются куки HTTP. getSession() извлекает текущую сессию из заголовка Cookie входящего запроса, а commitSession() и destroySession() устанавливают заголовок Set-Cookie для исходящего ответа.


Эти методы используются в loader() и action() для доступа к данным сессии.


Форма авторизации может выглядеть так:


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

import { getSession, commitSession } from "../sessions";

export async function loader({ request }) {
  const session = await getSession(
    request.headers.get("Cookie")
  );

  if (session.has("userId")) {
    // перенаправляем пользователя на главную страницу, если он уже авторизован
    return redirect("/");
  }

  const data = { error: session.get("error") };

  return json(data, {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

export async function action({ request }) {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  const form = await request.formData();
  const username = form.get("username");
  const password = form.get("password");

  const userId = await validateCredentials(
    username,
    password
  );

  if (userId == null) {
    session.flash("error", "Неверное имя пользователя/пароль");

    // возвращаемся на страницу авторизации с ошибками
    return redirect("/login", {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    });
  }

  session.set("userId", userId);

  // авторизация прошла успешно, перенаправляем пользователя на главную страницу
  return redirect("/", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

export default function Login() {
  const { currentUser, error } = useLoaderData();

  return (
    <div>
      {error ? <div className="error">{error}</div> : null}
      <form method="POST">
        <div>
          <p>Авторизация</p>
        </div>
        <label>
          Имя пользователя: <input type="text" name="username" />
        </label>
        <label>
          Пароль: <input type="password" name="password" />
        </label>
      </form>
    </div>
  );
}

Форма для выхода из системы может выглядеть так:


import { getSession, destroySession } from "../sessions";

export const action: ActionFunction = async ({
  request,
}) => {
  const session = await getSession(
    request.headers.get("Cookie")
  );

  return redirect("/login", {
    headers: {
      "Set-Cookie": await destroySession(session),
    },
  });
};

export default function LogoutRoute() {
  return (
    <>
      <p>Вы уверены, что хотите выйти из системы?</p>
      <Form method="post">
        <button>Выйти из системы</button>
      </Form>
      <Link to="/">Я передумал</Link>
    </>
  );
}

Особенности работы с сессиями


Из-за вложенных роутов для генерации страницы может вызываться несколько loader(). Поэтому при использовании session.flash() или session.unset() необходимо убедиться, что другие loader(), участвующие в запросе, не будут читать данные сессии, в противном случае, мы получим гонку условий (race conditions).


isSession()


Возвращает true, если объект является сессией Remix:


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

const sessionData = { foo: "bar" };
const session = createSession(sessionData, "remix-session");
console.log(isSession(session)); // true

createSessionStorage()


Remix позволяет хранить сессии в собственной БД. createSessionStorage() требует cookie (или настроек для ее создания) и набор методов для создания, чтения, обновления и удаления данных сессии. Куки используется для хранения идентификатора сессии.


Пример создания хранилища сессии с помощью клиента БД общего назначения:


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

function createDatabaseSessionStorage({
  cookie,
  host,
  port,
}) {
  // настраиваем клиента БД
  const db = createDatabaseClient(host, port);

  return createSessionStorage({
    cookie,
    async createData(data, expires) {
      // `expires` - это дата, после которой данные считаются невалидными.
      // Мы можем использовать это свойство для инвалидации данных или
      // автоматического удаления записи из БД
      const id = await db.insert(data);
      return id;
    },
    async readData(id) {
      return (await db.select(id)) || null;
    },
    async updateData(id, data, expires) {
      await db.update(id, data);
    },
    async deleteData(id) {
      await db.delete(id);
    },
  });
}

Пример использования этой утилиты:


const { getSession, commitSession, destroySession } =
  createDatabaseSessionStorage({
    host: "localhost",
    port: 1234,
    cookie: {
      name: "__session",
      sameSite: "lax",
    },
  });

createCookieSessionStorage()


Позволяет создавать сессии, основанные на куки.


Главным преимуществом такой сессии является то, что для ее использования не требуются дополнительные серверные сервисы или БД. Однако размер такого хранилища весьма ограничен и, как правило, составляет 4 Кб.


Недостатком является необходимость вызывать commitSession() почти в каждой loader() и action(). Это означает, что если мы вызываем session.flash() в одной action(), и затем — session.get() в другой, мы должны зафиксировать состояние для очистки данных:


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

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: "__session",
      secrets: ["r3m1xr0ck5"],
      sameSite: "lax",
    },
  });

createMemorySessionStorage()


При использовании этого хранилища куки хранится в памяти сервера. Обратите внимание: это хранилище следует использовать только в режиме разработки.


import {
  createCookie,
  createMemorySessionStorage,
} from "@remix-run/node";

const sessionCookie = createCookie("__session", {
  secrets: ["r3m1xr0ck5"],
  sameSite: true,
});

const { getSession, commitSession, destroySession } =
  createMemorySessionStorage({
    cookie: sessionCookie,
  });

export { getSession, commitSession, destroySession };

createFileSessionStorage()


При использовании этого хранилища вся информация о сессии хранится в файловой системе.


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


import {
  createCookie,
  createFileSessionStorage,
} from "@remix-run/node";

const sessionCookie = createCookie("__session", {
  secrets: ["r3m1xr0ck5"],
  sameSite: true,
});

const { getSession, commitSession, destroySession } =
  createFileSessionStorage({
    // корневая директория для записи файлов
    // убедитесь, что она доступна для записи
    dir: "/app/sessions",
    cookie: sessionCookie,
  });

export { getSession, commitSession, destroySession };

Интерфейс объекта сессии


has(key)


Возвращает true, если сессия содержит переменную key:


session.has("userId");

set(key, value)


Устанавливает переменную key в значение value:


session.set("userId", "1243");

flash(key, value)


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


import { getSession, commitSession } from "../sessions";

export async function action({ request, params }) {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  const deletedProject = await archiveProject(
    params.projectId
  );

  session.flash(
    "globalMessage",
    `Проект ${deletedProject.name} успешно перемещен в архив`
  );

  return redirect("/dashboard", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

После этого мы можем прочитать сообщение в loader():


import { json } from "@remix-run/node";
import {
  Meta,
  Links,
  Scripts,
  Outlet,
} from "@remix-run/react";

import { getSession, commitSession } from "./sessions";

export async function loader({ request }) {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  const message = session.get("globalMessage") || null;

  return json(
    { message },
    {
      headers: {
        // требуется только при использовании `cookieSessionStorage`
        "Set-Cookie": await commitSession(session),
      },
    }
  );
}

export default function App() {
  const { message } = useLoaderData();

  return (
    <html>
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        {message ? (
          <div className="flash">{message}</div>
        ) : null}
        <Outlet />
        <Scripts />
      </body>
    </html>
  );
}

get(key)


Возвращает значение переменной key (из предыдущего запроса):


session.get("userId");

unset(key)


Удаляет переменную key из сессии:


session.unset("userId");

Обратите внимание: при использовании cookieSessionStorage, после удаления переменной состояние сессии необходимо зафиксировать:


export async function loader({ request }) {
  // ...

  return json(data, {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

Это конец второй части руководства.


Благодарю за внимание и happy coding!




Комментарии (3)


  1. vagon333
    00.00.0000 00:00
    +1

    Может что-то с браузером, но у меня не работают переходы по ссылкам в содержании для первой и второй части.


    1. aio350 Автор
      00.00.0000 00:00

      Спасибо, поправил.


  1. Ambi2Rush
    00.00.0000 00:00

    Мысль хорошая чтобы приблизиться к html-стандарту и использовать нативные фичи. Интересно выживет ли.

    Сам взял ремикс дл небольшого интернет магазина, пока полёт нормальный. Жаль сообщество ещё очень маленькое. Русскоязычных сообществ вообще нет вроде как.