Skip to content

Define And Components

Chris Michael edited this page Jul 25, 2026 · 8 revisions

Effuse

Define And Components

define({ name?, props?, layers?, script, template })

Status: Current.

define creates an Effuse component blueprint. script runs when the component state is created. Its returned object becomes the template context together with reactive props and children.

Basic Shape

const Greeting = define({
  name: 'Greeting',
  props: defineProps<{ name: string }>(),
  script({ props, computed }) {
    const label = computed(() => `Hello, ${props.name}`);
    return { label };
  },
  template: ({ label }) => <h1>{label}</h1>,
});

Props are reactive and are updated in place during reconciliation. Do not copy a prop into an ordinary variable when later prop changes should remain visible.

Schema-First Props

Use defineProps(schema) when props need defaults, runtime validation, nested structures, or input transforms. The schema produces two synchronized types:

  • Caller input: required fields must be supplied; optional and defaulted fields may be omitted; transformed fields use their encoded input type.
  • Resolved props: script and template receive decoded values with defaults applied.
const userProps = PropSchema.struct({
  count: PropSchema.required(PropSchema.NumberFromString),
  name: PropSchema.required(PropSchema.String),
  role: PropSchema.optional(PropSchema.String, 'member'),
});

const UserLabel = define({
  props: defineProps(userProps),
  script: ({ props }) => ({
    // count is number and role is string after schema resolution.
    label: `${props.name}:${props.role}:${props.count}`,
  }),
  template: ({ label }) => <span>{label}</span>,
});

UserLabel({ name: 'Ada', count: '3' });

Prop schemas are an Effuse API. Applications do not import Effect or depend on its schema vocabulary. Use Effuse value builders such as String, Number, Boolean, NumberFromString, BooleanFromString, DateFromString, literal, union, array, and object; the validation engine remains internal.

Nested builders retain their own presence rules. An optional nested object may be omitted, while required fields inside it remain required when the object is provided. Invalid initial props and updates throw PropsValidationError; a failed update leaves the previous reactive props unchanged.

Compatibility

The generic declaration remains supported:

props: defineProps<{ name: string }>()

Existing components may continue pairing that form with propsSchema. New components should pass the schema directly to defineProps to avoid duplicate type declarations. Supplying a different schema through both defineProps(schema) and propsSchema throws PropsSchemaConflictError.

Template Context Ownership

Every template has explicit namespaces for its two value owners:

  • ctx.props contains the component's reactive, resolved props.
  • ctx.exposed contains values returned from script or registered with expose.
  • ctx.children contains rendered child content.

Values with unique names also remain available directly on ctx for concise templates and source compatibility:

const Greeting = define({
  props: defineProps<{ name: string }>(),
  script: ({ props, computed }) => ({
    label: computed(() => `Hello, ${props.name}`),
  }),
  template: (ctx) => (
    <h1 data-name={ctx.props.name}>{ctx.label}</h1>
  ),
});

Flat access never chooses between owners. If a prop and exposed value use the same key, that key is removed from the flat context and both values remain available through their namespace:

const Status = define({
  props: defineProps<{ status: string }>(),
  script: () => ({ status: 'locally-computed' }),
  template: (ctx) => (
    <output>{ctx.props.status}:{ctx.exposed.status}</output>
  ),
});

props, exposed, and children are reserved top-level context keys. A prop or exposed value with one of those names remains reachable through its owner, such as ctx.props.props or ctx.exposed.children; it is not flattened. Ordinary component children continue to use ctx.children.

In development, Effuse warns once per component instance and key when a value cannot be flattened. Migrate the ambiguous read to ctx.props.key or ctx.exposed.key. Production behavior uses the same collision-safe context but does not emit development diagnostics.

Script Context

The current script context includes:

Group Members
Inputs props, layers, router
Reactivity signal, computed, watch, watchMultiple, watchEffect
Lifecycle onBeforeMount, onMount, onBeforeUnmount, onUnmount
Capabilities useLayer, useService, useComponent, store, useStore
Composition provide, inject, expose, useCallback, useMemo

Prefer alias layer records or typed direct layer helpers. String store/service lookups remain escape hatches and compatibility surfaces.

Memoization And Callbacks

Effuse runs script once for each component instance. A closure declared in script therefore already has stable identity for the component lifetime:

script: ({ signal }) => {
  const count = signal(0);
  const increment = () => (count.value += 1);
  return { count, increment };
}

useCallback is deprecated. It returns the original function for compatibility and emits a development migration warning; dependency arguments are ignored. Replace it with a plain closure. This avoids a React-style dependency API that cannot replace callback identity because Effuse does not rerun script during rendering.

Use computed for normal derived reactive state. useMemo exists for the narrower case where a component intentionally needs an explicit invalidation boundary:

script: ({ signal, useMemo }) => {
  const page = signal(1);
  const draft = signal('');

  const requestKey = useMemo(
    () => `${page.value}:${draft.value}`,
    [page],
  );

  return { page, draft, requestKey };
}

The dependency contract is deterministic:

  • Omit the list to automatically track signals read by the memo function. This is equivalent to computed and computed is preferred for clarity.
  • Pass a readonly list of signals to invalidate only when one of those signals changes. Other signal reads are snapshots taken during recomputation.
  • Pass [] to compute once on first read for that component instance.
  • Non-signal entries are rejected by TypeScript. Unsafe runtime values are ignored with a development warning.
  • Memo subscriptions are disposed with the component lifecycle and do not remain active after unmount.

Children

Children are available in the template context:

const Panel = define({
  script: () => ({}),
  template: ({ children }) => <section class="panel">{children}</section>,
});

Lifecycle

const Clock = define({
  script({ signal, onMount, onUnmount }) {
    const now = signal(Date.now());
    let timer: ReturnType<typeof setInterval> | undefined;

    onMount(() => {
      timer = setInterval(() => (now.value = Date.now()), 1000);
    });
    onUnmount(() => {
      if (timer) clearInterval(timer);
    });

    return { now };
  },
  template: ({ now }) => <time>{now}</time>,
});

Watchers created through the script context are scoped to component unmount. Lifecycle callback failures are collected after all sibling callbacks and cleanups run. Configure createApp(root, { onError }) to receive typed LifecycleError aggregates; without a handler, Effuse reports them to the console instead of hiding them.

Rendering Primitives

Effuse exports For, Show, Switch, Dynamic, Repeat, Await, Suspense, and ErrorBoundary. Use these for explicit control flow and async boundaries. JSX expressions may also contain signals and functions that resolve to Effuse children.

Typed JSX Events

JSX event handlers receive the concrete owning element as event.currentTarget — no casts required:

<input
  onInput={(event) => {
    // event: InputEvent, event.currentTarget: HTMLInputElement
    draft.value = event.currentTarget.value;
  }}
/>

<dialog onClose={(event) => log(event.currentTarget.returnValue)} />

The element type is threaded through every intrinsic tag, including SVG (<circle onClick> sees SVGCircleElement) and MathML. Payload types follow the platform: onToggle/onBeforeToggle receive ToggleEvent, onInput/onBeforeInput receive InputEvent, onSubmit receives SubmitEvent, and onError receives a plain Event (never a string — handlers are bound with addEventListener). Dialog onClose/onCancel and onCueChange are part of the surface. Handler names remain on* and lower to the matching DOM event name at runtime. The EffuseEvent and EffuseEventHandler types are exported for building typed wrappers, and custom elements fall back to Element as the handler target.

Context

createTypedContext publishes a value to a component subtree. Identity is the returned token, so two contexts that happen to share a name stay distinct — no global string registry, no accidental merging between a library and an application.

const ThemeContext = createTypedContext<Signal<Theme>>({ name: 'theme' });

// provider component
script: () => { ThemeContext.provide(themeSignal); return {}; }

// consumer component
script: () => ({ theme: ThemeContext.use() })

use() returns the value and throws a typed error naming the context when no provider exists; useOptional() returns undefined. Resolution walks the provide scope tree, so nesting shadows correctly, siblings never interfere, and values resolve during render — including on the server.

Provide a signal, not a snapshot. Consumers then track the signal directly, and because rendering is fine-grained a consumer updates only for the values it actually reads. There is no re-render cascade and therefore no selector API of the kind context implementations built on re-rendering require.

provide/inject remain available for untyped keys and share the same resolution path.

Error Surface

A component can define blueprint error/loading behavior through lower-level APIs. Dynamic render failures without a custom boundary produce a visible data-effuse-render-error alert rather than silently dropping the subtree.

Related

Clone this wiki locally