Руководство по созданию многостраничных форм с использованием 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.