I've spent a surprising amount of my career building admin portals.
There were four of them in total: two at Accumulus and two at Restful Mind. In both cases, the “two” came from rebuilding a portal with a new stack.
And admin portals, as anyone who has worked on one knows, are mostly forms.
Some were simple. Some were not. At Accumulus alone, we had around forty forms covering products, plans, coupons, subscriptions, and all the business rules that come with a mature SaaS product.
So I've had plenty of reasons to think about form state.
It started as part of a UI library
I never planned to build a standalone form library.
The form API evolved through several projects and a few different designs.
| Project | Stack | Form model |
|---|---|---|
| Admin portal 1 (Accumulus) | Polymer + Dart | Flat, UI-coupled |
| Admin portal 2 (Accumulus) | Lit + TypeScript | Tree, UI-coupled |
| Admin portal 3 (Restful Mind) | React + TypeScript | React Hook Form |
| Admin portal 4 (Restful Mind) | Lit + TypeScript | Flat, UI-coupled |
| Kin Form (extracted package, Lit only) | Lit + TypeScript | Tree, UI-coupled |
| Kin Form (framework-agnostic) | TypeScript | Tree, UI-free |
The first version was built at Accumulus for a Polymer/Dart admin portal. It had a fairly simple model: a form and a collection of fields similar to the web's native form and input elements.
When we rebuilt the UI with Lit and TypeScript, I redesigned the form API around a tree based on @angular/forms. A form control could either be a leaf control or a collection of child controls. This worked much better for the complex forms (with nested groups/arrays) we were building.
At Restful Mind, the first admin portal used React Hook Form and Chakra UI. React Hook Form was usable but it didn't quite feel right to me. I found myself thinking about forms differently: less as a collection of inputs connected to a form, and more as structured state with its own hierarchy and behavior.
For the second admin portal at Restful Mind, I simplified the API again to be flat with just FormController and FormField.
After finishing my work at Restful Mind, I decided to extract the form API from the UI library into a standalone package for Lit and published it to JSR and npm. For this initial Kin Form, I went back to the tree model as it better supports nested groups/arrays.
LitElement → FormField → FieldGroup → FormController
This was the first time the form API existed independently of the UI library.
But it was still tied to Lit.
Lit has a smaller ecosystem than React. If I wanted Kin Form to reach more
developers, I would need to make it work with React and other frameworks.
I remembered that TanStack already had a framework-agnostic form library, so I looked at TanStack Form for some ideas. It confirmed that separating form state from the UI framework was a direction worth exploring.
But I also wanted to keep the tree model I had arrived at through the Lit
versions, while making the nodes themselves independent of UI components.
That led to the current design of Kin Form:
BaseApi → FieldApi → FormApi
BaseApi provides the pub/sub mechanism. FieldApi adds state, configuration, field operations, parent relationships, and child registration. FormApi adds form-level operations such as reset and submit.
The important part is that every node in the form tree is a FieldApi.
FormApi isn't required to use the tree. You can work with a FieldApi tree and handle things like reset and submission yourself. FormApi simply adds those form-level operations when you want them.
The nodes are no longer UI components. They are framework-agnostic state objects that can be connected to React, Lit, or another UI framework.
That became the new Kin Form: a framework-agnostic, type-safe form state library for TypeScript, with bindings for React and Lit (and more to come).
The idea is simple: a form is a tree
Forms tend to grow in a predictable way.
You start with a name and an email address. Then you add an address. Then maybe a list of contacts. Then conditional fields. Then validation that depends on another field. Eventually you have tables where users can add, remove, and reorder rows.
At that point, the form is no longer just a collection of inputs. It's a piece of application state with a structure of its own.
The model I settled on is simple:
A form is a tree, and every node in that tree follows the same model. Each node has its own state, configuration, and subscribers.
Nested groups, arrays, and leaf fields all use the FieldApi model. There doesn't need to be a separate abstraction every time the value gets more complicated.
The form tree doesn't have to mirror the value shape exactly, either.
Given the same value type, you can choose how much structure you want in the form tree. A form can be completely flat, fully nested, or somewhere in between.
For example, a value like:
type Order = {
email: string;
items: {
name: string;
quantity: number;
}[];
shipping: {
line1: string;
line2: string;
};
};
can be represented as
// Completely flat
Form
├── email
├── items.0.name
├── items.0.quantity
├── shipping.line1
└── shipping.line2
// Fully nested
Form
├── email
├── items
| └── 0
| ├── name
| └── quantity
└── shipping
├── line1
└── line2
// Somewhere in between
Form
├── email
├── items.0.name
├── items.0.quantity
└── shipping
├── line1
└── line2
All three trees represent the same value. The difference is how the form state is organized.
This makes the tree a useful composition mechanism rather than a constraint imposed by the value structure. You can introduce a group when you need shared behavior, validation, subscriptions, or reusable components without having to change the underlying value shape.
Forms should read like composition
This is probably the part of Kin Form that best shows what I was trying to do.
Instead of passing a form context around and having each component figure out which part of the form it needs, a component gets the node it works with:
// React
<form onSubmit={form.handleSubmit}>
<TextField api={form.field("email")} label="Email" />
<AddressField api={form.field("shipping")} />
<AddressField api={form.field("billing")} />
<ItemsField api={form.field("items")} />
<SubmitButton api={form}>Place order</SubmitButton>
</form>
// Lit
html`
<form @submit=${form.handleSubmit}>
<text-field .api=${form.field("email")} label="Email"></text-field>
<address-field .api=${form.field("shipping")}></address-field>
<address-field .api=${form.field("billing")}></address-field>
<items-field .api=${form.field("items")}></items-field>
<submit-button .api=${form}>Place order</submit-button>
</form>
`;
Each component receives a resolved FieldApi, rather than a path or a form context. The component just focuses on its UI and behavior without dealing with how to obtain the FieldApi or where it lives in the form tree. This approach significantly simplifies component implementation and enables 100% type safety.
This means I can build a field component once and use it wherever its value type fits.
The core doesn't know about UI frameworks
The form state itself shouldn't need to know how it's being rendered.
Kin Form has a framework-agnostic core, with framework bindings on top of it. The bindings take care of subscriptions and connecting the form state to the framework's rendering model.
For example, a reusable TextField receives a FieldApi:
// React
export type TextFieldProps<TParentValue> = {
api: FieldApi<string, TParentValue>;
label: string;
}
export function TextField<TParentValue>({
api,
label,
}: TextFieldProps<TParentValue>) {
const field = useWatch(api);
return (
<label>
{label}
<input
value={field.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.touched && field.invalid && (
<span>{field.error ?? field.schemaError}</span>
)}
</label>
);
}
// Lit
@customElement("text-field")
export class TextField extends LitElement {
@property({ attribute: false })
accessor api!: FieldApi<string, unknown>;
@property()
accessor label = "";
#watch = new WatchController(this, () => this.api);
override render() {
const field = this.#watch.value;
return html`
<label>
${this.label}
<input
.value=${field.value}
@blur=${field.handleBlur}
@input=${(e: Event) =>
field.handleChange((e.target as HTMLInputElement).value)}
>
</label>
`;
}
}
The implementations are framework-specific, as they should be. The form API they
consume is not.
That's the separation I wanted when I started the rewrite.
What I care about in the design
The main things I wanted to get right were type safety, nested groups, dynamic arrays, reusable field components, and keeping the API relatively small.
Some of the details follow naturally from the tree model.
For example, a path such as field("items.0.name") is checked against the
form's value type. Array operations such as push, insert, move, swap, and remove are part of the same field model rather than requiring a separate array API.
Validation can be scoped to a field, a subtree or the whole form, and fields can declare dependents when validation needs to react to changes elsewhere.
Subscriptions are also scoped to the nodes that need them, so a change doesn't have to cause the entire form to update.
These aren't separate features that I added one by one. They're mostly
consequences of having the same model at every level of the tree.
How it compares
The design was the main reason I built Kin Form, but performance and bundle size still matter for a library like this. Below is how Kin Form compares with React Hook Form, Formik, and TanStack Form.
Those numbers come from my own benchmarks, so I wouldn't treat them as absolute. The scenarios and implementation details matter. For full comparison, see here.
For me, the more interesting question is whether the design makes real forms easier to build and maintain.
When it makes sense, and when it doesn't
Kin Form isn't something I think every form needs.
If you're building a small login or contact form, component-local state may be all you need. And if your team already has a form library that works well, there may be no reason to switch.
Kin Form is well suited to forms with shared field components, nested objects, dynamic arrays, multiple steps, flexible validation, or state that needs to survive UI unmounts and remounts.
That's the kind of problem I built it for.
Still learning in public
This is the first version of Kin Form that I'm putting out as an independent, framework-agnostic library.
I expect the design to change as more people use it. There are forms and use cases I haven't encountered yet, and those are usually where the most useful feedback comes from.
Kin Form is now part of Kintools, where I'm bringing together small, focused tools built from ideas I've found useful in real applications.
You can explore Kin Form and its documentation at kintools.dev/form.
If you try it, I'd love to hear whether the tree model feels natural when
building real-world forms, and where it gets in your way.
That's the part I'm most interested in learning next.



Top comments (0)