Руководство по созданию многостраничных форм с использованием Remix и Redis.

Недавно я решил создать многостраничную форму с помощью Remix. Я решил использовать Redis для хранения временной версии данных формы, которую можно использовать между страницами. Это тестовый проект, показывающий множество одинаковых вариантов использования и реализаций.

Код из проекта можно найти на GitHub:



Оригинальная статья, размещенная на Noah Johnson Dev:



Настраивать

Создайте новый проект Remix или используйте существующий.

npx create-remix@latest

Это работает с любым вариантом. Я выбрал Remix App Server, так как собираюсь развернуть и запустить его на Fly.io.

Установить зависимости

npm install redis

Для этой базовой демонстрации нам просто нужно установить Redis. Я также собираюсь установить попутный ветер для стайлинга. Инструкцию по установке tailwind в свой проект вы можете найти в Remix Docs

Настроить Redis локально

Простой способ запустить Redis локально — через Docker. Убедитесь, что у вас установлен Docker (здесь документация по этому поводу). Добавьте имя файла docker-compose.yml в корень вашего проекта, а затем добавьте в этот файл следующий код:

version: "3.7"
services:
  redis:
    image: redis:3.0.6
    ports:
      - "6379:6379"
    volumes:
      - ./data/redis:/data

Примечание. Если Redis работает локально, вам потребуется изменить порт, чтобы он не конфликтовал с вашим локальным экземпляром reds.

Наконец, добавьте новый скрипт в ваш package.json, который вы можете запустить, чтобы запустить экземпляр Redis:

{
	"scripts": {
		/* Rest of Scripts */
		"docker": "docker-compose up -d",
	},
}

Затем вы можете запустить экземпляр Redis, выполнив команду:

npm run docker

Примечание. Флаг -d в сценарии Docker означает, что он будет работать в автономном режиме. Просто убедитесь, что в вашей консоли нет ошибок и что она работает в фоновом режиме.

Установить переменную среды

Последним шагом в настройке является установка переменной среды Redis URL. Создайте файл с именем .env и добавьте строку, в которой говорится:

REDIS_URL="redis://localhost:6379"

Это будет использоваться для создания соединения с локальным экземпляром Redis. Затем в развернутой версии приложения можно установить секрет среды для подключения к удаленному экземпляру Redis.

Начать

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

Я собираюсь изменить маршрут index.tsx, чтобы просто иметь ссылку на вложенные маршруты формы, которые мы создадим:

// index.tsx

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

export default function Index() {
  return (
    <main className="w-full py-4 text-center">
      <Link
        to="/form"
        className="rounded bg-blue-600 px-4 py-2 text-white shadow hover:bg-blue-800"
      >
        Go to Form
      </Link>
    </main>
  );
}

Затем создайте новый файл с именем form.tsx в папке маршрутов, а затем давайте создадим базовую структуру для элементов, окружающих форму:

// form.tsx

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

export default function FormRoot() {
  return (
    <main className="mx-auto h-full w-full max-w-2xl py-4">
      <header>
        <h1 className="text-3xl font-bold">Remix Redis Form</h1>
      </header>
      <Outlet />
    </main>
  );
}

Примечание. Если вы не знакомы с маршрутизацией в Remix, компонент Outlet отобразит вместо него любой вложенный маршрут. Это означает, что следующие компоненты, которые мы создадим в папке /routes/form/, будут отображаться вместо Outlet.

Следующий файл, который мы создадим, — это индексный файл в папке формы в файле route. Итак, создайте новую папку с именем form, а затем внутри создайте новый файл с именем index.tsx. Затем мы создадим первую страницу формы в этом файле:

// form/index.tsx

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

export default function FormIndex() {
  return (
    <div>
      <h2 className="text-lg text-gray-600">Basic Info</h2>
      <Form method="post">
        <label className="mb-1">
          Name
          <input
            type="text"
            name="name"
            placeholder="Name"
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <label className="mb-1">
          Phone
          <input
            type="tel"
            name="phone"
            placeholder="(123) 456-7890"
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <label className="mb-1">
          Email
          <input
            type="email"
            name="email"
            placeholder="[email protected]"
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <button className="mx-auto mt-2 block w-1/2 rounded bg-blue-600 py-2 text-white hover:bg-blue-700">
          Continue
        </button>
      </Form>
    </div>
  );
}

Имея базовую форму, мы можем настроить нашу первую функцию ActionFunction, которая будет получать данные формы, сохранять объект в Redis, а затем переходить на следующую страницу с ключом в качестве параметра поиска. Начнем с ActionFunction:

// form/index.tsx

/* Other imports */
import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { saveToRedis } from "~/services/redis.server";
import type { CustomFormData } from "~/types/form";
import { generateCode } from "../utils/helpers.server";

export const action: ActionFunction = async ({ request }) => {
  // Get the form data from the request.
  const formData = await request.formData();

  // Get each form element
  const name = formData.get("name");
  const phone = formData.get("phone");
  const email = formData.get("email");

  // Check if the form data is valid
  if (
    typeof name !== "string" ||
    typeof phone !== "string" ||
    typeof email !== "string"
  ) {
    throw Error("Form data is invalid"); // This error could be handled differently, but we will keep it for now
  }

  // Create an ID for the form data
  const id = generateCode(6); // Should generate a random, unique ID

  // Create a new object of type CustomFormData
  const formDataObject: CustomFormData = {
    id,
    name,
    phone,
    email,
    // There are other optional fields that will be added in later
  };

  // Save the form data to Redis with a helper in the services folder
  await saveToRedis(formDataObject);

  return redirect(`/form/more?id=${formDataObject.id}`);
};

/* FormIndex Component /*

Чтобы эта функция действия работала, мы напишем несколько функций и пользовательский тип. Во-первых, мы используем базовую функцию для генерации кода для идентификатора. Это можно сделать разными способами, но вот функция, которую я использую, которую я поместил в файл /utils/helpers.server.ts.

export const generateCode = (length: number): string => {
  var result = "";
  var characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
  var charactersLength = characters.length;
  for (var i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * charactersLength));
  }
  return result;
};

Я поместил тип в папку с именем types, чтобы его можно было использовать вспомогательными функциями, а также различными маршрутами.

export type CustomFormData = {
  id: string;
  name: string;
  phone: string;
  email: string;
  bio?: string;
  birthday?: Date;
  age?: number;
};

Наконец, я создал функцию для сохранения данных в Redis и поместил их в новый файл в папке services.

// services/redis.server.ts

import * as redis from "redis";
import type { CustomFormData } from "~/types/form";

const client = redis.createClient({
  url: process.env.REDIS_URL,
});

client.on("error", (err) => console.log("Redis client error", err));

// If you update the data type, update the key version so you are not left with invalid states
const KEY_VERSION = "1";

export const saveToRedis = async (data: CustomFormData) => {
  await client.connect();
  await client.set(`f-${KEY_VERSION}-${data.id}`, JSON.stringify(data));
  await client.quit();
};

Другая страница формы

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

import type { LoaderFunction } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { requireFormData } from "~/services/redis.server";

export const loader: LoaderFunction = async ({ request }) => {
  // Function in redis.server.ts that is reusable for each page of the form
  const id = await requireFormData(request);

  return id;
};

export default function MoreForm() {
  const { id } = useLoaderData<{ id: string }>();

  return (
    <div>
      <h2 className="text-lg text-gray-600">Basic Info</h2>
      <Form method="post">
        <input type="hidden" name="id" value={id} />
        <label className="mb-1">
          Bio
          <input
            type="text"
            name="bio"
            placeholder="Bio"
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <label className="mb-1">
          Birthday
          <input
            type="date"
            name="birthday"
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <label className="mb-1">
          Age
          <input
            type="number"
            name="age"
            placeholder="0"
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <button className="mx-auto mt-2 block w-1/2 rounded bg-blue-600 py-2 text-white hover:bg-blue-700">
          Continue
        </button>
      </Form>
    </div>
  );
}

Теперь, чтобы реализовать функцию requireFormData, мы создадим две новые функции в redis.server.ts.

export const getDataFromRedis = async (
  id: string
): Promise<CustomFormData | null> => {
  // Get data from redis
  await client.connect();
  const data = await client.get(`f-${KEY_VERSION}-${id}`);
  await client.quit();

  if (!data) {
    return null;
  }

  const formData = JSON.parse(data) as CustomFormData;

  return formData;
};

export const requireFormData = async (
  request: Request
): Promise<CustomFormData> => {
  // Get ID from search params
  const url = new URL(request.url);
  const id = url.searchParams.get("id");

  if (typeof id !== "string" || !id) {
    throw Error("Issue getting id");
  }

  // Get cached form data from Redis
  const formData = await getDataFromRedis(id);

  if (!formData) {
    throw Error("No Data Found");
  }

  return formData;
};

Теперь давайте добавим ActionFunction, которая будет обрабатывать данные формы и снова сохранять их в Redis:

import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import {
  getDataFromRedis,
  requireFormData,
  saveToRedis,
} from "~/services/redis.server";

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();

  const id = formData.get("id");
  const bio = formData.get("bio");
  const birthday = formData.get("birthday");
  const age = formData.get("age");

  if (
    typeof id !== "string" ||
    typeof bio !== "string" ||
    typeof birthday !== "string" ||
    typeof age !== "string"
  ) {
    throw Error("Form data is invalid");
  }

  const customFormData = await getDataFromRedis(id);

  if (!customFormData) {
    throw Error("Form data is not found");
  }

  customFormData.bio = bio;
  customFormData.birthday = new Date(birthday);
  customFormData.age = parseInt(age);

  await saveToRedis(customFormData);

  return redirect(`/form/confirm?id=${customFormData.id}`);
};

Одна последняя страница

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

// form/confirm.tsx

import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form, Link, useLoaderData } from "@remix-run/react";
import {
  deleteFormData,
  getDataFromRedis,
  requireFormData,
} from "~/services/redis.server";
import type { CustomFormData } from "~/types/form";

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();

  const id = formData.get("id");

  if (typeof id !== "string") {
    throw Error("Form data is invalid");
  }

  const customFormData = await getDataFromRedis(id);

  if (!customFormData) {
    throw Error("Form data is not found");
  }

  /*
    Here you can use the custom form data however you want, 
    for example adding it to a database
  */

  console.log(customFormData);

  // Can cleanup the form data from Redis
  await deleteFormData(id);

  return redirect(`/`);
};

export const loader: LoaderFunction = async ({ request }) => {
  // Function in redis.server.ts that is reusable for each page of the form
  const formData = await requireFormData(request);
  return formData;
};

export default function MoreForm() {
  const data = useLoaderData<CustomFormData>();

  return (
    <div>
      <div className="mb-4">
        <h2 className="text-lg text-gray-600">Basic Info</h2>
        <p>Name: {data.name}</p>
        <p>Phone: {data.phone}</p>
        <p>Email: {data.email}</p>
        <Link
          to={`/form?id=${data.id}`}
          className="text-blue-700 hover:underline"
        >
          Edit
        </Link>
      </div>

      <div className="mb-4">
        <h2 className="text-lg text-gray-600">More Info</h2>
        <p>Bio: {data.bio ?? ""}</p>
        <p>Birthday: {data.birthday ?? ""}</p>
        <p>Age: {data.age ?? ""}</p>
        <Link
          to={`/form/more?id=${data.id}`}
          className="text-blue-700 hover:underline"
        >
          Edit
        </Link>
      </div>

      <Form method="post">
        <input type="hidden" name="id" value={data.id} />
        <button className="mx-auto mt-2 block w-1/2 rounded bg-blue-600 py-2 text-white hover:bg-blue-700">
          Submit
        </button>
      </Form>
    </div>
  );
}

Единственная дополнительная функция, которая нам нужна, чтобы заставить этот код работать, — это функция deleteFormData, которую мы поместим с другим кодом сервера Redis.

export const deleteFormData = async (id: string) => {
  await client.connect();
  await client.del(`f-${KEY_VERSION}-${id}`);
  await client.quit();
};

Функциональность редактирования

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

Во-первых, LoaderFunction вернет либо null, либо CustomFormData.

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

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

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

// form/index.tsx

import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { getDataFromRedis, saveToRedis } from "~/services/redis.server";
import type { CustomFormData } from "~/types/form";
import { generateCode } from "../utils/helpers.server";

export const action: ActionFunction = async ({ request }) => {
  // Get the form data from the request.
  const formData = await request.formData();

  // Get each form element
  const name = formData.get("name");
  const phone = formData.get("phone");
  const email = formData.get("email");

  // Check if the form data is valid
  if (
    typeof name !== "string" ||
    typeof phone !== "string" ||
    typeof email !== "string"
  ) {
    throw Error("Form data is invalid"); // This error could be handled differently, but we will keep it for now
  }

  // Check if there is already an ID
  const formId = formData.get("id");
  let id = "";

  // No need to generate a new code if you already have one
  // Also, if you already have one a generate a new code, then you will lose the old data
  if (typeof formId === "string" && formId !== "") {
    console.log(formId);
    id = formId;

    const formDataObject = await getDataFromRedis(id);

    if (!formDataObject) {
      throw Error("Form data is not found");
    }

    formDataObject.name = name;
    formDataObject.phone = phone;
    formDataObject.email = email;

    await saveToRedis(formDataObject);
  } else {
    id = generateCode(6);

    // Create a new object of type CustomFormData
    const formDataObject: CustomFormData = {
      id,
      name,
      phone,
      email,
      // There are other optional fields that will be added in later
    };

    // Save the form data to Redis with a helper in the services folder
    await saveToRedis(formDataObject);
  }

  return redirect(`/form/more?id=${id}`);
};

export const loader: LoaderFunction = async ({ request }) => {
  // This page could possibly not have an ID and that is okay since it is the first page
  const url = new URL(request.url);
  const id = url.searchParams.get("id");

  if (typeof id !== "string" || !id) {
    return null;
  }

  // Get cached form data from Redis
  const formData = await getDataFromRedis(id);

  if (!formData) {
    return null;
  }

  return formData;
};

export default function FormIndex() {
  const data = useLoaderData<CustomFormData | null>();

  return (
    <div>
      <h2 className="text-lg text-gray-600">Basic Info</h2>
      <Form method="post">
        <input type="hidden" name="id" value={data?.id} />
        <label className="mb-1">
          Name
          <input
            type="text"
            name="name"
            placeholder="Name"
            defaultValue={data?.name}
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <label className="mb-1">
          Phone
          <input
            type="tel"
            name="phone"
            placeholder="(123) 456-7890"
            defaultValue={data?.phone}
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <label className="mb-1">
          Email
          <input
            type="email"
            name="email"
            defaultValue={data?.email}
            placeholder="[email protected]"
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <button className="mx-auto mt-2 block w-1/2 rounded bg-blue-600 py-2 text-white hover:bg-blue-700">
          Continue
        </button>
      </Form>
    </div>
  );
}

Страница more проще, так как всегда есть данные из Redis, необходимые для маршрута. Все, что нужно изменить, это то, что LoaderFunction передает весь объект formData и что форма на странице устанавливает значения по умолчанию. Кроме того, мы будем использовать простую функцию для получения строковой даты и возврата.

// form/more.tsx

export const loader: LoaderFunction = async ({ request }) => {
  // Function in redis.server.ts that is reusable for each page of the form
  const formData = await requireFormData(request);

  return formData;
};

export default function MoreForm() {
  const formData = useLoaderData<CustomFormData>();

  return (
    <div>
      <h2 className="text-lg text-gray-600">More Info</h2>
      <Form method="post">
        <input type="hidden" name="id" value={formData.id} />
        <label className="mb-1">
          Bio
          <input
            type="text"
            name="bio"
            placeholder="Bio"
            defaultValue={formData.bio}
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <label className="mb-1">
          Birthday
          <input
            type="date"
            name="birthday"
            defaultValue={String(formData.birthday).split("T")[0] || ""}
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <label className="mb-1">
          Age
          <input
            type="number"
            name="age"
            placeholder="0"
            defaultValue={formData.age?.toString()}
            className="mb-2 w-full p-2 text-lg shadow-md"
          />
        </label>
        <button className="mx-auto mt-2 block w-1/2 rounded bg-blue-600 py-2 text-white hover:bg-blue-700">
          Continue
        </button>
      </Form>
    </div>
  );
}

И точно так же у нас есть функциональность редактирования между каждой страницей формы!

Подведение итогов

Хотя это может показаться немного сложным, преимущество многостраничной формы с использованием Remix и Redis просто фантастическое. Каждый маршрут, помимо сохранения данных в Redis, может выполнять и другие задачи. У вас может быть обработка платежей, проверка данных или многое другое.

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

В ближайшие дни я выпущу еще одно руководство, показывающее, как вы можете развернуть это приложение вместе с экземпляром Redis на Fly.io, так что следите за ним. Пожалуйста, дайте мне знать, если у вас есть какие-либо вопросы или предложения!

Дополнительные материалы на PlainEnglish.io. Подпишитесь на нашу бесплатную еженедельную рассылку новостей. Подпишитесь на нас в Twitter и LinkedIn. Присоединяйтесь к нашему сообществу Discord.