Page Controllers

Without a controller, the Svelte page owns everything: fetching, state, error handling, lifecycle. That works for simple pages, but as behavior grows — forms, retries, subscriptions, multi-step workflows — logic leaks into handlers and the page becomes untestable without mounting the UI.

A page controller is a plain class that owns that behavior instead. It receives the load() result, exposes a public input API, and holds observable data. The page becomes a rendering layer: it reads data, calls input.*, and listens to optional events.

This makes two things possible. The page is simple enough that a bug is almost always a rendering issue. And the controller is testable without Svelte — resolve it from a fresh IoC scope, swap collaborators with fakes, call input.*, and assert data.

Wiring a Controller to a Route

Add x-controller beside x-view. The controller receives load() data through initialize(), then owns what happens next.

src/ui/openapi/pages/user.yaml
# src/ui/openapi/pages/user.yaml
modules:
  user:
    basePath: /user
    paths:
      /:
        get:
          summary: User
          x-view: pages/user/main.svelte
          x-controller: controllers/user.controller.svelte.ts
          x-layout:
            - layout/app_layout.svelte
src/ui/pages/user/main.svelte
export async function load({ context }) {
  return { user: await getUser(context.user.id) };
}
src/ui/controllers/user.controller.svelte.ts
import type { PageController, RouteContext } from "@noego/forge/client";
import { Component, Inject, LoadAs } from "@noego/ioc";

export abstract class UserClient {
  abstract rename(id: string, name: string): Promise<void>;
}

@Component({ scope: LoadAs.Scoped })
export default class UserController implements PageController {
  data = $state({ name: "" });

  constructor(@Inject(UserClient) private api: UserClient) {}

  input = {
    rename: async (name: string) => {
      await this.api.rename(this.data.id, name);
      this.data.name = name;
    }
  };

  initialize(loadData, _route: RouteContext) {
    Object.assign(this.data, loadData);
  }
}

With behavior in the controller, the page is just props and markup. That makes UI testing straightforward — pass in data and input as props, assert what renders, done. No service mocks, no async setup, no container.

src/ui/pages/user/main.svelte
<script lang="ts">
  let { data, input } = $props();
</script>

<h1>{data.name}</h1>
<button onclick={() => input.rename("Ada")}>Rename</button>

Controller Contract

data
Reactive state. The page reads it; tests assert it.
input
Public behavior API. The page calls it; tests call it too.
events
Optional fire-and-forget streams for toasts, animations, and other momentary signals.
initialize
Receives the load function result and route context before the page renders.
destroy
Optional cleanup for subscriptions, timers, streams, or other resources.

Test the Behavior Boundary

Fresh container, fake collaborators, call input.*, assert data. No Svelte, no module mocks.

src/ui/controllers/user.controller.test.ts
import createContainer, { LoadAs } from "@noego/ioc";
import UserController, { UserClient } from "./user.controller.svelte";

class FakeUserClient extends UserClient {
  renamed = "";
  async rename(id: string, name: string) { this.renamed = name; }
}

it("renames through the controller", async () => {
  const container = createContainer();
  const fake = new FakeUserClient();
  container.registerFunction(UserClient, () => fake, { loadAs: LoadAs.Singleton });

  const ctrl = await container.instance(UserController);
  ctrl.initialize({ id: "1", name: "Ada" }, routeContext);
  await ctrl.input.rename("Grace");

  expect(ctrl.data.name).toBe("Grace");
  expect(fake.renamed).toBe("Grace");
});

When to Add One

Use raw load props

  • Read-only pages
  • Static marketing content
  • Simple detail pages where bugs are mostly rendering or copy

Use a controller

  • Forms and saves
  • Async pipelines or retries
  • State shared across several controls
  • Behavior that should be tested without rendering Svelte
NoEgo

© 2025 NoEgo. All rights reserved.