htsx

Form

Form best practices using htsx, zod validator and hc (Hono Stack).

Examples

How it works

  1. Submit with blank (client validation with type and required)
  2. Submit with 1 word (zod validation, error message, keep form state)
  3. Submit with 2 words (loading spinner, success toast, clear form state)

Code

api/form.tsx
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import * as z from "zod";
 
const app = new Hono();
 
const schema = z.object({
  text1: z.string().trim().min(2, "Text 1 must be at least 2 characters."),
  text2: z.string().trim().min(2, "Text 2 must be at least 2 characters."),
});
 
const submitRoute = app.post(
  "/submit",
  zValidator("json", schema, (result, c) => {
    if (!result.success) {
      return c.json(z.flattenError(result.error), 400);
    }
  }),
  async (c) => {
    // const { text1, text2 } = c.req.valid("json");
    // try {
    //   await db.insert(form).values({ text1, text2 });
    // } catch (e) {
    //   console.error("Failed:", e);
    //   return c.json({ message: "Failed" }, 503);
    // }
    await new Promise((resolve) => setTimeout(resolve, 1000));
 
    return c.json({ message: "ok" }, 200);
  },
);
 
export type SubmitApiType = typeof submitRoute;
 
export default app;
client/form.tsx
import { hc } from "hono/client";
import { useState } from "hono/jsx";
import { render } from "hono/jsx/dom";
import type { SubmitApiType } from "../api/form";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Spinner } from "../components/ui/spinner";
import { toast } from "../components/ui/toast";
 
const client = hc<SubmitApiType>("/api/form");
 
type Status = "idle" | "loading" | "success" | "error_zod" | "error";
 
type ZodErrors = {
  text1?: string[];
  text2?: string[];
};
 
const STATUS_MESSAGES: Partial<Record<Status, string>> = {
  success: "Submitted.",
  error_zod: "Invalid input.",
  error: "Server error.",
};
 
function FormSample() {
  const [text1, setText1] = useState("");
  const [text2, setText2] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [zodErrors, setZodErrors] = useState<ZodErrors>({});
 
  const handleSubmit = async (e: Event) => {
    e.preventDefault();
 
    setStatus("loading");
    setZodErrors({});
 
    try {
      const res = await client.submit.$post({ json: { text1, text2 } });
 
      if (!res.ok) {
        if (res.status === 400) {
          const data = await res.json();
          setStatus("error_zod");
          setZodErrors({
            text1: data.fieldErrors?.text1,
            text2: data.fieldErrors?.text2,
          });
          toast({ status: "error", message: STATUS_MESSAGES.error_zod ?? "" });
          return;
        }
        setStatus("error");
        toast({ status: "error", message: STATUS_MESSAGES.error ?? "" });
        return;
      }
      setStatus("success");
      setText1("");
      setText2("");
      toast({ status: "success", message: STATUS_MESSAGES.success ?? "" });
    } catch {
      setStatus("error");
      toast({ status: "error", message: STATUS_MESSAGES.error ?? "" });
    }
  };
 
  return (
    <form
      method="post"
      onSubmit={handleSubmit}
      class="not-prose flex w-full max-w-sm flex-col gap-4"
    >
      <Input
        id="form-text-sample"
        label="Text 1"
        name="text1"
        value={text1}
        onInput={(event) => setText1((event.target as HTMLInputElement).value)}
        invalid={Boolean(zodErrors.text1)}
        error={zodErrors.text1?.[0]}
        placeholder="Text 1"
        disabled={status === "loading"}
        required
      />
      <Input
        id="form-text-2-sample"
        label="Text 2"
        name="text2"
        value={text2}
        onInput={(event) => setText2((event.target as HTMLInputElement).value)}
        invalid={Boolean(zodErrors.text2)}
        error={zodErrors.text2?.[0]}
        placeholder="Text 2"
        disabled={status === "loading"}
        required
      />
      <Button type="submit" disabled={status === "loading"}>
        {status === "loading" ? <Spinner /> : "Submit"}
      </Button>
    </form>
  );
}
 
const root = document.getElementById("form-sample-root");
if (root) render(<FormSample />, root);