Fullstack serverless web development with Remix and Architect (Part 4, validated forms in Remix with zod)

Tuomas Koivistoinen

In the last part of our series on Remix and Architect, we separated some of the UI code from route code. In this part we will do the same for forms, but go a bit further by adding a few modules to make form validation more succinct.

Remix form validation

We’ll start by looking at what the sample has, then install the necessary modules and create some UI components with validation hooked in, and finally we will use those in the route code.

Sample application form

Let’s start by reviewing the current notes/new route.

Sure it does frontend and backend validation, error messages and accessibility, but it’s a 116 line file for a single form and it doesn’t include the code communicating with the database (it only calls a function that does). I think that we can do better.

import type { ActionFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import * as React from "react";

import { createNote } from "~/models/note.server";
import { requireUserId } from "~/session.server";

type ActionData = {
  errors?: {
    title?: string;
    body?: string;
  };
};

export const action: ActionFunction = async ({ request }) => {
  const userId = await requireUserId(request);

  const formData = await request.formData();
  const title = formData.get("title");
  const body = formData.get("body");

  if (typeof title !== "string" || title.length === 0) {
    return json<ActionData>(
      { errors: { title: "Title is required" } },
      { status: 400 }
    );
  }

  if (typeof body !== "string" || body.length === 0) {
    return json<ActionData>(
      { errors: { body: "Body is required" } },
      { status: 400 }
    );
  }

  const note = await createNote({ title, body, userId });

  return redirect(`app/notes/${note.id}`);
};

export default function NewNotePage() {
  const actionData = useActionData() as ActionData;
  const titleRef = React.useRef<HTMLInputElement>(null);
  const bodyRef = React.useRef<HTMLTextAreaElement>(null);

  React.useEffect(() => {
    if (actionData?.errors?.title) {
      titleRef.current?.focus();
    } else if (actionData?.errors?.body) {
      bodyRef.current?.focus();
    }
  }, [actionData]);

  return (
    <Form
      method="post"
      style={{
        display: "flex",
        flexDirection: "column",
        gap: 8,
        width: "100%",
      }}
    >
      <div>
        <label className="flex w-full flex-col gap-1">
          <span>Title: </span>
          <input
            ref={titleRef}
            name="title"
            className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose"
            aria-invalid={actionData?.errors?.title ? true : undefined}
            aria-errormessage={
              actionData?.errors?.title ? "title-error" : undefined
            }
          />
        </label>
        {actionData?.errors?.title && (
          <div className="pt-1 text-red-700" id="title-error">
            {actionData.errors.title}
          </div>
        )}
      </div>

      <div>
        <label className="flex w-full flex-col gap-1">
          <span>Body: </span>
          <textarea
            ref={bodyRef}
            name="body"
            rows={8}
            className="w-full flex-1 rounded-md border-2 border-blue-500 py-2 px-3 text-lg leading-6"
            aria-invalid={actionData?.errors?.body ? true : undefined}
            aria-errormessage={
              actionData?.errors?.body ? "body-error" : undefined
            }
          />
        </label>
        {actionData?.errors?.body && (
          <div className="pt-1 text-red-700" id="body-error">
            {actionData.errors.body}
          </div>
        )}
      </div>

      <div className="text-right">
        <button
          type="submit"
          className="rounded bg-blue-500  py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400"
        >
          Save
        </button>
      </div>
    </Form>
  );
}

New components for form validation

Let’s start by adding the Remix Validated Forms module, with zod as the chosen plugin. Again I’d advise you to scan the docs before following along.

npm i remix-validated-form @remix-validated-form/with-zod zod

Now we can make an input component that is automatically hooked up to validation.

import { useField } from "remix-validated-form";

interface TextInputProps {
  name: string;
  label: string;
}

export const TextInput = ({ name, label }: TextInputProps) => {
  const { error, getInputProps } = useField(name);
  return (
    <div>
      <label className="flex w-full flex-col gap-1">
        <span>{label}</span>
        <input
          {...getInputProps({ id: name })}
          className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose"
        />
      </label>
      {error && (
        <div className="pt-1 text-red-700" id="title-error">
          {error}
        </div>
      )}
    </div>
  );
};

And also a custom form component.

import { ValidatedForm } from "remix-validated-form";

import type { Validator } from "remix-validated-form";

interface BasicFormProps {
  validator: Validator<unknown>;
  children: JSX.Element;
}

export default function BasicForm({ validator, children }: BasicFormProps) {
  return (
    <ValidatedForm
      validator={validator}
      method="post"
      style={{
        display: "flex",
        flexDirection: "column",
        gap: 8,
        width: "100%",
      }}
    >
      {children}

      <div className="text-right">
        <button
          type="submit"
          className="rounded bg-blue-500  py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400"
        >
          Save
        </button>
      </div>
    </ValidatedForm>
  );
}

Refactor new note route

Now we are ready to refactor the new note form.

import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { withZod } from "@remix-validated-form/with-zod";
import { validationError } from "remix-validated-form";
import { z } from "zod";
import BasicForm from "~/components/BasicForm";
import { TextInput } from "~/components/TextInput";
import { createNote } from "~/models/note.server";
import { requireUserId } from "~/session.server";

export const validator = withZod(
  z.object({
    title: z.string().min(1, "Title is required"),
    body: z.string().min(1, "Body is required"),
  })
);

export const action: ActionFunction = async ({ request }) => {
  const userId = await requireUserId(request);

  const result = await validator.validate(await request.formData());
  if (result.error) return validationError(result.error);

  const { title, body } = result.data;
  const note = await createNote({ title, body, userId });

  return redirect(`app/notes/${note.id}`);
};

export default function NewNotePage() {
  return (
    <BasicForm validator={validator}>
      <TextInput name="title" label="Title" />
      <TextInput name="body" label="Body" />
    </BasicForm>
  );
}

We could have made a custom TextArea component too, but we got the idea of how to make validated inputs with TextInputs, so let’s stick with that for now. We are down to a 37 line file and everything seems succinct enough. Note that since validator works both in the browser and NodeJS, we can declare it at top level and use it both on the server side action and the client side form.

Coming Up in Part 5

In this part we refactored our forms to be a lot more succinct utilising zod and Remix Validated Forms. When you are ready, please join me in the next part where we will start building new features.

Get in Touch.

Let’s discuss how we can help with your cloud journey. Our experts are standing by to talk about your migration, modernisation, development and skills challenges.