Headless Core

Quick Start

Get started with headless Goodie Forms core.
Remember! @goodie-forms/core exists mainly as a headless virtual form state tracker and data manager. If you don't need low-level building blocks, you can always check on the conviniently wrapped packages such as @goodie-forms/react or @goodie-forms/vue.

Install Dependencies

  1. Install Goodie Forms
pnpm i @goodie-forms/core
  1. Install a Validation Library (totally optional) See supported libs
pnpm i zod # Or any other validation lib

Build your first DOM

<form id="userform">
  <input id="firstname" type="text" />
  <input id="lastname" type="text" />
  <input id="age" type="number" />
  <button type="submit">Submit</button>
</form>

Build your first Form

import { FormController } from "@goodie-forms/core";
import type { FieldPath } from "@goodie-forms/core";
import z from "zod"; // <-- Can be any StandardSchemaV1 compliant lib!

// An examplar schema with 3 values
const UserSchema = z.object({
  firstname: z.string().nonempty(),
  lastname: z.string().nonempty(),
  age: z.number().int(),
});

const formController = new FormController({
  //  ^? FormController<{ firstname: string; lastname: string; age: number; }>
  validationSchema: UserSchema,
  initialData: {
    firstname: "",
    lastname: "",
  },
});

Register your first Fields

// Register firstname field, and bind a DOM node
// Remember, the control is yours with this headless core lib!
const firstnamePath = formController.path.of("firstname"); 
//                                           ^ IntelliSense will suggest the paths
const firstnameField = formController.registerField(firstnamePath);
const firstnameEl = document.getElementById("fistname") as HTMLInputElement;
firstnameEl.onchange = (e) =>
  firstnameField.setValue((e.target as HTMLInputElement).value);
firstnameField.bindElement(firstnameEl);

// Maybe even build a helper to encapsulate some logic!
function registerWithElement<
  TOutput extends object,
  TPath extends FieldPath.Segments,
  TElement extends HTMLElement
>(
  formController: FormController<TOutput>,
  path: TPath,
  el: TElement,
  valueGetter: (el: TElement) => FieldPath.Resolve<TOutput, TPath>,
) {
  const field = formController.registerField(path);
  el.onchange = (e) => field.setValue(valueGetter(e.target as TElement));
  field.bindElement(el);
  return field;
}

const lastnameField = registerWithElement(
  formController,
  formController.path.of("lastname"),
  document.getElementById("lastname") as HTMLInputElement,
  (el) => el.value,
);

const ageField = registerWithElement(
  formController,
  formController.path.of("age"),
  document.getElementById("age") as HTMLInputElement,
  (el) => el.valueAsNumber,
);

Attach Submit Handler

const formEl = document.getElementById("form") as HTMLFormElement;

formEl.onsubmit = formController.createSubmitHandler(
  // Success callback
  async (data, event) => {
    //   ^? { firstname: string; lastname: string; age: number; }

    event.preventDefault();
    //^? SubmitEvent

    console.log("Do whatever, with", data);

    await maybeSendToARemoteAPI(data);

    orConsumeSyncLocally(data);
  },

  // Error callback, with issues arised
  async (issues) => {
    //   ^? StandardSchemaV1.Issue[]
    console.log("Wops, cannot submit. These issues raised:", issues);
  },
);
Copyright © 2026