Browse docsComponents and state

MicroTS components

Use components to pass data, handle actions, and keep state for each mounted instance. Start with MicroTS for the app files, Rust entry point, and build commands. The examples below use the vue-sfc-lab app.

Choose who owns the state

State Declare it in Change it through
App data shared by rows The root's basename module, such as app.ts Root model methods and child events
State belonging to one mounted child A factory in the child's basename module The child's model methods
A value controlled by the parent defineProps or defineModel in the child An event or a model assignment
Read access across several component levels Root provide and child inject The root model's methods

TypeScript defines the component contract in both model modes. With the default app.model: "rust", you implement the generated trait in Rust. With app.model: "compiled" and app.aot: true, Model AOT generates the implementation from .ts bodies. See the execution-mode comparison for source admission and browser, QuickJS and native behavior.

The examples below show the Vue lab's handwritten Rust model. Component props, events, slots and context keep the same view contract with compiled models. See TypeScript and native boundaries for model ownership and TypeScript support for the view and model language rules.

Pass props and emit events

FeatureToggle.vue declares its input data with defineProps and its output with defineEmits. Event declarations use named tuples; toggle: [] would declare an event with no payload.

<!-- FeatureToggle.vue -->
<script setup lang="ts">
import { Text, View } from "@pocketjs/framework/vue-vapor/components";
import type { i32 } from "@pocketjs/framework/vue-vapor/std";
import { createFeatureToggle } from "./FeatureToggle";

const { presses, press } = createFeatureToggle();
const props = defineProps<{ label: string; enabled: boolean }>();
const emit = defineEmits<{ toggle: [presses: i32] }>();
</script>

<template>
  <View focusable @press="emit('toggle', press())">
    <Text>{{ props.label }}: {{ props.enabled ? 'ON' : 'OFF' }}</Text>
    <Text>Pressed {{ presses }} times</Text>
  </View>
</template>

Props are read-only inputs. String, array, and object props borrow the parent's Rust data during rendering. An emitted payload owns its data, so the parent listener can mutate the model while handling the event.

In the parent template, pass values with :prop and handle an event with @event. $event refers to the payload for a single-argument event. A listener can read parent state, as the keyed-list example below does.

Give each child its own state

Put the factory in FeatureToggle.ts, matching the component's basename. This factory takes no arguments and returns the bindings destructured by the SFC:

// FeatureToggle.ts
import { ref } from "vue";
import type { i32 } from "@pocketjs/framework/vue-vapor/std";

export function createFeatureToggle() {
  const presses = ref<i32>(0);
  function press(): i32 {
    presses.value += 1;
    return presses.value;
  }
  return { presses, press };
}

In the default Rust mode, generate the app and implement FeatureToggleViewModel in its Rust source:

use crate::generated::FeatureToggleViewModel;

#[derive(Default)]
pub struct ToggleState {
    presses: i32,
}

impl FeatureToggleViewModel for ToggleState {
    fn presses(&self) -> i32 {
        self.presses
    }

    fn press(&mut self) -> i32 {
        self.presses = self.presses.wrapping_add(1);
        self.presses
    }
}

Add type FeatureToggle = ToggleState; inside the parent's existing impl AppViewModel for LabViewModel. The generated trait names the associated type after the child component. See the complete Rust implementation.

Each child mount calls ToggleState::default() and owns that state until unmount. Two toggles have two press counts. A keyed row keeps its state when its position changes. A v-if branch that disappears drops its state; its next mount starts from Default. Put the child in a View with v-show to retain the mounted subtree while hiding it.

Compiled mode generates the child model and its constructor from the factory. The presses seed and press body above become Rust code. Each mounted child owns a region containing its state, cached memos and tasks. Unmount cancels that region's tasks and drops its state; a remount runs a new constructor. No handwritten ToggleState implementation is required.

A factory can also take typed mount-time arguments, for example createRow(props.seed) with seed: i32. The child snapshots these arguments when it mounts. A later prop change does not reseed the same child; use the prop binding for values that must track the parent. In Rust mode, a parameterized child model implements microts::New<(i32,)>; compiled mode generates that implementation from the TypeScript factory.

Use either a factory or direct view-model imports in one component. Put ref, computed, and function bodies in the basename module; the SFC script contains imports, type declarations, component macros, and the admitted factory/context statements.

Bind a parent value with v-model

Use defineModel when an interaction edits a value owned by the parent. This button increments an i32 and supplies a label when the parent omits it:

<!-- ModelButton.vue -->
<script setup lang="ts">
import { Text, View } from "@pocketjs/framework/vue-vapor/components";
import type { i32 } from "@pocketjs/framework/vue-vapor/std";

const props = withDefaults(defineProps<{ label?: string }>(), {
  label: "VALUE +1",
});
const model = defineModel<i32>({ required: true });
</script>

<template>
  <View focusable @press="model++">
    <Text>{{ props.label }}: {{ model }}</Text>
  </View>
</template>

Import ModelButton and the root's count binding in app.vue, then use:

<ModelButton v-model="count" />

The generated root trait requires count(&self) -> i32 and set_count(&mut self, value: i32). Rust mode requires both implementations; compiled mode generates them from the root TypeScript state. The child receives a modelValue prop; assigning it emits UpdateModelValue(i32), and the parent listener calls the setter.

For a named model, declare defineModel<i32>("count", { required: true }) and bind it with v-model:count="count".

Prop defaults must be string, number, boolean, or enum literals. Arrays, objects, and factory defaults are unsupported. A defaulted prop is present inside the child; an optional prop without a default remains an Option in Rust. Use a v-if="props.value !== undefined" guard before using its inner value.

Render a list without losing row state

Import FeatureToggle in the parent and iterate over the root's features binding:

<FeatureToggle
  v-for="feature in features"
  :key="feature.id"
  :label="feature.label"
  :enabled="feature.enabled"
  @toggle="toggleFeature(feature.id)"
/>

The root's features getter returns &[Feature]; toggleFeature changes the matching item. Every v-for requires a unique key of type i32, i64, string, or a string-literal enum. Use an item's stable identifier so moves preserve its child model. Removing a key unmounts its row; adding a new key creates a row with fresh state. A loop index is available through v-for="(feature, index) in features" and has type i32.

Let the parent render list items

Use a scoped slot when a component owns the layout but callers supply the item content. This list accepts any item type with a string id:

<!-- FeatureList.vue -->
<script setup lang="ts" generic="T extends { id: string }">
import { View } from "@pocketjs/framework/vue-vapor/components";

const props = defineProps<{ items: T[] }>();
defineSlots<{ row(props: { item: T }): any }>();
</script>

<template>
  <View class="flex-row gap-2">
    <template v-for="item in props.items" :key="item.id">
      <slot name="row" :item="item" />
    </template>
  </View>
</template>

The parent imports FeatureList and FeatureToggle, then supplies the row slot. Destructuring can rename a parameter:

<FeatureList :items="features">
  <template #row="{ item: feature }">
    <FeatureToggle
      :label="feature.label"
      :enabled="feature.enabled"
      @toggle="toggleFeature(feature.id)"
    />
  </template>
</FeatureList>

Slot expressions use the parent's scope plus the declared slot parameters. Here feature comes from the outlet and toggleFeature comes from the parent. Use <slot /> for default content and <slot name="footer" /> with <template #footer> for named content that needs no parameters. See FeatureCard.vue.

The compiler infers T from items, checks its id field, and generates a Rust component for each distinct type argument list. Type parameter defaults fill arguments that supplied props do not determine. The root component cannot declare generic parameters because it has no parent supplying props.

A generic template cannot display or compare the same value as Color in one use and another type in another use: browser template conversion is shared across these uses. Split that rendering into components with a concrete type for each operation.

Read shared data across component levels

Export the context type and value from the root's basename module:

// Add to app.ts.
export interface LabTheme { enabledLabel: string }
export const theme = ref<LabTheme>({ enabledLabel: "ON" });

This addition uses the ref import from vue in the root module. Add the provider in the root app.vue script:

import { provide } from "vue";
import { theme } from "./app";

provide("theme", theme);

In a child's SFC script, inject the same type and key:

import { inject } from "vue";
import type { LabTheme } from "./app";

const theme = inject<LabTheme>("theme")!;

The child can render <Text>{{ theme.enabledLabel }}</Text>. The native root model stores a generated LabTheme and exposes it through fn theme(&self) -> &LabTheme. Rust mode requires you to implement the getter; compiled mode generates it from theme in app.ts.

Providers belong to the root; every injection requires a matching literal key and type. The compiler passes borrowed data through intermediate components, with no runtime key lookup. The ! records the required value for Vue's editor types; the AOT build checks the provider. Injection defaults, symbol keys, and child providers are unsupported. Change shared data through root methods; injection adds no child setter.

In a compiled model, another model's mutable state must cross the component contract through props, events or context. Importing a sibling model's state into the .ts body is rejected. Pure helper modules may export functions and constants; their functions receive inputs as parameters and cannot read model state or access the host.

See TypeScript support for expression, type and module rules, and the MicroTS reference for Vue template syntax, input handlers and diagnostics.