Navigated to /docs/core/components-and-jsx

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.

  • Fragment groups siblings without adding a DOM element.

  • Explicit children passed between component tags are available through props.children.

  • A className may 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.

TSX
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 createTavo with a model for state owned by one mounted component.

  • Add a TavoController for behavior, routing, services, refs, async actions, or managed side effects.

  • Use a global Store for 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.

TSX
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.

TSX
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-rendered Tavo.js markup.

  • render(node, container) is the convenience form for a one-off browser mount when you do not need the Root handle.

  • 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 VNode values. h is the lower-level explicit constructor for tooling or non-JSX integrations.

TSX
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();
}
TSX
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 onChange for live text-entry updates; Tavo.js maps the browser input event into the controlled-field flow.

  • Read event.currentTarget or event.target as the correct input element before updating the model.

  • Give every field an accessible label and expose validation with normal HTML constraints and aria-describedby where needed.

  • Use focusFirstInvalid after 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.