Multi-Page Forms Using Remix and Redis

Multi-Page Forms Using Remix and Redis

Featured on Hashnode

Recently, I set out to create a multi-page form using Remix. I decided to use Redis to store a temporary version of the form data that can be used between pages. This is a test project showing a lot of the same use cases and implementations.

Code from the project can be found on GitHub

Setup

Create a new Remix project, or use an existing one

npx create-remix@latest

This works with any option. I chose Remix App Server as I am going to deploy and run this on Fly.io

Install Dependencies

npm install redis

For this basic demo, we just need to install Redis. I am also going to install tailwind for stying. You can find instructions on installing tailwind in your project in the Remix Docs

Setup Redis Locally

An easy way to get Redis up and going locally is through Docker. Make sure to have Docker installed (here are the docs on that). Add a file name docker-compose.yml to the root of your project, and then add in the following code into that file:

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

Note: if you have Redis running locally, you would need to change the port so that it does not conflict with your local reds instance

Finally, add a new script into your package.json that you can run to get the Redis instance running:

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

You can then start the Redis instance by running the command:

npm run docker

Note: The -d flag on the docker script means that it will be running in detached mode. Just make sure that there are no errors in your console, and it is running in the background.

Set Environment Variable

The last step in the setup is to set a Redis URL environment variable. Create a file called .env and add a line that states:

REDIS_URL="redis://localhost:6379"

This will be used to create the connection with the local Redis instance. An environment secret can then be set in the deployed version of the app to connect to the remote Redis instance.

Get Started

With the basic setup complete, let’s dive into creating a multi-page form!

I am going to modify the index.tsx route to just have a link over to the form nested routes we will create:

// 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>
  );
}

Next up, create a new file called form.tsx in the routes folder and then, let’s create the basic structure for the elements surrounding the form:

// 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>
  );
}

Note: If you are unfamiliar with the routing in Remix, the Outlet component will render any nested route in its place. That means that the next components that we create in the folder /routes/form/ will render in place of the Outlet.

The next file we will create is an index file within a form folder in the routes. So create a new folder called form and then inside create a new file called index.tsx. We will then create the first page of the form in this file:

// 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="test@example.com"
            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>
  );
}

With the basic form in place, we can set up our first ActionFunction that will receive the form data, save the object in Redis, and then navigate to the next page with the key as a search parameter. Let’s start with the 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 /*

In order for this action function to work, we will write a few functions and a custom type. First, we are using a basic function to generate a code for the ID. This could be done in a variety of ways, but here is the function I am using that I put in the file /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;
};

I placed the type in a folder called types, so it can be used by the helper functions as well as the different routes.

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

Finally, I created a function to save the data to Redis and placed it in a new file in a services folder.

// 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();
};

Another Form Page

Next up, we will create another form page that will check if there is data in Redis with the given ID, and if so, renders the page. The ID should be passed into the page, so it can be used in the next action function.

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>
  );
}

Now to implement the requireFormData function, we will create two new functions in 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;
};

Now, let’s add in the ActionFunction that will handle the form data and saving to Redis again:

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}`);
};

One Last Page

We are on the final page! We will create a confirmation page that can then submit the data. A lot of the concepts are the same. Then, we will look at how you can add in edit functionality to go back to previous pages and alter the form data.

// 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>
  );
}

The only additional function we need to make this code work is the deleteFormData function that we will put with the other Redis server code

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

Editing Functionality

Let’s do a bit of refactoring to add edit functionality to the previous pages that we created. Starting in the form index page, there is a little we have to change in each area.

First, the LoaderFunction will either return null or the CustomFormData.

Second, the page will then accept that through a useLoaderData function and set the default values of the form. It also must set an ID in the form.

Finally, the action function will need to check if there is an ID and if so, it will pull the and update the data, so it doesn’t wipe out the data from the other page.

This part is one area where there is some complexity, but I think the tradeoff is worth it. Simplifying this logic can also be explored further.

// 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="test@example.com"
            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>
  );
}

The more page is simpler since there is always data from Redis required by the route. All that needs to change is that the LoaderFunction passes in the entire formData object and that the Form on the page sets default values. Additionally, we will use a simple function to get the stringified date and return

// 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>
  );
}

And just like that, we have editing functionality between each page of the form!

Wrapping Up

While it may seem a little complicated, the benefit of having a multi-page form using Remix and Redis is fantastic. Each route, in addition to saving the data to Redis, could also perform other tasks. You could have payment processing, data validation, or more.

In a use case that I am working on, the first page of the form is using a useFetcher from Remix to make sure that it is getting valid reservation dates, then the next pages are just gathering more information until payment is accepted. It helps section out the different functionality, while also providing a great user experience.

Next up: Deployment

See how you can deploy this project onto Fly quickly and easily. Here is a link to the article that goes through these next steps.

Please let me know if you have any questions or suggestions!

Did you find this article valuable?

Support Noah Johnson by becoming a sponsor. Any amount is appreciated!