Skip to content

Component Anatomy

A Verbose component is a TypeScript class that extends BaseComponent. This guide walks through each part of a component and how they connect.

Overview

tsx
import { Component, Prop, State, Watch, Emit, Slot, OnCommand } from '@verbose/decorators'
import { Command, createCommand, BaseComponent } from '@verbose/decorators'
import { resource } from '@verbose/core'

@Component({ tag: 'user-card' })
class UserCard extends BaseComponent {
  // ── Props ──────────────────────────────────────────────────────────────
  @Prop() userId!: number
  @Prop() elevated = false
  @Prop() onSave?: (user: User) => void
  @Prop() refresh?: Command

  // ── Local state ─────────────────────────────────────────────────────────
  @State() editing = false
  @State() name = ''

  // ── Async data ──────────────────────────────────────────────────────────
  user = resource(async () => {
    const res = await fetch(`/api/users/${this.props.userId}`)
    return res.json() as Promise<User>
  })

  // ── Watchers ─────────────────────────────────────────────────────────────
  @Watch('editing')
  onEditingChange(next: boolean) {
    if (next) this.name = this.user.data.value?.name ?? ''
  }

  // ── Inbound commands ─────────────────────────────────────────────────────
  @OnCommand('refresh')
  handleRefresh() {
    this.user.refetch()
  }

  // ── Emitted events ───────────────────────────────────────────────────────
  @Emit('onSave')
  save() {
    return { ...this.user.data.value, name: this.name }
  }

  // ── Slots ────────────────────────────────────────────────────────────────
  @Slot('actions') actions!: Child[]
  @Slot() default!: Child[]

  // ── Lifecycle ────────────────────────────────────────────────────────────
  onMount() {
    console.log('UserCard mounted, userId:', this.props.userId)
  }

  onUnmount() {
    console.log('UserCard unmounted')
  }

  // ── Render ───────────────────────────────────────────────────────────────
  render() {
    const { data, pending, error } = this.user

    if (pending.value) return <div class="skeleton" />
    if (error.value) return <div class="error">{error.value.message}</div>

    return (
      <div class={`card ${this.props.elevated ? 'elevated' : ''}`}>
        <header>
          {this.editing ? (
            <input value={this.name} onInput={e => { this.name = (e.target as HTMLInputElement).value }} />
          ) : (
            <h2>{data.value?.name}</h2>
          )}
        </header>

        <main>{this.default}</main>

        <footer>
          {this.editing ? (
            <button onClick={() => this.save()}>Save</button>
          ) : (
            <button onClick={() => { this.editing = true }}>Edit</button>
          )}
          {this.actions}
        </footer>
      </div>
    )
  }
}

Each part explained

1. @Component

Registers the class as a component and defines metadata.

ts
@Component({ tag: 'user-card' })
class UserCard extends BaseComponent { }
  • tag — custom element tag name (optional)
  • Every component class extends BaseComponent

2. Props — @Prop()

Props are values received from the parent. The decorated property value is the local default — the parent always takes priority.

ts
@Prop() userId!: number            // required (parent always provides)
@Prop() elevated = false           // optional with default
@Prop() onSave?: (u: User) => void // callback prop
@Prop() refresh?: Command          // imperative command prop

Always read props via this.props.propName inside render() to ensure reactivity:

tsx
render() {
  return <div class={this.props.elevated ? 'elevated' : ''} />
}

3. Local state — @State()

Internal component state. Each @State property is a signal; any assignment schedules a re-render.

ts
@State() editing = false
@State() name = ''

Direct read and write:

ts
this.editing          // reads current value
this.editing = true   // updates and schedules re-render

To access the underlying signal (e.g. to pass to composables):

ts
import { getSignal } from '@verbose/decorators'
const editingSignal = getSignal<boolean>(this, 'editing')

4. Async data — resource

Manages data fetching with reactive pending, error, and data state. Re-executes whenever any signal read inside the fetcher changes.

ts
user = resource(async () => {
  const res = await fetch(`/api/users/${this.props.userId}`)
  return res.json()
})

In the template:

tsx
if (this.user.pending.value) return <Spinner />
if (this.user.error.value) return <Error />
return <h2>{this.user.data.value?.name}</h2>

Manual actions:

ts
this.user.refetch()              // re-trigger the fetcher
this.user.cancel()               // abort the in-flight request
this.user.mutate({ name: 'x' }) // optimistic update

5. Watchers — @Watch

Runs a method whenever one or more @State / @Prop properties change.

ts
@Watch('editing')
onEditingChange(next: boolean, prev: boolean) {
  if (next) this.name = this.user.data.value?.name ?? ''
}

Multiple properties at once:

ts
@Watch('firstName', 'lastName')
onNameChange([newFirst, newLast]: [string, string]) {
  this.fullName = `${newFirst} ${newLast}`
}

6. Inbound commands — @OnCommand

Subscribes to a Command prop, executing the method when the parent calls command.trigger(). Automatically unsubscribes on unmount.

ts
@Prop() refresh?: Command

@OnCommand('refresh')
handleRefresh() {
  this.user.refetch()
}

In the parent:

ts
const refreshCmd = createCommand()
<UserCard refresh={refreshCmd} />
refreshCmd.trigger()  // calls handleRefresh

7. Emitted events — @Emit

Binds the method and calls the named prop callback with the return value. Ensures correct this binding.

ts
@Prop() onSave?: (user: User) => void

@Emit('onSave')
save() {
  return { ...this.user.data.value, name: this.name }
  // return value is passed to this.props.onSave(...)
}

In the parent:

tsx
<UserCard onSave={(user) => console.log('saved:', user)} />

8. Slots — @Slot

Allows distributing content into the component. An unnamed slot receives direct children with no slot attribute.

ts
@Slot('actions') actions!: Child[]  // matched by <div slot="actions">
@Slot() default!: Child[]           // children without slot=""

In the parent:

tsx
<UserCard userId={1}>
  <p>This goes into the default slot</p>
  <div slot="actions">
    <button>Delete</button>
  </div>
</UserCard>

9. Lifecycle

Override lifecycle methods directly on the class.

MethodWhen it runs
onBeforeMount()Before first render
onMount()After first DOM insertion
onBeforeUpdate(prev)Before re-render triggered by prop change
onAfterUpdate(prev)After DOM update
onUnmount()When removed from DOM
onError(err)On uncaught error inside the component
ts
onMount() {
  this._timerId = setInterval(() => this.tick(), 1000)
}

onUnmount() {
  clearInterval(this._timerId)
}

10. Render

The only required method. Must return a VNode or null. Re-executes reactively whenever any signal read inside it changes.

tsx
render() {
  // Signal reads here create reactive dependencies
  const { data, pending } = this.user

  return (
    <div>
      {pending.value ? <Spinner /> : <h2>{data.value?.name}</h2>}
    </div>
  )
}

TIP

Prefer reading @State and @Prop values directly inside render() rather than storing them in variables outside — this ensures the reactive tracking works correctly.


Data flow

      Parent

   @Prop / Command


  ┌─────────────┐
  │  Component  │
  │             │
  │  @State ◄───┼── @Watch
  │  resource   │
  │  @Slot ◄────┼── children from parent
  │             │
  └──────┬──────┘

    render() ──► DOM

    @Emit / callback props


      Parent

Composing with external utilities

tsx
import { createRef, useElementSize } from '@verbose/composables'
import { tween } from '@verbose/motion'

@Component()
class AnimatedPanel extends BaseComponent {
  ref = createRef<HTMLDivElement>()
  size = useElementSize(this.ref)
  opacity = tween(0, 1, { duration: 400 })

  onMount() {
    this.opacity.target.set(1)
  }

  render() {
    return (
      <div
        ref={this.ref}
        style={{ opacity: String(this.opacity.value.value) }}
      >
        <p>Width: {this.size.width.value}px</p>
      </div>
    )
  }
}

Released under the MIT License.