CotomyPageController in Practice

A practical guide to CotomyPageController with endpoint-bound screen design, CRUD flow, and clear separation from form handling.

In Cotomy, a single endpoint, especially in CRUD-heavy business systems, is treated as one operational screen boundary. This continues from Working with CotomyElement . For those screens, Cotomy expects the endpoint-level behavior to be coordinated by CotomyPageController.

Each class involved in CRUD operations (forms, API forms, entity-aware forms) requires explicit initialization. In Cotomy’s design, that initialization kick and lifecycle coordination are also responsibilities of the page controller.

By centralizing screen entry, initialization, and CRUD orchestration in the page controller, Cotomy standardizes how these screens behave. That consistency keeps large systems maintainable as they grow.

This guide shows how that screen-level orchestration model works with Cotomy forms in a practical CRUD example under the principle:

one screen = one endpoint boundary.

For reference details, see: CotomyPageController , CotomyApiForm , and CotomyEntityFillApiForm .

Before PageController: Choosing the Right Form Type

Before discussing why the page controller comes first, it helps to see how Cotomy form classes are typically used at the screen level.

Case A: Search Conditions (Query Screen)

Use CotomyQueryForm when the goal is URL navigation via query parameters.

import { CotomyElement, CotomyQueryForm } from "cotomy";
const form = CotomyElement.byId<CotomyQueryForm>(
    "user-search-form", class extends CotomyQueryForm {})!.initialize();

This form always uses GET, serializes input values into the query string, and navigates through location.href. It does not use an entity lifecycle.

This is appropriate for list filters and search condition panels.


Case B: Simple API Submit (Non-Entity Form)

Use CotomyApiForm when submitting data to an API without entity identity switching.

import { CotomyElement, CotomyApiForm } from "cotomy";
const form = CotomyElement.byId<CotomyApiForm>(
    "feedback-form", class extends CotomyApiForm {})!.initialize();

This form submits FormData through fetch, exposes apiFailed and submitFailed events, and does not auto-switch POST and PUT. It is not bound to entity identity or CRUD lifecycle.

This form type is useful for smaller operational units inside a screen:

for example, a search panel that should not trigger full GET navigation, a modal or side panel that selects options and posts results, or a feedback form that is not part of the main CRUD contract. The same applies to any POST operation that is operational but not part of the primary endpoint CRUD contract.

In other words, use CotomyApiForm when the submission is operational, but not identity-bound and not the primary endpoint contract of the screen.


Case C: Entity CRUD Form (Create + Edit in One Screen)

Use CotomyEntityFillApiForm for endpoint-bound CRUD screens.

import { CotomyElement, CotomyEntityFillApiForm } from "cotomy";
const form = CotomyElement.byId<CotomyEntityFillApiForm>(
    "user-edit-form", class extends CotomyEntityFillApiForm {})!.initialize();

It automatically switches POST to PUT based on entityKey, performs a GET on load when entityKey exists, and calls fillAsync() to project data into inputs. That keeps create and edit under one endpoint contract.

This is the typical choice for business CRUD screens.


All of these form types share one structural requirement: they must be initialized.

Whether you bind to an existing

already present in the DOM or generate a new one dynamically, each form instance needs lifecycle wiring (initialize()), API binding, and sometimes entity loading coordination.

When multiple forms, panels, and behaviors coexist on the same screen, something must coordinate them as a single operational unit.

That coordination role is what CotomyPageController provides.

It does not replace forms. It aggregates them under one screen boundary, ensuring initialization order, lifecycle restoration, and endpoint-level consistency.


Why Start with PageController

Most business UI does not run as isolated form widgets. It runs as screens with operational context:

you load initial data, show current state, accept edits, submit and reflect server results, and handle failures without losing user intent.

That lifecycle belongs to the screen, not to a single submit button.

The page controller is useful here because it gives one place to coordinate screen behavior without forcing a component tree mental model. You still use real DOM, but with a clear entry boundary.

Three important points:

  1. Override initializeAsync() only for screen-level orchestration.
  2. Do not load or expand entity data inside initialize(). Use fillAsync for data projection.
  3. Treat a screen as a URL-bound operational unit with a defined load and submit contract.

Practical rule:

initializeAsync() wires forms, registers screen-level behavior, and restores state. fillAsync() projects API response data into the DOM. actionUrl at the form level defines the endpoint contract clearly instead of scattering it across handlers.

Keep lifecycle wiring separate from data projection.

Core Concept: One Screen = One Endpoint

Think in URL-addressable screens first:

such as /users/edit/123, /orders/new, or /products/list.

Each URL is an independent UI boundary. Each boundary has its own lifecycle, data loading path, submit semantics, and error recovery strategy.

One-screen-one-endpoint keeps responsibilities structurally separated:

the URL and server endpoint define the boundary, the page controller defines lifecycle and orchestration, and form helpers define submit protocol.

Practical Example: Build a CRUD User Edit Screen

Target screen endpoint:

/users/edit/{id}

We will build this as a single endpoint-bound surface that can:

Load one user. Edit fields. Save changes. Delete user. Recover from failures.

HTML Structure

Start with explicit HTML owned by the page.

<main id="user-edit-screen">
  <h1>User Edit</h1>

  <div id="message"></div>

  <form id="user-edit-form"
        action="/api/users"
        data-cotomy-entity-key="123">
    <label>
      Name
      <input name="name" autocomplete="name">
    </label>

    <label>
      Email
      <input name="email" type="email" autocomplete="email">
    </label>

    <div class="actions">
      <button type="submit">Save</button>
      <button type="button" id="delete-button">Delete</button>
    </div>
  </form>
</main>

This markup is intentionally plain. The focus is a stable screen root (#user-edit-screen) with explicit inputs.

In Cotomy, a screen controller is typically defined as an anonymous class inside the page entry file. This keeps the endpoint boundary structurally isolated and prevents unnecessary cross-page coupling.

Create the PageController

Use initializeAsync() as the screen entry point.

import { CotomyPageController, CotomyEntityFillApiForm } from "cotomy";

CotomyPageController.set(class extends CotomyPageController {
  protected override async initializeAsync(): Promise<void> {
    await super.initializeAsync();

    // Bind and initialize the form
    this.setForm(
      CotomyEntityFillApiForm.byId<CotomyEntityFillApiForm>(
        "user-edit-form",
        CotomyEntityFillApiForm
      )!
    );

    // actionUrl is defined at the form level (HTML or attribute)
    // Do not hardcode endpoint URLs inside controller logic.
  }
});

Key points:

initializeAsync() wires the screen, setForm() registers the form under controller lifecycle, and CotomyEntityFillApiForm handles data loading through loadAsync() and fillAsync(). The controller should not manually fetch and push values into inputs.

Separate New vs Edit Using Endpoint Context

Mode (create vs edit) is determined by endpoint structure and form configuration, not by conditional branching inside the controller. The controller wires the screen, and the form plus endpoint contract express the mode.

Example HTML:

<form id="user-edit-form"
      action="/api/users"
      data-cotomy-entity-key="123">

Rules:

data-cotomy-identify defaults to true, so it usually does not need to be written explicitly. If data-cotomy-entity-key exists, CotomyEntityApiForm uses PUT, and if it does not exist, it uses POST. CotomyEntityFillApiForm schedules loadAsync() on window ready after initialize(), and when data-cotomy-entity-key is present it issues GET and calls fillAsync() to reflect the response into inputs.

fillAsync() is executed not only after GET, but also after successful POST and PUT operations. Therefore, your endpoint must return a consistent entity object for all three operations.

Expected contract: GET /api/users/{id} returns the entity object, POST /api/users returns the created entity object, and PUT /api/users/{id} returns the updated entity object.

All responses should have the same structure if you rely on automatic form reflection.

Note: When the server responds with 201 Created and entity identification is enabled (data-cotomy-identify !== “false”), Cotomy expects a Location header containing the new resource path. The entity key is extracted from that Location path relative to the form action. If Location is missing, the entity key is not updated automatically. If Location does not match the action prefix (or does not contain exactly one additional key segment), Cotomy throws an error during submit processing.

Important: The Location header requirement applies only when you use CotomyEntityApiForm (or CotomyEntityFillApiForm), which implements the POST → PUT transition automatically. If you are integrating with an existing API that cannot provide a Location header or does not follow this contract, inherit from CotomyApiForm instead and implement your own submit handling logic. Cotomy does not force a server contract — entity-aware behavior is opt-in through the Entity form classes.

If your API returns only a status flag or a different response shape, you must override the form behavior and handle the response explicitly.

This keeps create and edit under one endpoint contract without duplicating controllers.

Add Failure Handling at Screen Level

If you need special submit behavior, override submitAsync() and use try–catch explicitly.

In practice, this is often done by passing an anonymous class to setForm():

import { CotomyPageController, CotomyEntityFillApiForm, CotomyConflictException } from "cotomy";

CotomyPageController.set(class extends CotomyPageController {
  protected override async initializeAsync(): Promise<void> {
    await super.initializeAsync();

    this.setForm(
      CotomyEntityFillApiForm.byId<CotomyEntityFillApiForm>(
        "user-edit-form",
        class extends CotomyEntityFillApiForm {
          public override async submitAsync(): Promise<void> {
            try {
              await super.submitAsync();
              console.log("Saved successfully");
            } catch (error) {
              if (error instanceof CotomyConflictException) {
                console.warn("Duplicate identifier.");
              }
              throw error;
            }
          }
        }
      )!
    );
  }
});

Use this pattern only when the screen requires additional behavior beyond the standard form contract. Most CRUD screens should rely on the default entity form behavior and avoid duplicating submit mechanics.

Keep Submit Behavior as a Form Concern

The page controller orchestrates. The form helper standardizes submission.

This means your screen stays readable:

Page entry and lifecycle coordination belong to the controller. Mode (create vs edit) is determined by the form’s entity key and endpoint configuration — not by conditional branching in the controller. The controller defines when the screen starts. The form and endpoint define how it behaves.

Do not push full submit mechanics into page lifecycle code unless you need a custom operation path.

CRUD Contract Table for One Endpoint Surface

When teams say “CRUD screen,” they often mean different things. Write the contract explicitly for one endpoint-bound surface:

Read: load existing user by id on entry. Create: same screen in new mode with defaults. Update: submit edited fields with standardized form protocol. Delete: explicit action with redirect and failure handling.

You can encode this in controller structure:

import { CotomyApi, CotomyPageController } from "cotomy";

// Note:
// wireActions() and showMessage() are application-level methods.
// They are not part of CotomyPageController.
// Implement them in your own controller as needed.

CotomyPageController.set(class extends CotomyPageController {
  protected override async initializeAsync(): Promise<void> {
    await super.initializeAsync();
    this.wireActions();
  }

  // Application-level example: wire your own UI events here.
  private wireActions(): void {
    // e.g. bind delete button click -> this.deleteUser()
  }

  // Application-level example: render feedback to your screen.
  private showMessage(message: string): void {
    console.warn(message);
  }

  private get currentId(): string | undefined {
    const segments = this.url.path.split("/").filter(Boolean);
    return segments[segments.length - 1];
  }

  private async deleteUser(): Promise<void> {
    const id = this.currentId;
    if (!id) return;

    try {
      const api = new CotomyApi();
      await api.deleteAsync(`/api/users/${id}`);
      location.href = "/users/list";
    } catch {
      this.showMessage("Delete failed. Please try again.");
    }
  }
});

Now each CRUD operation has a visible home, which improves long-term maintainability:

onboarding is faster because behavior is discoverable, endpoint-level logs map to endpoint-level code, and tests can assert operation outcomes without bootstrapping a large app shell.

Role Separation from Forms

Keep this boundary explicit:

the page controller handles screen-level orchestration, CotomyApiForm provides the standardized submit path, and CotomyEntityFillApiForm adds automatic entity reflection on top.

Practical workflow:

  1. Decide screen endpoint boundary
  2. Implement controller behavior for load/mode/failure/navigation
  3. Attach form protocol for submit consistency
  4. Add entity-fill extension only when reflection requirements justify it

If you invert this and start from form classes, page logic drifts into submit hooks, and CRUD screens become hard to reason about.

Why This Matters in Real Systems

Business systems are operated by screens, not by abstract component fragments. A user opens a URL, performs work, corrects errors, and continues. That unit is the screen boundary.

One-screen-one-endpoint gives concrete benefits:

it gives responsibility closure per endpoint, easier testing for load/submit/failure behavior by screen, a stable refactoring surface for long-lived systems, compatibility with both SPA-like navigation and classic MPA transitions, and a better fit for AI-assisted generation because prompts can target one endpoint contract at a time.

On testing specifically:

integration tests can target /users/edit/{id} as one contract, failure tests can assert page-level message and fallback behavior, and submit tests can focus on form protocol separately.

This is cleaner than mixing all behavior into one large handler graph.

Next

Next: Standardizing CotomyPageController for Shared Screen Flows


If you adopt only one rule from this guide, use this:

Define the screen endpoint boundary first. Then place page behavior in CotomyPageController. Then add form protocol helpers on top.

That sequence keeps Cotomy usage practical, testable, and stable.

Practical Guide

This article is part of the Cotomy Practical Guide, which focuses on hands-on usage patterns for the framework.

Series articles: Working with CotomyElement , CotomyPageController in Practice, and Standardizing CotomyPageController for Shared Screen Flows .

Links

Previous: Working with CotomyElement . More posts: /posts/ .

Learn Cotomy

Cotomy is a DOM-first UI runtime for long-lived business applications.