Jacob Paris
← Back to all content

Multiple action handlers with Zod in Remix

When mutating data in your route action, you'll want to keep track of which components are using the action.

As your app grows you'll end up having several components using the same action endpoint, and each will submit data with its own payload.

For example, you may have

  • a CreateItem form that makes a new item
  • an EditItem form that lets the user change specific fields
  • a DeleteItem button that deletes the item

Each of these components can have their own Zod schema that is exported from their respective files.

Each one will need an intent field that distinguishes it from the other schemas.

ts
import { z } from "zod"
// components/CreateItem.tsx
export const CreateItemSchema = z.object({
intent: z.literal("create"),
title: z.string(),
description: z.string().optional(),
status: z.enum(["todo", "doing", "done"]),
})
// components/EditItem.tsx
export const EditItemSchema = z.object({
intent: z.literal("edit"),
changeset: z
.object({
title: z.string(),
description: z.string(),
status: z.enum(["todo", "doing", "done"]),
})
.partial(),
})
// components/DeleteItem.tsx
export const DeleteItemSchema = z.object({
intent: z.literal("delete"),
})

In the action for the route that uses these components, you can compose them together as a discriminated union.

If you are only going to accept JSON, you can use zod to parse it on its own.

ts
import { z } from "zod"
export async function action({
request,
}: ActionFunctionArgs) {
const body = await request.json()
const result = z
.discriminatedUnion("intent", [
CreateItemSchema,
EditItemSchema,
DeleteItemSchema,
])
.safeParse(body)
}

If you are only going to accept form data, you can use Conform's parseWithZod function. It will turn the form data into an object, while coercing multiple entries with the same name into an array.

ts
import { parseWithZod } from "@conform-to/zod"
export async function action({ request }) {
const formData = await request.formData()
const submission = parseWithZod(formData, {
schema: z.discriminatedUnion("intent", [
CreateItemSchema,
EditItemSchema,
DeleteItemSchema,
]),
})
if (submission.status !== "success") {
throw new Error("Unknown form schema")
}
}

If you want to handle JSON and form data, you can use this parseRequest function that works with both.

ts
export async function action({ request }) {
const submission = parseRequest(request, {
schema: z.discriminatedUnion("intent", [
CreateItemSchema,
EditItemSchema,
DeleteItemSchema,
]),
})
if (submission.status !== "success") {
throw new Error("Unknown form schema")
}
if (submission.value.intent === "create") {
return createItem(submission.value)
}
if (submission.value.intent === "edit") {
return updateItem(submission.value)
}
if (submission.value.intent === "delete") {
return deleteItem(submission.value)
}
throw new Error("Unknown intent")
}
Professional headshot
Moulton
Moulton

Hey there! I'm a developer, designer, and digital nomad building cool things with Remix, and I'm also writing Moulton, the Remix Community Newsletter

About once per month, I send an email with:

  • New guides and tutorials
  • Upcoming talks, meetups, and events
  • Cool new libraries and packages
  • What's new in the latest versions of Remix

Stay up to date with everything in the Remix community by entering your email below.

Unsubscribe at any time.