Tiny 0.5kb Zod-based, HTML form abstraction that goes brr.
β If you find this tool useful please consider giving it a star on Github β
The latest v2 of frrm uses Zod v4 (npm install frrm). If you want to use frrm with Zod v3 then you need to install version 1.4.2 (npm install frrm@1). Note currently the API is exactly the same, the only difference is the Zod version you plan on using.
import { create, attach } from 'frrm'
import { z } from 'zod'
const handler = create({
  /**
   * If string value then will replace submit button text with provided value
   * while server request is resolving. Will also disable all buttons and
   * inputs. If `true` is passed the label won't be replaced, but everything will
   * still disable. Alternatively you can pass a callback if you want to
   * manually handle the busy state (will prevent default behaviour).
   */
  onBusy: "Loading...",
  /**
   * Applies client-side Zod validation to determine whether `onSubmit` should
   * fire.
   */
  schema: z.object({
    email: z.string().min(1, { message: "Email value is required" }).email({
      message: "Email is not formatted correctly",
    }),
    password: z
      .string()
      .min(1, {
        message: "Password value is required",
      })
      .min(6, {
        message: "Password is required to be at least 6 characters",
      }),
  }),
  /**
   * Will inject error message into the provided DOM element. Alternatively a
   * callback can be provided that accepts both `timestamp` and `value`
   * properties. 
   * 
   * Note that error is automatically removed when the form is
   * submitted again - likewise a `null` value will be passed to the callback
   * (if used).
   */
  onError: document.querySelector('[role="alert"]')!,
  onSubmit: (submission) => {
    /**
     * Fake server request that takes 4 seconds to resolve, and throws on
     * incorrect email or password.
     */
    return new Promise((resolve) => {
      try {
        setTimeout(() => {
          if (submission.email !== "[email protected]")
            resolve(Error("Invalid email"));
          if (submission.password !== "hunter2")
            resolve(Error("Invalid password"));
          resolve(undefined);
        }, 4000);
      } catch (error) {
        console.error(error);
        resolve(Error("Something went wrong"));
      }
    });
  },
});
/**
 * If you are using a framework like React, then you can simply pass the
 * instance to the onSubmit handler (see example further down page). 
 * However, if you are using plain JavaScript,  then you need to 
 * attach the event listener manually. You can use the `attach`
 * function to do this if you want, since it provides a returned object 
 * with a `remove` method for cleanup.
 */
attach(document.querySelector("form")!, handler);@keyframes enter {
  from {
    transform: translateY(-0.2rem);
    opacity: 0.5;
  }
  
  to {
    transform: translateY(0);
    opacity: 1;
  }
}
form {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1rem;
}
[role="alert"] > * {
  background: rgba(255, 0, 0, 0.05);
  padding: 1rem;
  animation: enter 0.3s ease;
}<form>
  <label>
    <span>Email:</span>
    <input type="email" name="email" />
  </label>
  <div>
    <label>
      <span>Password:</span>
      <input type="password" name="password" />
    </label>
  </div>
  <div role="alert" aria-live="assertive"></div>
  <button type="submit">Login</button>
</form>When using React you can simply pass the handler as is to onSubmit.
import { z } from "zod";
import { useState } from "react";
import { create } from "./react";
export const Example = () => {
  const [message, setMessage] = useState({
    value: null,
    timestamp: Date.now(),
  });
  return (
    <form
      className="form"
      onSubmit={create({
        schema,
        onSubmit: fromServer,
        onError: setMessage,
        onBusy: "Loading...",
        schema: z.object({
          email: z.string().min(1, { message: "Email value is required" }).email({
            message: "Email is not formatted correctly",
          }),
          password: z
            .string()
            .min(1, {
              message: "Password value is required",
            })
            .min(6, {
              message: "Password is required to be at least 6 characters",
            }),
        }),
      })}
    >
      <label>
        <span>Email:</span>
        <input type="email" name="email" />
      </label>
      <div>
        <label>
          <span>Password:</span>
          <input type="password" name="password" />
        </label>
      </div>
      <div>
        {message.value && (
          <div className="message" key={`${message.value}-${message.timestamp}`}>
            {message.value}
          </div>
        )}
      </div>
      <button type="submit">
        Login
      </button>
    </form>
  );
};Pretty much. Technically it is ~0.537kb . This is the minified code:
const e=e=>{const{onSubmit:t,schema:r,onError:a}=e;return async e=>{e.preventDefault(),a({value:null,timestamp:Date.now()});const n=e.currentTarget,o=Object.fromEntries(new FormData(n));try{const e=r.parse(o),n=await t(e);n&&a({value:n,timestamp:Date.now()})}catch(e){if(e.errors.length)return n.querySelector(`[name="${e.errors[0].path[0]}"]`).focus(),a({value:e.errors[0].message,timestamp:Date.now()});throw e}}},t=(e,t)=>(e.addEventListener("submit",t),{remove:()=>e.removeEventListener("submit",t)});export{t as attach,e as create};