This is a continuation of CotomyElement Value and Form Behavior . The previous article focused on what values the browser actually submits. This time the focus is the form classes themselves: which one to use, what each class adds, and which methods are meant to be overridden in real screens.
The practical point is simple. Cotomy does not treat every form as one generic submit helper. It splits query navigation, API submit, entity-aware submit, and entity load-and-fill into different classes so the screen can keep one explicit runtime path.
The Form Line in Cotomy
The current form line in the implementation is this:
flowchart TD
A[CotomyForm]
B[CotomyQueryForm]
C[CotomyApiForm]
D[CotomyEntityApiForm]
E[CotomyEntityFillApiForm]
A --> B
A --> C
C --> D
D --> E
Each class adds one operational concern. CotomyForm standardizes submit interception. CotomyQueryForm turns that into query navigation. CotomyApiForm turns it into API submit with FormData. CotomyEntityApiForm adds entity identity handling. CotomyEntityFillApiForm adds load and fill behavior on top.
CotomyForm
CotomyForm is the base contract. It is not just a base class. It is the entry point where form submission becomes part of the screen runtime. It intercepts submit in initialize(), prevents native navigation, and calls submitAsync(). Its default method is get, and its default actionUrl is the current path plus query string.
In practice, you do not usually instantiate CotomyForm directly for business screens. You inherit from one of the concrete classes. What matters is the baseline behavior. initialize() wires the submit event once, reloadAsync() performs a full window reload by default, and autoReload controls whether page restore should call reloadAsync().
That makes CotomyForm the common lifecycle surface for all form variants.
CotomyQueryForm
Use CotomyQueryForm when the form should rewrite the URL and move the screen by query string. This is the right fit for search conditions, list filters, and paging inputs that belong to the page URL itself.
import { CotomyElement, CotomyPageController, CotomyQueryForm } from "cotomy";
class CustomerListPage extends CotomyPageController {
protected override async initializeAsync(): Promise<void> {
await super.initializeAsync();
this.setForm(
CotomyElement.byId("customer-search-form", class extends CotomyQueryForm {})!
);
}
}
CotomyPageController.set(CustomerListPage);
The implementation always uses GET here. When submitAsync() runs, it reads the current actionUrl, merges existing query values with current form values, removes empty values, rebuilds the query string, and sets location.href.
That means this class is for navigation, not API transport. If the screen should stay on the same page and call fetch, move to CotomyApiForm instead.
If you need a custom target URL, override actionUrl.
import { CotomyQueryForm } from "cotomy";
class ProductSearchForm extends CotomyQueryForm {
public override get actionUrl(): string {
const category = this.attribute("data-category") ?? "all";
return `/products?category=${encodeURIComponent(category)}`;
}
}
CotomyApiForm
Use CotomyApiForm when the form should submit to an API endpoint but the screen is not using entity identity switching. Typical cases are feedback forms, operation dialogs, import forms, or one-off actions that are not edit screens for one resource.
import {
CotomyApiForm,
CotomyElement,
CotomyPageController,
} from "cotomy";
class FeedbackForm extends CotomyApiForm {
public override get actionUrl(): string {
return "/api/feedback";
}
}
class FeedbackPage extends CotomyPageController {
protected override async initializeAsync(): Promise<void> {
await super.initializeAsync();
const form = this.setForm(CotomyElement.byId("feedback-form", FeedbackForm)!);
form.submitFailed(event => {
if (event.response.status === 422) {
CotomyElement.byId("feedback-status")!.text = "Please review the input.";
}
});
}
}
CotomyApiForm changes the default method from get to post, requires a concrete actionUrl, builds FormData from the real form element, and calls CotomyApi.submitAsync().
The important built-in behavior is not only transport. It also does three practical things.
First, datetime-local values are converted before submit. The implementation rewrites them from local browser input format into an offset-aware string.
Second, API failures dispatch events. When CotomyApi throws a CotomyApiException, CotomyApiForm emits cotomy:apifailed and cotomy:submitfailed before rethrowing.
Third, the screen can replace the API client. That is what apiClient() is for.
These are not convenience features. They ensure that submit behavior stays consistent across screens.
import { CotomyApi, CotomyApiForm } from "cotomy";
class AdminTaskForm extends CotomyApiForm {
public override apiClient(): CotomyApi {
return new CotomyApi({
baseUrl: "/admin",
headers: {
"X-Screen": "task-runner",
},
});
}
public override get actionUrl(): string {
return "/api/tasks/run";
}
}
If the server expects a non-default method, override method.
import { CotomyApiForm } from "cotomy";
class ApprovalForm extends CotomyApiForm {
public override get actionUrl(): string {
return "/api/approvals";
}
protected override get method(): string {
return "patch";
}
}
CotomyEntityApiForm
Use CotomyEntityApiForm when the form is tied to one entity and the submit path should change between create and update. This is the class that turns one form into a POST-or-PUT form based on whether the form already has an entity key.
The entity key lives on data-cotomy-entity-key. When the key is missing, the default method is post. When the key exists, the default method becomes put. The actionUrl also changes. If the action is /api/users and the entity key is 42, the effective actionUrl becomes /api/users/42.
<form id="user-edit-form" action="/api/users" data-cotomy-entity-key="42">
<input type="text" name="user[name]" />
</form>
import { CotomyElement, CotomyEntityApiForm, CotomyPageController } from "cotomy";
class UserEditPage extends CotomyPageController {
protected override async initializeAsync(): Promise<void> {
await super.initializeAsync();
this.setForm(
CotomyElement.byId("user-edit-form", class extends CotomyEntityApiForm {})!
);
}
}
On a 201 Created response, the class also tries to read the Location header and store the generated entity key back onto the form. That behavior matters for screens that start as create and continue as update without rebuilding the screen contract.
The main override point here is still method when the server contract is different.
import { CotomyEntityApiForm } from "cotomy";
class LegacyOrderForm extends CotomyEntityApiForm {
public override get actionUrl(): string {
return "/legacy/orders";
}
protected override get method(): string {
return this.entityKey ? "patch" : "post";
}
}
This is also the point where you should be strict about boundaries. If the API does not follow an entity-oriented URL contract, it is usually cleaner to step back to CotomyApiForm than to fight the entity-aware behavior. If the API does not represent a single stable resource over time, forcing it into CotomyEntityApiForm usually creates drift instead of reducing it.
CotomyEntityFillApiForm
CotomyEntityFillApiForm is the form for edit or detail screens that need both submit and load. It extends CotomyEntityApiForm and adds one more path:
- initialize() schedules loadAsync() on window ready
- loadAsync() sends GET to loadActionUrl when canLoad is true
- fillAsync() writes the response into matching inputs
- renderer().applyAsync(response) updates data-cotomy-bind targets
- successful submit also calls fillAsync(response)
This is the form type you use when the screen should stay aligned with one entity over time.
import {
CotomyElement,
CotomyEntityFillApiForm,
CotomyPageController,
} from "cotomy";
class CustomerForm extends CotomyEntityFillApiForm {}
class CustomerPage extends CotomyPageController {
protected override async initializeAsync(): Promise<void> {
await super.initializeAsync();
this.setForm(CotomyElement.byId("customer-form", CustomerForm)!);
}
}
The default load condition is whether the form already has an entity key. That is often enough for ordinary edit pages, but the real extension points are in the protected methods.
Overriding loadActionUrl
loadActionUrl defaults to actionUrl. Override it when the save endpoint and load endpoint are different.
import { CotomyEntityFillApiForm } from "cotomy";
class CustomerProfileForm extends CotomyEntityFillApiForm {
public override get actionUrl(): string {
return "/api/customers";
}
protected override get loadActionUrl(): string {
return `/api/customer-profiles/${encodeURIComponent(this.entityKey!)}`;
}
}
Overriding canLoad
Override canLoad when load should wait for more than just an entity key. This is useful when the screen needs a mode flag, permission flag, or another prerequisite before the initial GET.
import { CotomyEntityFillApiForm } from "cotomy";
class InvoiceForm extends CotomyEntityFillApiForm {
protected override get canLoad(): boolean {
return !!this.entityKey && this.attribute("data-mode") === "edit";
}
}
Overriding bindNameGenerator
fillAsync() fills inputs by their name attribute and also uses CotomyViewRenderer for bind targets. The bindNameGenerator() hook exists so both paths use the same naming contract.
import {
CotomyDotBindNameGenerator,
CotomyEntityFillApiForm,
ICotomyBindNameGenerator,
} from "cotomy";
class DotNameCustomerForm extends CotomyEntityFillApiForm {
protected override bindNameGenerator(): ICotomyBindNameGenerator {
return new CotomyDotBindNameGenerator();
}
}
That matters when your screen uses names like customer.name instead of customer[name].
Overriding renderer
renderer() returns a CotomyViewRenderer built from the form and the bind name generator. Override it when the non-input reflection layer needs a different renderer setup.
import {
CotomyEntityFillApiForm,
CotomyViewRenderer,
} from "cotomy";
class SummaryForm extends CotomyEntityFillApiForm {
public override renderer(): CotomyViewRenderer {
return new CotomyViewRenderer(this, this.bindNameGenerator());
}
}
In many screens, you will not need to override renderer() at all. The practical override point is usually bindNameGenerator(), not renderer() itself.
Adding custom fillers
CotomyEntityFillApiForm already includes filler behavior for datetime-local, checkbox, and radio. If one input type needs custom write behavior, register another filler during initialize().
import { CotomyEntityFillApiForm } from "cotomy";
class UserSettingsForm extends CotomyEntityFillApiForm {
public override initialize(): this {
super.initialize();
this.filler("date", (input, value) => {
input.value = String(value ?? "").slice(0, 10);
});
return this;
}
}
The key point is that filler customization is local to the form. Cotomy does not force one global fill rule for every input type.
What This Form Does Not Do
There is one boundary worth keeping explicit. CotomyEntityFillApiForm does not automatically fill multiple select inputs. The implementation skips select elements with multiple and also skips names ending in [] while walking object properties.
That is intentional. Array synchronization patterns vary too much across projects, so the core form keeps that part explicit instead of pretending there is one universal answer.
Choosing the Right Form
The key question is not the data shape, but the operational path the screen must keep stable.
If the screen goal is URL navigation, use CotomyQueryForm. If the goal is API submit without entity identity, use CotomyApiForm. If the form should switch between create and update by entity key, use CotomyEntityApiForm. If the screen also needs initial load and response-to-input reflection, use CotomyEntityFillApiForm.
That separation is what keeps Cotomy forms practical. The class you choose already says what kind of runtime path the screen is allowed to take.
Usage Series
This article is part of the Cotomy Usage Series, which focuses on concrete runtime behavior and day-to-day API usage.
Series articles: CotomyElement in Practice , CotomyElement Value and Form Behavior , CotomyForm in Practice, CotomyApi in Practice , and Debugging Features and Runtime Inspection in Cotomy .
Conclusion
Cotomy forms are easier to use when you do not flatten them into one generic abstraction. Each class exists to keep one runtime concern explicit: navigation, API submit, entity identity, or entity load and reflection. This separation is not about abstraction. It is what prevents multiple informal paths from emerging as the screen grows.
The most useful override points are actionUrl, method, loadActionUrl, canLoad, bindNameGenerator, and local filler registration. If those stay aligned with the real screen contract, the form stays small and the page controller does not need to absorb transport logic.
Previous article: CotomyElement Value and Form Behavior Next article: CotomyApi in Practice