
Fullstack serverless web development with Remix and Architect (Part 4, validated forms in Remix with zod)
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.
