Photo by Andrew Neel on Unsplash
Effortless Client Server Communication with Remix and Zod
Remix's server actions along with Zod unlock lightning fast API development. Inputs and outputs are immediately available for use and strongly typed.
I recently started using Remix for a few projects and I’ve been impressed with the client server model. I enjoy the clear boundaries between server and client and find it easier to reason about compared to NextJS.
If you’re not familiar with Remix I’d encourage you to read their docs on Server vs. Client code. Here is a summary:
We can define a route file which exports the client component and the server action
...
export default function Product() {
return (
<Form method="post">
<ProductDetails />
<input type="hidden" id="productId" name="productId" value="1234" />
<button type="submit">Add to cart</button>
</Form>
);
}
export async function action({
request,
}: ActionFunctionArgs) {
const formData = await request.formData();
const productId = formData.get("productId"),
// do something server-side with the product
}
This is a great start, but I’ve found that the “everything is a form” approach gets cumbersome for more complex web apps.
remix-pc
I created a small library called remix-pc to solve the “everything is a form” problem. Here’s how it works:
With remix-pc we define fully typed API methods as routes, let’s make one called addToCart
// routes/api.addToCart/route.tsx
import { serverOnly$ } from "vite-env-only/macros";
import { z } from "zod";
import { zfd } from "zod-form-data";
import { registerAction, useAPI } from "remix-pc";
const ROUTE = "/api/addToCart";
export const CartItem = zfd.formData({
price: z.number(),
quantity: z.number(),
productId: z.string(),
title: z.string(),
});
const CartItemResponse = z.object({
success: z.boolean(),
message: z.string(),
});
// Remix only removes the export named 'action' from the client build, so we notate this method as $serverOnly
const addToCart = serverOnly$(async (cartItem: z.infer<typeof CartItem>): Promise<z.infer<typeof CartItemResponse>> => {
// Do something server-side with cartItem
return {
success: true,
message: "Item added to cart",
};
});
export const action = registerAction(CartItem, addToCart!);
export function useAddToCart() {
const submit = useAPI<typeof CartItem, typeof action>(ROUTE);
return submit;
}
In just a few lines, we’ve created an API method with enforced input and output types ready for use by our component:
export default function Product() {
const addToCart = useAddToCart();
const clickedAddToCart = useCallback(async () => {
const response = await addToCart({
price: 10,
quantity: 1,
productId: "123",
title: "Test Product",
});
console.log("addToCart response", response);
}, [addToCart]);
return (
<div>
<ProductDetails />
<button type="submit" onClick={clickedAddToCart}>Add to cart</button>
</div>
);
}
This approach offers several major advantages over stock Remix form submission. We get zod enforced typing of inputs and outputs as well as an inline response back to our component without ever having to think about <form>
elements.
If you’re interested in how this is accomplished under the hood, the library implements two methods: registerAction
and useAPI
registerAction
This method creates the server function that remix expects. It iterates over each item in the form and parses each string item back into a JSON object. It then parses the entire built object to the provided zod type:
import { ActionFunctionArgs } from "@remix-run/node";
import { z, ZodType } from "zod";
export function registerAction<T extends ZodType>(
object: T,
handler: (input: z.infer<T>) => unknown,
) {
return async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const formDataObject = Object.fromEntries(formData.entries());
// Need to JSON parse each field in the form data
// because we encoded them as JSON strings
for (const [key, value] of formData.entries()) {
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
formDataObject[key] = parsed;
} catch (error) {
console.error(`Failed to parse JSON for key: ${key}`);
}
}
}
const body = object.parse(formDataObject);
return handler(body);
};
}
useAPI
This hook exposes a submit method which iterates over each property of a zod object, encoding it as a string and appending it onto a form object. It then submits the form and observes Remix’s fetcher for a response. I prefer to do the observing in this internal class so that the call-site can imperatively get the response inline from the server. It also includes a timeout to fail the promise in case there is a server communication failure.
import { useFetcher } from "@remix-run/react";
import { useCallback, useEffect, useRef } from "react";
import { z, ZodType } from "zod";
export function useAPI<T extends ZodType, R>(action: string) {
const promiseResolveRef = useRef<(value: typeof fetcher.data) => void>();
const promiseResolveTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const fetcher = useFetcher<R>();
useEffect(() => {
if (fetcher.state !== "idle" || promiseResolveRef.current === undefined || !fetcher.data) {
return;
}
promiseResolveTimeoutRef.current && clearTimeout(promiseResolveTimeoutRef.current);
promiseResolveRef.current(fetcher.data);
}, [fetcher]);
const submit = useCallback(
(item: z.infer<T>) => {
const body = new FormData();
Object.entries(item).forEach(([key, value]) => {
if (value instanceof Blob) {
body.append(key, value);
} else {
body.append(key, JSON.stringify(value));
}
});
fetcher.submit(body, {
method: "POST",
action,
});
return new Promise<typeof fetcher.data>((resolve, reject) => {
promiseResolveRef.current = resolve;
promiseResolveTimeoutRef.current = setTimeout(() => {
reject(new Error("API Timeout"));
}, 10000);
});
},
[action, fetcher],
);
return submit;
}
I hope you find this utility as useful as I have for rapid API iteration.
My initial knowledge of this subject came from Jacob Paris’ blog, I encourage you to check it out: https://www.jacobparis.com/content/remix-rpc-pattern