Components and JSX
Build functional Tavo.js components, understand the JSX contract, and choose when local behavior belongs in createTavo.
Start with a function component
A Tavo.js component is a function that receives props and returns a renderable Child. Use a plain function when output depends only on props, children, or application services that are already reactive.
The automatic JSX runtime compiles TSX for you. Import runtime values such as Fragment only when you reference them explicitly.
Strings, numbers, elements, nested child arrays, null, undefined, and booleans are valid children. Nullish and boolean children do not produce HTML.
Fragmentgroups siblings without adding a DOM element.Explicit children passed between component tags are available through
props.children.A
classNamemay be one string or an array of strings; arrays are joined with spaces.A style value may be a CSS string or an object. Object keys written in camel case become kebab-case CSS properties during SSR.
tsximport type { PropsWithChildren } from "@tavojs/core";
type StatusBadgeProps = PropsWithChildren<{
tone: "neutral" | "success" | "danger";
}>;
export function StatusBadge({
tone,
children,
}: StatusBadgeProps) {
return (
<span className={["statusBadge", `statusBadge--${tone}`]}>
{children}
</span>
);
}Choose the smallest state owner
Rendering and state ownership are separate decisions. Keep a component functional until it needs local reactive state, behavior, lifecycle, or cleanup.
Use a plain function component for render-only output.
Use
createTavowith a model for state owned by one mounted component.Add a
TavoControllerfor behavior, routing, services, refs, async actions, or managed side effects.Use a global
Storefor browser state intentionally shared by multiple routes or component owners.Use a route loader for request-specific and route-critical data; do not place request identity in global state.
tsximport { createTavo, TavoController } from "@tavojs/core";
type NameFieldState = {
name: string;
};
class NameFieldController extends TavoController {
updateName(event: Event) {
const input = event.currentTarget as HTMLInputElement;
this.model.patch({ name: input.value });
}
}
export const NameField = createTavo<
Record<string, never>,
NameFieldState,
NameFieldController
>({
model() {
return { name: "" };
},
controller: NameFieldController,
view({ state, controller }) {
return (
<label>
Project name
<input
name="name"
value={state.name}
onChange={(event: Event) => {
controller?.updateName(event);
}}
/>
</label>
);
},
});Understand intrinsic element behavior
Text and attribute values are escaped. Unsafe attribute names and javascript-style URL protocols are rejected.
Event props begin with on and are attached by the browser runtime; they are not serialized into server HTML.
ref, use, transition, and key are runtime instructions rather than HTML attributes.
Boolean true emits a boolean attribute; false, null, and undefined omit the attribute.
HTML void elements such as input, img, and br render without closing tags.
Unknown safe intrinsic attributes pass through, which keeps data-* and aria-* attributes available.
tsxexport function ProfileLink({
active,
userId,
}: {
active: boolean;
userId: string;
}) {
return (
<a
href={`/users/${encodeURIComponent(userId)}`}
className={["profileLink", active ? "profileLink--active" : ""]}
aria-current={active ? "page" : undefined}
data-user-id={userId}
>
View profile
</a>
);
}Mount outside the Pages runtime
Most applications let Auto Pages create and hydrate the root. Use createRoot when Tavo.js is embedded in an existing page, widget host, test shell, or other manually owned DOM container.
root.render(node) owns repeat renders;root.unmount() removes the tree and releases refs, directives, listeners, and controller cleanup.Use
root.hydrate(node) only when the container already holds matching server-renderedTavo.jsmarkup.render(node, container) is the convenience form for a one-off browser mount when you do not need the
Roothandle.renderToString(node) returns escaped static HTML. Use the server rendering APIs instead when you need a complete document, route resolution, status, headers, head output, streaming, or hydration state.The automatic JSX transform normally creates
VNodevalues.his the lower-level explicit constructor for tooling or non-JSX integrations.
tsximport { createRoot } from "@tavojs/core";
import { SupportWidget } from "./SupportWidget";
const container = document.querySelector("#support-widget");
if (!(container instanceof HTMLElement)) {
throw new Error("Missing #support-widget container.");
}
const root = createRoot(container);
root.render(<SupportWidget />);
// Call this when the host removes the widget.
export function unmountSupportWidget() {
root.unmount();
}tsximport { renderToString } from "@tavojs/core";
import { ReceiptCard } from "./ReceiptCard";
export function renderReceiptCard(total: string) {
return renderToString(<ReceiptCard total={total} />);
}Treat value and checked as controlled
When value or checked comes from model state, update that same state from the corresponding event. The browser runtime restores the rendered value after an input event, so a static controlled value intentionally remains pinned.
Use
onChangefor live text-entry updates;Tavo.jsmaps the browser input event into the controlled-field flow.Read
event.currentTargetorevent.targetas the correct input element before updating the model.Give every field an accessible label and expose validation with normal HTML constraints and
aria-describedbywhere needed.Use
focusFirstInvalidafter a failed client validation pass when moving focus is helpful and expected.
Verify both render environments
Render the component to HTML and confirm text, escaping, boolean attributes, class names, styles, and void elements.
Mount it in a browser test and confirm event updates, controlled values, focus, and cleanup.
Hydrate server markup and check that the first browser output matches without a hydration diagnostic.
Typecheck public props and event targets; do not rely on casts hidden inside application callers.
Look up exact public types
Follow linked API names to their canonical TypeScript declarations and package boundaries.