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.
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.
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.
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
controller
createController
view
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.
| Property | Value | Use |
|---|---|---|
model | Store<State> | Read and update the component-local reactive model. |
props | Props | Read the latest props; Tavo.js refreshes them before each view call. |
router | Router helpers | Navigate, update the URL, prefetch, and inspect available routes. |
stores | Global store registry | Get, test, and list stores previously created with defineGlobalStore. |
services | Service registry | Get, optionally resolve, test, and list named or typed services. |
page | Current route state | Read pathname, status, params, data, errors, and layout-layer data. |
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 });
}
}| Context | Available members |
|---|---|
router | navigate(to, options?), pushUrl(to), replaceUrl(to), prefetch(pathname, { signal? }?), routes |
stores | get<T>(name), has(name), list() |
services | get<T>(identifier), tryGet<T>(identifier), has(identifier), list() |
page | pathname, 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.
| Method | Behavior |
|---|---|
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. |
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.
| Hook | When it runs | Return | SSR |
|---|---|---|---|
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.
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.
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.
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
Leaking manual subscriptions
Using URL updates as navigation
Ignoring action rejection
Doing DOM work during SSR
Duplicating model state
Look up exact public types
Follow linked API names to their canonical TypeScript declarations and package boundaries.