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/componentssplit, the JSX-tag syntax, and an index of all shipped components.
Component Basics
Component Structure
A .stx component file has up to three parts:
<script server>— server-side prop normalization and class string assembly- The template HTML
<script client>— client-side reactivity (signals, event handlers, exposed methods)
<!-- components/MyButton.stx -->
<scriptserver
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>
<scriptclient
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.
<divclass="">
<MyButtonvariant=""click="">Save</MyButton>
<MyButtonvariant=""click="">Cancel</MyButton>
<MyButtonvariant=""disabled=""click="">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>
export const label = $props.label || ''
export const tone = $props.tone || 'neutral'
</script>
<spanclass="{{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.
<divclass="">
<Button
type="primary"
size="lg"
="handleClick"
>
Click Me
</Button>
</div>
Component Features
Props
Define and validate component props:
interface CardProps {
title: string
subtitle?: string
image?: string
loading?: boolean
}
@component(''
props: {
title: '',
subtitle: undefined,
image: undefined,
loading: false
},
// Prop validation
propValidators: {
title: (value) => value.length > 0,
image: (value) => value?.startsWith('http')
}
})
<divclass="">
@if(loading)
<loading-spinner />
@if(image)
<imgx-src=""x-alt="" />
<divclass="">
<h2>{{ title }}</h2>
@if(subtitle)
<p>{{ subtitle }}</p>
<slot></slot>
</div>
</div>
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>
<imgsrc="{{props}}"alt="{{props}}"data-count="{{props}}">
| Macro | Returns | Use it for |
|---|---|---|
defineProps<T>() | the props object, typed as T | reading props with autocomplete + type-checked access |
withDefaults(defineProps<T>(), { … }) | T with defaults filled in | default values that survive type narrowing |
defineProps<T>({ key: { default } }) | T with options-style defaults | Vue runtime-options form (equivalent to withDefaults) |
defineEmits<E>() | a typed emit(event, payload) fn | emitting events the parent listens to via @event |
defineSlots<S>() | the live slots map, typed as S | branching 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('')
<divclass="">
<!-- Default slot -->
<slot></slot>
<!-- Named slots -->
<slotname=""></slot>
<slotname=""></slot>
<!-- Slots with fallback content -->
<slotname="">
<p>No content available</p>
</slot>
</div>
<!-- Usage -->
<Panel>
@slot('')
<h2>Panel Title</h2>
<p>Main content goes here</p>
@slot('')
<button>Close</button>
</Panel>
Events
Emit and handle component events:
@component(''
props: {
placeholder: 'Search...'
}
})
<divclass="">
<input
type="text"
x-placeholder="placeholder"
="handleInput"
/>
<button@click="handleClear">Clear</button>
</div>
function handleInput(e: Event) {
const value = (e.target as HTMLInputElement).value
$emit('search', value)
}
function handleClear() {
$emit('clear')
}
<!-- Usage -->
<SearchInput
="onSearch"
="onClear"
/>
Lifecycle Hooks
Use lifecycle hooks to run code at specific times:
@component('')
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
})
<divclass="">
@if(loading)
<loading-spinner />
@elseif(error)
<error-message:error="error" />
<data-display:items="data" />
</div>
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:navigateevent rather than relying on the component running again:<script client> const path = state(location.pathname) useEventListener('stx:navigate', () => path.set(location.pathname)) </script> -
onDestroyruns 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
useEventListenercalled insideonMountis removed when the component is destroyed, not at the next navigation. -
Teardown registered later, after an
awaitor in a timer, by a composable (or byonDestroyin 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('')
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}`)
})
<divclass="">
<button@click="decrement"></button>
<span>{{ count }}</span>
<button@click="increment"></button>
<p>Count is {{ isEven ? 'even' : 'odd' }}</p>
</div>
Advanced Features
Async Components
Load components asynchronously:
@component('')
<loading-spinner />
<error-message:error="error" />
<!-- Heavy component content -->
Error Boundaries
Handle component errors:
@component('')
<divclass="">
<h3>Something went wrong</h3>
<p>{{ error.message }}</p>
<button@click="retry">Retry</button>
</div>
<slot></slot>
Component Composition
Compose components together:
@component('')
interface Props {
userId: string
}
<divclass="">
<ErrorBoundary>
<AsyncComponent>
<UserInfo:id="userId" />
<UserStats:id="userId" />
<UserPosts:id="userId" />
</AsyncComponent>
</ErrorBoundary>
</div>
Best Practices
-
Component Design
- Keep components focused and single-purpose
- Use TypeScript interfaces for props
- Document component APIs
- Follow naming conventions
-
Performance
- Use async components for large components
- Implement proper cleanup in
onUnmounted - Optimize re-renders with
memo - Use lazy loading when appropriate
-
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
-
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
- Learn about Template Syntax
- Explore Directives
- Understand TypeScript Integration
- Check out Component Testing