Components

On this page 20

stx components are reusable UI elements that encapsulate template, logic, and styling. This guide covers everything you need to know about creating and using components.

Looking for the component library? See the Component Library reference for the engine builtins / @stacksjs/components split, the JSX-tag syntax, and an index of all shipped components.

Component Basics

Component Structure

A .stx component file has up to three parts:

  1. <script server> — server-side prop normalization and class string assembly
  2. The template HTML
  3. <script client> — client-side reactivity (signals, event handlers, exposed methods)
<!-- components/MyButton.stx -->
<script server>
  export const variant = $props.variant || 'primary'
  export const size = $props.size || 'md'
  export const disabled = !!$props.disabled
  export const buttonClass = `btn btn-${variant} btn-${size}`
</script>

<button
  class="{{ buttonClass }}"
  @if(disabled)disabled@endif
  @click="onClick($event)"
>
  <slot />
</button>

<script client>
  const emit = defineEmits(['click'])
  const disabled = {{ disabled }}

  function onClick(event) {
    if (disabled) return
    emit('click', event)
  }
</script>

Drop the file into your componentsDir (default components/) and it's auto-registered. The file name (PascalCase) becomes the tag.

Using Components

The canonical syntax is JSX tags. No imports — components are resolved by tag name from componentsDir and the engine builtins.

<div class="actions">
  <MyButton variant="primary" @click="save()">Save</MyButton>
  <MyButton variant="secondary" @click="cancel()">Cancel</MyButton>
  <MyButton variant="danger" :disabled="isDeleting" @click="del()">Delete</MyButton>
</div>

Static props pass strings (variant="primary"). Dynamic props bind expressions (:disabled="isDeleting"). Event listeners use @event (@click="save()").

Pure server component memoization

Repeated server-only components can opt into render memoization with <script server cache>. Use it only when the rendered fragment is a pure function of the component's props and slots:

<script server cache>
export const label = $props.label || ''
export const tone = $props.tone || 'neutral'
</script>

<span class="badge badge-{{ tone }}">{{ label }}</span>

stx keys the cached fragment by component file, props, resolved slot content, the serializable caller values a projected slot can read, build mode, and event-processing mode. This keeps successive request contexts isolated when a slot contains expressions, conditionals, loops, includes, or nested components. If the projected caller context contains a function, symbol, or cycle, stx safely skips fragment caching for that render instead of guessing whether the output is reusable. Nested component dependencies are retained, and the development watcher clears the cache whenever component source changes.

Do not enable this for components that read request data, authentication, cookies, the database, the clock, random values, mutable globals, or any other input that is not passed through props or slots. Components with client scripts may opt in; stx replays their factory and scope bookkeeping on a cache hit so each instance still receives its own reactive scope.

@component Directive

The Blade-style directive form is the other equally first-class way to invoke a component. It pairs naturally with the rest of the @-prefixed server directives (@if, @foreach, @include, etc.) and is the easiest way to pass props that don't fit cleanly into HTML attributes (functions, deeply nested objects):

@component('MyButton', {
  variant: 'primary',
  onClick: () => save(),
})
  Save
@endcomponent

Both forms compile to the same thing. Use whichever fits the call site.

<div class="actions">
  <Button
    type="primary"
    size="lg"
    @click="handleClick"
  >
    Click Me
  </Button>
</div>

Component Features

Props

Define and validate component props:

@ts
interface CardProps {
  title: string
  subtitle?: string
  image?: string
  loading?: boolean
}
@endts

@component('Card', {
  props: {
    title: '',
    subtitle: undefined,
    image: undefined,
    loading: false
  },
  // Prop validation
  propValidators: {
    title: (value) => value.length > 0,
    image: (value) => value?.startsWith('http')
  }
})
  <div class="card">
    @if(loading)
      <loading-spinner />
    @else
      @if(image)
        <img x-src="image" x-alt="title" />
      @endif

      <div class="card-content">
        <h2>{{ title }}</h2>
        @if(subtitle)
          <p>{{ subtitle }}</p>
        @endif
        <slot></slot>
      </div>
    @endif
  </div>
@endcomponent

Typed component contracts

For a Vue-3-style typed contract, declare props/emits/slots with the define* macros inside <script server> (SSR) or <script> (client). They are globally available — no import needed.

<script server>
interface Props {
  src: string            // required
  alt?: string           // optional
  count?: number         // optional
  fallback?: string      // optional
}

interface Emits {
  (e: 'close'): void
  (e: 'submit', payload: { value: string }): void
}

interface Slots {
  default: () => unknown
  header?: (props: { title: string }) => unknown
}

// Typed props. Pair with withDefaults for default values.
const props = withDefaults(defineProps<Props>(), {
  alt: '',
  count: 0,
  fallback: '/images/default.svg',
})

const emit = defineEmits<Emits>()
const slots = defineSlots<Slots>()
</script>

<img src="{{ props.src }}" alt="{{ props.alt }}" data-count="{{ props.count }}">
MacroReturnsUse it for
defineProps<T>()the props object, typed as Treading props with autocomplete + type-checked access
withDefaults(defineProps<T>(), { … })T with defaults filled indefault values that survive type narrowing
defineProps<T>({ key: { default } })T with options-style defaultsVue runtime-options form (equivalent to withDefaults)
defineEmits<E>()a typed emit(event, payload) fnemitting events the parent listens to via @event
defineSlots<S>()the live slots map, typed as Sbranching on which slots the caller provided

Defaults never collapse falsy values. A default is applied only when the prop is undefined — a passed 0, false, or '' is a real value and is kept. This holds identically on the server and the client, so there is no hydration mismatch:

<!-- caller -->
<Counter :count="0" />

<script server>
// props.count is 0 — NOT the 99 default. `$props.count || 99` would wrongly
// collapse it to 99; defineProps/withDefaults do not.
const props = withDefaults(defineProps<{ count?: number }>(), { count: 99 })
</script>

Compile-time call-site validation (turning <SafeImage src={123} /> into a TS error via a generated .d.ts) is tracked separately as a build-tooling phase — the macros above give you the typed authoring surface and the runtime-correct defaults today.

Slots

Use slots to inject content into components:

@component('Panel')
  <div class="panel">
    <!-- Default slot -->
    <slot></slot>

    <!-- Named slots -->
    <slot name="header"></slot>
    <slot name="footer"></slot>

    <!-- Slots with fallback content -->
    <slot name="empty">
      <p>No content available</p>
    </slot>
  </div>
@endcomponent

<!-- Usage -->
<Panel>
  @slot('header')
    <h2>Panel Title</h2>
  @endslot

  <p>Main content goes here</p>

  @slot('footer')
    <button>Close</button>
  @endslot
</Panel>

Events

Emit and handle component events:

@component('SearchInput', {
  props: {
    placeholder: 'Search...'
  }
})
  <div class="search">
    <input
      type="text"
      x-placeholder="placeholder"
      @input="handleInput"
    />
    <button @click="handleClear">Clear</button>
  </div>

  @ts
  function handleInput(e: Event) {
    const value = (e.target as HTMLInputElement).value
    $emit('search', value)
  }

  function handleClear() {
    $emit('clear')
  }
  @endts
@endcomponent

<!-- Usage -->
<SearchInput
  @search="onSearch"
  @clear="onClear"
/>

Lifecycle Hooks

Use lifecycle hooks to run code at specific times:

@component('DataFetcher')
  @ts
  let data: any[] = []
  let loading = true
  let error: Error | null = null

  // Called when component is mounted
  onMounted(async () => {
    try {
      data = await fetchData()
    } catch (e) {
      error = e
    } finally {
      loading = false
    }
  })

  // Called before component is unmounted
  onUnmounted(() => {
    // Cleanup
  })

  // Called when props change
  onPropsChanged((newProps, oldProps) => {
    // Handle prop changes
  })
  @endts

  <div class="data-fetcher">
    @if(loading)
      <loading-spinner />
    @elseif(error)
      <error-message :error="error" />
    @else
      <data-display :items="data" />
    @endif
  </div>
@endcomponent

Components in a layout

A component the layout renders (a nav, a sidebar, a sign-in sheet) sits outside the router's container, so SPA navigation between pages that share the layout leaves it on screen, and leaves it running. Its <script client> ran once, on the page that loaded first, and it is not set up again on the next page: its state, its listeners, its DOM, focus and scroll all carry over, and whatever it sets later still renders.

  • To follow the route, listen for the router's stx:navigate event rather than relying on the component running again:

    <script client>
      const path = state(location.pathname)
      useEventListener('stx:navigate', () => path.set(location.pathname))
    </script>
    
  • onDestroy runs when the component actually leaves the page: when a navigation lands on a page with a different layout, which replaces the whole body.

  • Teardown you register while the component mounts belongs to it: a composable such as useEventListener called inside onMount is removed when the component is destroyed, not at the next navigation.

  • Teardown registered later, after an await or in a timer, by a composable (or by onDestroy in an @included partial) goes to the page-wide queue that every navigation drains. Register it while the component sets up or mounts.

This applies to component tags and @included partials alike. A component inside the container is part of the page: it is destroyed and set up again on every navigation.

State Management

Manage component state:

@component('Counter')
  @ts
  let count = 0

  // Computed property
  const isEven = computed(() => count % 2 === 0)

  // Methods
  function increment() {
    count++
  }

  function decrement() {
    count--
  }

  // Watch for changes
  watch(count, (newValue, oldValue) => {
    console.log(`Count changed from ${oldValue} to ${newValue}`)
  })
  @endts

  <div class="counter">
    <button @click="decrement">-</button>
    <span>{{ count }}</span>
    <button @click="increment">+</button>
    <p>Count is {{ isEven ? 'even' : 'odd' }}</p>
  </div>
@endcomponent

Advanced Features

Async Components

Load components asynchronously:

@component('AsyncComponent')
  @async
    @loading
      <loading-spinner />
    @error
      <error-message :error="error" />
    @success
      <!-- Heavy component content -->
    @endasync
@endcomponent

Error Boundaries

Handle component errors:

@component('ErrorBoundary')
  @errorBoundary
    @error
      <div class="error">
        <h3>Something went wrong</h3>
        <p>{{ error.message }}</p>
        <button @click="retry">Retry</button>
      </div>
    @else
      <slot></slot>
    @enderror
  @enderrorBoundary
@endcomponent

Component Composition

Compose components together:

@component('UserProfile')
  @ts
  interface Props {
    userId: string
  }
  @endts

  <div class="profile">
    <ErrorBoundary>
      <AsyncComponent>
        <UserInfo :id="userId" />
        <UserStats :id="userId" />
        <UserPosts :id="userId" />
      </AsyncComponent>
    </ErrorBoundary>
  </div>
@endcomponent

Best Practices

  1. Component Design

    • Keep components focused and single-purpose
    • Use TypeScript interfaces for props
    • Document component APIs
    • Follow naming conventions
  2. Performance

    • Use async components for large components
    • Implement proper cleanup in onUnmounted
    • Optimize re-renders with memo
    • Use lazy loading when appropriate
  3. State Management

    • Keep state as local as possible
    • Use computed properties for derived state
    • Watch for side effects
    • Consider global state for app-wide data
  4. Testing

    • Write unit tests for components
    • Test edge cases and error states
    • Test component events
    • Test async behavior

Component Organization

Recommended file structure:

components/
├── common/
│   ├── Button.stx
│   ├── Card.stx
│   └── Input.stx
├── layout/
│   ├── Header.stx
│   └── Footer.stx
├── features/
│   ├── UserProfile.stx
│   └── SearchBar.stx
└── pages/
    ├── Home.stx
    └── About.stx

Next Steps