CxJS

Controllers

import { Controller } from 'cx/ui'; Copied

Controllers contain the business logic for your views. They handle data initialization, event callbacks, computed values, and reactions to data changes.

Creating a Controller

Extend the Controller class and attach it to a widget using the controller property. The controller has access to the store and can define methods that widgets call:

<div controller={PageController} class="flex flex-col gap-4">
  <div class="flex gap-2">
    <TextField value={m.name} />
    <Button
      onClick={(e, instance) => {
        instance.getControllerByType(PageController).greet()
      }}
    >
      Greet
    </Button>
    <Button
      onClick={(e, instance) => {
        instance.getControllerByType(PageController).clear()
      }}
    >
      Clear
    </Button>
  </div>
  <div text={m.greeting} class="text-primary" />
  <div class="p-3 bg-muted rounded text-sm">
    <strong>Store content</strong>
    <pre class="mt-2" text={(data) => JSON.stringify(data, null, 2)} />
  </div>
</div>
Store content
{
  "name": "World"
}

The controller’s methods are available to all widgets within its scope. In event handlers, access the controller through the second parameter.

Inline Controllers

For simple cases, define a controller inline using an object:

<div
  controller={{
    onInit() {
      this.store.init(m.count, 0)
      this.addComputable(m.double, [m.count], (c) => c * 2)
    },
  }}
  class="flex items-center gap-4"
>
  <NumberField value={m.count} style="width: 100px" />
  <span text={tpl(m.double, "Double: {0}")} />
</div>
Double: 0

The inline form supports lifecycle methods and controller features like addTrigger and addComputable.

Lifecycle Methods

Controllers have lifecycle methods that run at specific times:

MethodDescription
onInit()Runs once when the controller is created. Use for data initialization and setup.
onExplore()Runs on every render cycle during the explore phase.
onDestroy()Runs when the controller is destroyed. Use for cleanup (timers, subscriptions).
class PageController extends Controller {
  timer: number;

  onInit() {
    // Initialize data
    this.store.init(m.count, 0);

    // Start a timer
    this.timer = window.setInterval(() => {
      this.store.update(m.count, (c) => c + 1);
    }, 1000);
  }

  onDestroy() {
    // Clean up
    window.clearInterval(this.timer);
  }
}

Typed Controller Access

Use getControllerByType to get a typed reference to a controller. This provides full autocomplete and compile-time type checking:

<div controller={CounterController} class="flex flex-col gap-4">
  <div class="flex gap-2 items-center">
    <Button
      onClick={(e, ins) => {
        ins.getControllerByType(CounterController).decrement()
      }}
    >
      -1
    </Button>
    <span class="w-12 text-center text-xl" text={m.count} />
    <Button
      onClick={(e, ins) => {
        ins.getControllerByType(CounterController).increment()
      }}
    >
      +1
    </Button>
    <Button
      onClick={(e, ins) => {
        ins.getControllerByType(CounterController).increment(10)
      }}
    >
      +10
    </Button>
    <Button
      onClick={(e, ins) => {
        ins.getControllerByType(CounterController).reset()
      }}
    >
      Reset
    </Button>
  </div>
</div>
0

The getControllerByType method searches up the widget tree and returns a typed controller instance.

Triggers

Triggers watch store paths and run callbacks when values change. Use addTrigger in onInit:

class PageController extends Controller {
  onInit() {
    this.addTrigger(
      "selection-changed",
      [m.selectedId],
      (selectedId) => {
        if (selectedId) {
          this.loadDetails(selectedId);
        }
      },
      true,
    ); // true = run immediately
  }

  async loadDetails(id: string) {
    let data = await fetch(`/api/items/${id}`).then((r) => r.json());
    this.store.set(m.details, data);
  }
}

The trigger name allows you to remove it later with removeTrigger("selection-changed").

Computables

Add computed values that automatically update when dependencies change:

class PageController extends Controller {
  onInit() {
    this.addComputable(m.fullName, [m.firstName, m.lastName], (first, last) => {
      return `${first || ""} ${last || ""}`.trim();
    });

    this.addComputable(m.total, [m.items], (items) => {
      return items?.reduce((sum, item) => sum + item.price, 0) || 0;
    });
  }
}

The first argument is the store path where the result is written. The computed value updates whenever any dependency changes.

Accessing Parent Controllers

Use getParentControllerByType to get a typed reference to a parent controller:

class ChildController extends Controller {
  onSave() {
    let parent = this.getParentControllerByType(PageController);
    parent.saveChild(this.getData());
  }
}

This provides full type safety and autocomplete. For dynamic method invocation by name, use invokeParentMethod:

this.invokeParentMethod("onSave", this.getData());