Navigated to /docs/core/mvc

MVC components

Separate local reactive state, business behavior, and TSX rendering with createTavo.

Give each concern one home

A Tavo.js component has an optional model, an optional controller, and a required view. The model stores renderable local state, the controller coordinates behavior and side effects, and the view describes output from props and state.

  • Render-only components can provide only a view.

  • Small interactions can patch the model directly.

  • Use a controller when behavior needs lifecycle, services, stores, routing, refs, or managed cleanup.

Build a controller-backed component

Controllers receive the component model and current props automatically. They also have access to page, router, stores, and services supplied by the runtime.

TSX
tsximport { createTavo, TavoController } from "@tavojs/core";
import { Button } from "@tavojs/ui";

class CounterController extends TavoController {
  increment() {
    this.model.patch((state) => ({ count: state.count + 1 }));
  }
}

export const Counter = createTavo({
  model: () => ({ count: 0 }),
  controller: CounterController,
  view: ({ state, controller }) => (
    <Button onClick={() => controller?.increment()}>Count: {state.count}</Button>
  )
});

Own lifecycle work

Use onInit for subscriptions and initial model work, onMount or onLayout for DOM-dependent behavior, afterRender for post-commit work, and onPropsChange for explicit prop reactions.

TS
tsimport { TavoController } from "@tavojs/core";

export class ClockController extends TavoController {
  onMount() {
    const timer = window.setInterval(() => {
      this.model.patch({ now: new Date() });
    }, 1000);

    return () => window.clearInterval(timer);
  }
}

Understand the complete createTavo contract

See every part of an MVC component definition and when Tavo.js creates it.

createTavo returns a typed component. Only view is required. A model owns reactive state, while a controller owns behavior and managed side effects. Each mounted component keeps one model and one controller instance across rerenders.

TS
tscreateTavo<Props, State, Controller>({
  model?: (props: Props) => State | Store<State>,
  controller?: new (ctx: MvcControllerContext<Props, State>) => Controller,
  createController?: (ctx: MvcControllerContext<Props, State>) => Controller,
  view: (ctx: {
    props: Props;
    state: State;
    model: Store<State>;
    controller: Controller | null;
  }) => Child
}): Component<Props>

model

Runs once for a mounted component, using its initial props. Return a plain object for Tavo.js to wrap with createStore, or return an existing Store. When omitted, Tavo.js creates an empty Store.

controller

A class constructor that receives the complete controller context. Use it for reusable behavior, lifecycle methods, and managed work.

createController

A factory alternative for functional or object-based controllers. If both controller fields are supplied, the class controller is used.

view

Receives current props, the state snapshot, the model Store, and the controller instance. Without a controller, controller is null.
TSX
tsximport { createTavo } from "@tavojs/core";
import { Button } from "@tavojs/ui";

export const Counter = createTavo({
  model: ({ initial = 0 }: { initial?: number }) => ({ count: initial }),
  view: ({ state, model }) => (
    <Button onClick={() => model.set("count", (count) => count + 1)}>
      Count: {state.count}
    </Button>
  )
});

Use the context injected into every controller

Access local state, current props, routing, shared stores, services, and route data.

TavoController receives six developer-facing context groups. They are also available on the ctx constructor argument and on controllers returned by createController.

PropertyValueUse
modelStore<State>

Read and update the component-local reactive model.

propsProps

Read the latest props; Tavo.js refreshes them before each view call.

routerRouter helpers

Navigate, update the URL, prefetch, and inspect available routes.

storesGlobal store registry

Get, test, and list stores previously created with defineGlobalStore.

servicesService registry

Get, optionally resolve, test, and list named or typed services.

pageCurrent route state

Read pathname, status, params, data, errors, and layout-layer data.

TS
tsclass ProjectController extends TavoController {
  openSettings() {
    this.router.navigate(`/projects/${this.page.params.id}/settings`);
  }

  rememberTab(tab: string) {
    // Changes browser history without running route navigation or remounting.
    this.router.pushUrl(`?tab=${encodeURIComponent(tab)}`);
  }

  prefetchReports(signal?: AbortSignal) {
    return this.router.prefetch("/reports", { signal });
  }
}
ContextAvailable members
routernavigate(to, options?), pushUrl(to), replaceUrl(to), prefetch(pathname, { signal? }?), routes
storesget<T>(name), has(name), list()
servicesget<T>(identifier), tryGet<T>(identifier), has(identifier), list()
pagepathname, route, status, data, params, error, layers, layerData

Let the controller manage side effects

Every helper below participates in component cleanup automatically.

Controller helpers return unsubscribe functions. You can call one early to cancel the work; otherwise Tavo.js disposes it when the component is destroyed.

MethodBehavior
cleanup(fn)

Register any cleanup and receive an idempotent wrapped unsubscribe.

createId(prefix?)

Create an instance-scoped sequential ID. The default prefix is id.

setTimeout(fn, delay?)

Schedule a timeout that is removed after firing and cancelled on destroy.

setInterval(fn, delay?)

Schedule an interval that is cancelled on destroy.

scheduleLayoutEffect(fn)

Queue managed microtask work that may return cleanup.

scheduleAfterRender(fn)

Queue one-shot microtask work and unregister after it runs.

scheduleOnMount(fn)

Queue managed mount work that may return cleanup.

listen(store, listener, options?)

Subscribe to a complete Tavo.js Store snapshot.

select(store, selector, listener, options?)

Subscribe to a selected value with optional equality.

watch(store, target, listener, options?)

Watch a key, nested path, or selector.

listenExternal(store, listener, options?)

Subscribe to an ExternalStore snapshot with optional equality.

observeResize(target, listener, options?)

Create a managed ResizeObserver.

observeIntersection(target, listener, options?)

Create a managed IntersectionObserver.

observeMutation(target, listener, options?)

Create a managed MutationObserver for a Node or ref object.

action(fn)

Wrap sync or async work with reactive pending, result, and error state.

TSX
tsxclass PanelController extends TavoController {
  declare panel: { current: HTMLElement | null };

  onMount() {
    this.setInterval(() => this.refresh(), 30_000);
    this.observeResize(this.panel, () => this.measure());
    return this.listen(filters, (state) => this.applyFilters(state));
  }

  refresh() {}
  measure() {}
  applyFilters(_state: FilterState) {}
}

Choose the lifecycle hook by timing

Distinguish layout work, passive work, prop reactions, and teardown.

HookWhen it runsReturnSSR
onInit()

Once in the first passive mount task, immediately before onMount.

No

No

onMount()

Once in the first passive mount task.

May return cleanup

No

onLayout()

After every client commit, before passive hooks.

May return cleanup

No

afterRender()

As a passive task after every client commit.

No

No

onPropsChange(props)

Initial client render and later shallowly changed props.

No

No

onDestroy()

Once during teardown, before managed controller cleanups flush.

No

No

onLayout cleanup follows layout rerenders and unmount. An onMount cleanup is registered with the controller automatically. Controller props are updated synchronously before view renders; onPropsChange is the later passive notification and uses top-level shallow equality.

TS
tsclass DialogController extends TavoController {
  onInit() {
    this.model.patch({ phase: "ready" });
  }

  onLayout() {
    const restore = captureFocusRestore();
    return () => restore();
  }

  onMount() {
    return this.listen(preferences, ({ reducedMotion }) => {
      this.model.patch({ reducedMotion });
    }, { immediate: true });
  }

  afterRender() {
    // Observe the committed client view.
  }

  onPropsChange(props: DialogProps) {
    if (!props.open) this.model.patch({ phase: "closed" });
  }

  onDestroy() {
    // Final controller-owned work. Managed helpers are cleaned next.
  }
}

Represent async work with controller actions

Expose pending, result, and error state without adding request flags to the model.

TSX
tsxclass ProfileController extends TavoController {
  save = this.action(async (name: string) => {
    const response = await fetch("/api/profile", {
      method: "POST",
      body: JSON.stringify({ name })
    });
    if (!response.ok) throw new Error("Could not save profile");
    return response.json() as Promise<{ name: string }>;
  });
}

export const Profile = createTavo({
  controller: ProfileController,
  view: ({ controller }) => <Stack>
    <Button
      loading={controller?.save.pending}
      onClick={() => controller?.save.run("Ada").catch(() => {})}
    >
      Save
    </Button>
    {controller?.save.error ? <Text color="danger">Save failed</Text> : null}
    {controller?.save.result ? <Text>Saved {controller.save.result.name}</Text> : null}
  </Stack>
});
  • An action starts with pending false, error null, and result null.

  • run sets pending true and clears error. It resolves with the function result or rethrows the caught error.

  • Only the most recently started run may update action state, so an older response cannot overwrite a newer one.

  • Starting another run keeps the previous result visible while pending.

  • reset clears pending, error, and result and prevents in-flight completions from changing action state.

  • Every action-state transition rerenders the owning MVC component.

Use constructor context before attachment

Avoid reading injected instance properties too early.

Tavo.js constructs a class with the complete ctx argument, then attaches model, props, framework context, and managed methods to the instance. Use ctx for constructor-time IDs or reads. Use this.model, this.props, and the other instance helpers from lifecycle hooks and normal methods.

TSX
tsximport { Box, TextInput } from "@tavojs/ui";

class FieldController extends TavoController {
  id: string;

  constructor(ctx: { createId(prefix?: string): string }) {
    super();
    this.id = ctx.createId("field");
  }
}

export const Field = createTavo<Record<string, never>, Record<string, never>, FieldController>({
  controller: FieldController,
  view: ({ controller }) => (
    <Box as="label" for={controller?.id}>
      Email
      <TextInput id={controller?.id} type="email" />
    </Box>
  )
});

Avoid common MVC mistakes

Recreating state from new props

model runs once per component instance. React to later props through current controller.props or onPropsChange.

Leaking manual subscriptions

Prefer listen, select, watch, listenExternal, and observer helpers. Otherwise pass the unsubscribe to cleanup.

Using URL updates as navigation

pushUrl and replaceUrl only change browser history. Use navigate when the application should resolve and render another route.

Ignoring action rejection

Action error state is reactive, but run still rejects. Await it in a try/catch or attach a catch handler from an event callback.

Doing DOM work during SSR

Put element measurement, focus, observers, and browser timers in onLayout or onMount rather than constructors or model factories.

Duplicating model state

Read renderable state from view.state and mutate through view.model or controller.model instead of copying snapshots into controller fields.

Look up exact public types

Follow linked API names to their canonical TypeScript declarations and package boundaries.