Components

Compose typed components with reactive props, named slots, and explicit context boundaries.

This guide documents how Regor components work in runtime, based on current implementation and tests.

PropsSlotsComposition

Define a reusable view, then decide which state belongs to its parent.

Try it live

Compose a profile, liveLive Regor
CHILD COMPONENT / LIVE PROPS

Ada Lovelace

Engineer

PARENT SLOTOpen to collaboration
import {
  computed,
  defineComponent,
  html,
  ref,
  unref,
  type RefOrValue,
} from 'regor'

interface ProfileProps {
  name: RefOrValue<string>
  role: RefOrValue<string>
  compact: RefOrValue<boolean>
}

export function createProfile(interactive = false) {
  const ProfileCard = defineComponent<
    ProfileProps & { initials: RefOrValue<string> }
  >(
    html` <article class="guide-profile" :class="{ 'is-compact': compact }">
      <div class="guide-avatar" aria-hidden="true">{{ initials }}</div>
      <div>
        <h3>{{ name || 'Your name' }}</h3>
        <p>{{ role }}</p>
      </div>
      <footer><slot name="footer"></slot></footer>
    </article>`,
    {
      props: ['name', 'role', 'compact'],
      context: (head) => {
        head.enableSwitch = true // The named slot reads the parent's context.
        return {
          ...head.props,
          initials: computed(
            () =>
              unref(head.props.name)
                .trim()
                .split(/\s+/)
                .slice(0, 2)
                .map((word) => word[0] ?? '')
                .join('')
                .toUpperCase() || '?',
          ),
        }
      },
    },
  )
  return {
    interactive,
    name: ref('Ada Lovelace'),
    role: ref('Engineer'),
    compact: ref(false),
    available: ref(true),
    components: { ProfileCard },
  }
}

export const profileTemplate = html` <div class="guide-demo guide-demo--split">
  <div class="guide-controls">
    <label for="profile-name">Display name</label
    ><input
      id="profile-name"
      type="text"
      maxlength="40"
      r-model="name"
      :disabled="!interactive"
    />
    <label for="profile-role">Role</label
    ><select id="profile-role" r-model="role" :disabled="!interactive">
      <option>Engineer</option>
      <option>Designer</option>
      <option>Researcher</option>
    </select>
    <label class="guide-check"
      ><input type="checkbox" r-model="compact" :disabled="!interactive" />
      Compact layout</label
    >
    <label class="guide-check"
      ><input type="checkbox" r-model="available" :disabled="!interactive" />
      Available for a project</label
    >
  </div>
  <div class="guide-profile-preview">
    <span class="guide-kicker">CHILD COMPONENT / LIVE PROPS</span>
    <ProfileCard :name="name" :role="role" :compact="compact">
      <template #footer
        ><span class="guide-slot-label">PARENT SLOT</span
        ><span
          >{{ available ? 'Open to collaboration' : 'Focused on a project'
          }}</span
        ></template
      >
    </ProfileCard>
  </div>
</div>`
import { createApp, useScope } from 'regor'
import { createQuote, quoteTemplate } from './quote'
import { createServices, servicesTemplate } from './services'
import { createProfile, profileTemplate } from './profile'
import { createLifecycle, lifecycleTemplate } from './lifecycle'

// Each guide mounts only its own island. Everything outside the root stays static.
function mount<T extends object>(
  id: string,
  create: (interactive: boolean) => T,
  template: string,
) {
  const element = document.getElementById(id)
  if (element)
    createApp(
      useScope(() => create(true)),
      { element, template },
    )
}

mount('guide-quote', createQuote, quoteTemplate)
mount('guide-services', createServices, servicesTemplate)
mount('guide-profile', createProfile, profileTemplate)
mount('guide-lifecycle', createLifecycle, lifecycleTemplate)

The parent owns the form. The child receives live props, computes initials, and renders a named slot supplied by the parent.

Define a Component

import { defineComponent, html } from 'regor'

export const UserCard = defineComponent(
  html`<article><h3 r-text="title"></h3></article>`,
  { props: ['title'] },
)

defineComponent(template, options) supports:

  1. Template from string: defineComponent('<div>...</div>')
  2. Template object with template
  3. Template object with element
  4. Template object with selector
  5. Template object with json

Interpolation is enabled by default (useInterpolation: true).

Register and Use Components

Register via app context:

createApp({
  components: { UserCard },
})

or via RegorConfig.addComponent(...) (registered/global style).

Tags resolve in component map using component name and kebab-case variant.

<UserCard :title="activeUser.name" /> <user-card :title="activeUser.name" />

Component Context and ComponentHead

Component context is created by options.context(head). head is ComponentHead and exposes:

  1. head.props: resolved incoming props object.
  2. head.emit(event, detail): dispatches CustomEvent from host element.
  3. head.autoProps (default true): auto-assigns props into component context fields.
  4. head.entangle (default true): if both sides are refs, parent and component refs are two-way entangled during auto-props.
  5. head.enableSwitch (default false): enables slot context switching to parent for slot templates.
  6. head.onAutoPropsAssigned: callback after auto props assignment.
  7. head.findContext(ContextClass, occurrence?): returns matching parent context instance from head.ctx by instanceof, or undefined.
  8. head.requireContext(ContextClass, occurrence?): resolves matching parent context instance from head.ctx by instanceof; throws if the selected occurrence does not exist.
  9. head.validateProps(schema): validates selected incoming props at runtime.
  10. head.unmount(): removes mounted nodes in component range and calls unmounted hooks.

occurrence is zero-based:

  1. 0 (default): first match
  2. 1: second match
  3. 2: third match

Emit Example

class CardContext {
  title = ref('local')
  $emit?: (event: string, args: Record<string, unknown>) => void
  save = () => this.$emit?.('save', { title: this.title() })
}

const Card = defineComponent('<button @click="save">Save</button>', {
  context: () => new CardContext(),
})

In parent:

<Card @save="onSave($event)" />

Parent context lookup example

class AppServices {
  api = '/v1'
}

class OuterLayoutContext {}

const Child = defineComponent('<div></div>', {
  context: (head) => {
    const services = head.requireContext(AppServices)
    const secondLayout = head.findContext(OuterLayoutContext, 1)
    return { services, secondLayout }
  },
})

Runtime Prop Validation

Regor lets component authors validate incoming props inside context(head).

This validation model is intentionally runtime-first:

  1. It is fully opt-in.
  2. It validates only the keys you list.
  3. It follows the active propValidationMode config.
  4. It does not coerce values.
  5. It does not mutate head.props.

Use the built-in validators through pval:

import { defineComponent, html, pval } from 'regor'

type EditorCard = {
  title: string
  count?: number
  mode: 'create' | 'edit'
  summary?: string
}

const EditorCard = defineComponent<EditorCard>(
  html`<article>{{ summary }}</article>`,
  {
    props: ['title', 'count', 'mode'],
    context: (head) => {
      head.validateProps({
        title: pval.isString,
        count: pval.optional(pval.isNumber),
        mode: pval.oneOf(['create', 'edit'] as const),
      })

      return {
        ...head.props,
        summary: `${head.props.title}:${head.props.mode}:${head.props.count ?? 'none'}`,
      }
    },
  },
)

Built-in validators

pval currently provides:

  1. pval.isString
  2. pval.isNumber
  3. pval.isBoolean
  4. pval.isClass(SomeClass)
  5. pval.optional(validator)
  6. pval.nullable(validator)
  7. pval.or(...validators)
  8. pval.oneOf([...])
  9. pval.arrayOf(validator)
  10. pval.shape({ ... })
  11. pval.refOf(validator)
  12. pval.describe(value)
  13. pval.fail(name, detail)

Example:

head.validateProps({
  title: pval.isString,
  count: pval.optional(pval.isNumber),
  mode: pval.oneOf(['create', 'edit'] as const),
  value: pval.or(pval.isString, pval.isNumber),
  tags: pval.arrayOf(pval.isString),
  meta: pval.shape({
    slug: pval.isString,
    retries: pval.nullable(pval.isNumber),
  }),
})

Single-prop bindings and refs

Dynamic single-prop bindings such as :title="titleRef" flow into component props as refs. When validating that runtime value, use pval.refOf(...):

type TitleCard = {
  title: Ref<string>
  summary?: string
}

const TitleCard = defineComponent<TitleCard>(html`<h3>{{ summary }}</h3>`, {
  props: ['title'],
  context: (head) => {
    head.validateProps({
      title: pval.refOf(pval.isString),
    })

    return {
      ...head.props,
      summary: head.props.title(),
    }
  },
})

Object input validation

For object-style :context="{ ... }" values, validate the plain runtime object shape:

head.validateProps({
  meta: pval.shape({
    slug: pval.isString,
  }),
})

Custom validators

Users are not limited to the built-in validators. Any function matching PropValidator<T> can be used:

import { pval, type PropValidator } from 'regor'

const isNonEmptyString: PropValidator<string> = (value, name) => {
  if (typeof value !== 'string' || value.trim() === '') {
    pval.fail(name, `expected non-empty string, ${pval.describe(value)}`)
  }
}

head.validateProps({
  title: isNonEmptyString,
})

Custom validators can also use the third head argument:

const startsWithParentPrefix: PropValidator<string> = (value, name, head) => {
  const ctx = head.requireContext(AppServices)
  if (typeof value !== 'string' || !value.startsWith(ctx.prefix)) {
    pval.fail(name, `expected prefixed value, ${pval.describe(value)}`)
  }
}

When to use validation

Validation is most useful when:

  1. The component depends on a required runtime contract.
  2. The same component is used in many places.
  3. You want an explicit runtime guard before local state mapping.
  4. You set head.autoProps = false and map incoming props manually.

Validation mode

Validation behavior is configured through RegorConfig.propValidationMode:

import { RegorConfig } from 'regor'

const config = new RegorConfig()
config.propValidationMode = 'warn'

Modes:

  1. 'throw' (default): throws on the first invalid prop.
  2. 'warn': sends the validation failure to warningHandler.warning(...) and continues.
  3. 'off': skips runtime prop validation.

Pass the config into createApp(...) to apply the mode for that app:

createApp(appContext, template, config)

How Component Inputs Are Routed

On a component host tag, Regor routes bindings through two different channels:

  1. Component input channel: values end up in head.props and can become component state.
  2. Attribute fallthrough channel: values are copied as DOM attributes/class/style to rendered output root.

1) Declared single-prop bindings (component input channel)

Use:

  1. :x="..."
  2. .x="..."
  3. r-bind:x="..."

These are treated as component props only if x is declared in props: [...].

const Card = defineComponent('<h3 r-text="title"></h3>', {
  props: ['title'],
  context: (head) => ({ title: head.props.title }),
})
<Card :title="user.name"></Card> <Card r-bind:title="user.name"></Card>

If x is not declared in props, that single binding is treated as normal attribute fallthrough.

2) Object component input (:context / r-context)

Use:

<Card :context="{ title: user.name, badge: role }"></Card>
<Card r-context="{ title: user.name, badge: role }"></Card>

This is object-style component input and does not require keys to be listed in props.

3) Object-form r-bind uses the attribute channel

<Card r-bind="{ id: cardId, 'data-role': role }"></Card>

For component hosts, this form is processed as attribute forwarding to rendered output nodes. It does not go through declared-prop resolution or :context object assignment.

r-bind:x="..." is different: it is single-key binding and can map to component input when x is declared in props.

autoProps and entangle behavior

  1. head.autoProps = true: Regor merges head.props into the returned component context.
  2. With head.autoProps = true, omitted declared prop names are added as local undefined only when the component did not already define that field.
  3. That keeps declared prop names local to the component and prevents same-named parent values from being resolved by accident.
  4. head.autoProps = false: component author maps from head.props manually, and omitted names are not isolated automatically.
  5. head.autoProps = true + head.entangle = true: ref-to-ref inputs are two-way entangled.
  6. head.autoProps = true + head.entangle = false: ref-to-ref inputs are initial snapshot only.
  7. Primitive/object values targeting existing component ref fields are applied to those refs.
  8. For single-prop bindings, wrapping an expression in ref(...) gives the child a live reactive prop source even when the expression evaluates to a non-ref.

Slots

Supported slot patterns:

  1. Default slot: <slot></slot>
  2. Named slot: <slot name="extra"></slot>
  3. Named slot shorthand in component template: <slot #extra></slot>
  4. Host-side named template: <template name="extra">...</template> or <template #extra>...</template>
  5. Fallback slot content when host does not provide matching content.

Default slot behavior:

  1. If host provides unnamed content, it is used.
  2. Named-only template shortcuts are not injected into default slot.

For parent-context slot expression switching, enable:

context: (head) => {
  head.enableSwitch = true
  return {}
}
Slots can read the parent context

Set head.enableSwitch = true when slot expressions should resolve against the parent. The preview uses this for the availability footer.

Attribute inheritance (inheritAttrs)

By default, component host attributes are inherited into component output root (inheritAttrs: true).

Details:

  1. class is merged.
  2. style properties are merged.
  3. Other attributes are copied.
  4. :context is excluded from inherit copy.
  5. If component has multiple root elements, r-inherit can mark intended inheritor.

Set inheritAttrs: false to disable this behavior.

Dynamic components with :is

<div :is="currentCard" :item="item"></div>

Switching :is remounts target component selection while keeping reactive prop flow behavior.

Nested components and lifecycle

Nested component trees are supported (r-for, r-if, nested slots). Component lifecycle hooks run through standard mounted/unmounted flow. Unmount cleans child component bindings and observers.

Best Practices

  1. Declare single props in props when using individual bindings.
  2. Use :context for object-style prop passing.
  3. Use head.validateProps(...) for explicit runtime prop contracts.
  4. Use pval.refOf(...) for dynamic single-prop bindings that arrive as refs.
  5. Use head.enableSwitch = true when slot content must evaluate in parent context.
  6. Decide autoProps + entangle explicitly when designing parent-child data ownership.
  7. Keep fallback slot content for robust component defaults.
  8. Use stable keys for component lists rendered with r-for.

See Also

  1. defineComponent API
  2. pval API
  3. Directive: :is
  4. Directive: :context
  5. TypeScript Guide
  6. Directive: r-for
  7. contextRegistry API