Composables Reference

On this page 57

All composables are available as globals in <script> and <script client> blocks. They can also be imported from @stacksjs/stx for TypeScript type checking.

Reactivity

state

state<T>(initialValue: T): Signal<T>

Creates a reactive signal. The foundation of stx reactivity.

<script>
const count = state(0)
const user = state({ name: 'Alice', age: 30 })
const items = state<string[]>([])
</script>

<p x-text="count()"></p>

Signal interface:

MethodSignatureDescription
signal()() => TRead the current value
signal.set(value)(value: T) => voidSet a new value
signal.update(fn)(fn: (current: T) => T) => voidUpdate based on current value
signal.subscribe(cb)(cb: (value: T, prev: T) => void) => () => voidSubscribe to changes. Returns unsubscribe function
signal._isSignaltrue (readonly)Internal marker for type detection
<script>
const count = state(0)

count()              // Read: 0
count.set(5)         // Write: 5
count.update(n => n + 1)  // Update: 6

const unsub = count.subscribe((value, prev) => {
  console.log(`Changed from ${prev} to ${value}`)
})
unsub() // Stop listening
</script>

Signals use Object.is() for equality — setting the same value does not trigger subscribers or effects.

derived

derived<T>(compute: () => T, options?: { equals?: (previous: T, next: T) => boolean }): DerivedSignal<T>

Creates a read-only computed signal that recomputes when its dependencies change.

<script>
const firstName = state('John')
const lastName = state('Doe')
const fullName = derived(() => `${firstName()} ${lastName()}`)

const items = state([1, 2, 3, 4, 5])
const evenItems = derived(() => items().filter(n => n % 2 === 0))
</script>

<p x-text="fullName()"></p>
<p x-text="'Even count: ' + evenItems().length"></p>

DerivedSignal interface:

MethodSignatureDescription
signal()() => TRead the computed value
signal._isDerivedtrue (readonly)Internal marker

Derived signals are lazy — they only recompute when read after a dependency changes.

Without equals, every change upstream reaches what reads the derived, even when it computes the same value again. With equals, a change upstream recomputes it at once (while anything reads it) and its readers run only when equals(previous, next) is false:

<script>
const items = state([1, 2])
// Re-renders only when the count changes, not on every new array.
const count = derived(() => items().length, { equals: Object.is })
</script>

memo

memo<T>(compute: () => T, equals?: (previous: T, next: T) => boolean): DerivedSignal<T>

A derived value that reaches its readers only when it changes: Object.is, or the equals given. Short for derived(compute, { equals }).

<script>
const user = state({ id: 1, name: 'Ada' })
const userId = memo(() => user().id)

effect(() => loadProfile(userId())) // runs once
user.set({ id: 1, name: 'Ada L.' })  // same id: the effect does not run again
user.set({ id: 2, name: 'Grace' })   // runs again
</script>

effect

effect(fn: () => void | CleanupFn, options?: EffectOptions): CleanupFn

Creates a side effect that re-runs when its signal dependencies change.

<script>
const count = state(0)

// Basic effect
effect(() => {
  console.log(`Count is now: ${count()}`)
})

// Effect with cleanup
const stopEffect = effect(() => {
  const interval = setInterval(() => console.log(count()), 1000)
  return () => clearInterval(interval)
})

// Stop the effect manually
stopEffect()
</script>

EffectOptions:

OptionTypeDefaultDescription
immediatebooleantrueRun the effect immediately on creation
namestring—Name for debugging

The effect automatically tracks which signals are read during execution and re-runs when any of them change.

batch

batch(fn: () => void): void

Batches multiple signal updates so dependent effects run only once.

<script>
const firstName = state('John')
const lastName = state('Doe')

// Without batch: effect runs twice (once per set)
// With batch: effect runs once after both updates
batch(() => {
  firstName.set('Jane')
  lastName.set('Smith')
})
</script>

isSignal

isSignal(value: unknown): value is Signal<unknown>

Type guard to check if a value is a signal.

isDerived

isDerived(value: unknown): value is DerivedSignal<unknown>

Type guard to check if a value is a derived signal.

untrack

untrack<T>(value: T | Signal<T> | DerivedSignal<T>): T

Reads a signal (or derived) without subscribing the running effect to it. Anything that is not a signal is returned as it is. For several reads at once, use peek.

<script>
const count = state(0)
const step = state(1)

effect(() => {
  // Re-runs when count changes; a new step is used next time, without re-running.
  console.log(count() + untrack(step))
})
</script>

Before 0.2.575 untrack read the signal tracked, so an effect calling it still re-ran on every change.

peek

peek<T>(fn: () => T): T

Reads signals inside fn without tracking them as dependencies.

<script>
const count = state(0)
const other = state(0)

effect(() => {
  console.log(count()) // tracked — effect re-runs on count change
  const val = peek(() => other()) // NOT tracked
})
</script>

watch

watch<T>(
  source: Signal<T> | DerivedSignal<T> | (() => T) | object | Array<Signal<unknown> | (() => unknown)>,
  callback: (next: T, previous: T | undefined, onCleanup: (fn: () => void) => void) => void,
  options?: { immediate?: boolean, deep?: boolean, flush?: 'post' | 'pre' | 'sync', once?: boolean },
): () => void

Calls back with the new and the previous value when the source's value changes. The source is a signal, a derived, a getter, a reactive object, or an array of those (the callback then gets arrays). Returns a stop function. The same contract in a <script client> block and from import { watch } from '@stacksjs/stx'.

OptionDefaultDescription
immediatefalseAlso call back once at creation, with previous undefined
deepfalse (true for a reactive object)Compare structurally instead of with Object.is, so an equal copy is not a change
flush'post''post' calls back after the next render, 'pre' on a microtask, 'sync' inside the change. Changes before a deferred call land as one call with the latest value
oncefalseStop after the first call
<script>
const user = state({ id: 1, name: 'Ada' })

// Only when the id changes, not for every new user object.
watch(() => user().id, (id, previous) => loadProfile(id))

// Also at once, for the value it starts with.
watch(trainerId, id => loadAvailability(id), { immediate: true })

// Several sources.
watch([from, to], ([start, end]) => reload(start, end))
</script>

The callback is untracked: what it reads does not become something the watch follows. A callback that throws is reported and the watch keeps going.

Changed in 0.2.575

The client watch used to be an alias of $watch. With a getter it called back once with the getter itself and never again; with a signal it called back at creation, with no previous value, and again on every write, equal or not. It now matches the module watch (and Vue): no call at creation unless immediate, (next, previous), only on a change. A caller that relied on the call at creation passes { immediate: true }. $watch(deps, fn) keeps its old behaviour.

Lifecycle

onMount

onMount(fn: () => void | CleanupFn | Promise<void>): void

Runs after the component's DOM is ready. Can return a cleanup function.

<script>
onMount(() => {
  console.log('Component mounted')
  const handler = () => console.log('scrolled')
  window.addEventListener('scroll', handler)
  return () => window.removeEventListener('scroll', handler)
})

onMount(async () => {
  const data = await fetch('/api/data').then(r => r.json())
  items.set(data)
})
</script>

onDestroy

onDestroy(fn: () => void | Promise<void>): void

Runs when the component is removed from the DOM.

<script>
const ws = new WebSocket('wss://example.com')
onDestroy(() => ws.close())
</script>
navigate(url: string, forceReload?: boolean): void

Navigates to a URL. Uses the SPA router if available, otherwise falls back to window.location.href.

ParameterTypeDefaultDescription
urlstring—Destination URL
forceReloadbooleanfalseWhen true, performs a full page reload
<script>
function goToProfile(id) {
  navigate(`/users/${id}`)
}

function goExternal() {
  navigate('https://example.com', true) // full reload
}
</script>

goBack / goForward

goBack(): void
goForward(): void

History navigation shortcuts wrapping window.history.back() and window.history.forward().

useRoute

useRoute(): RouteInfo

Returns reactive route information.

<script>
const route = useRoute()
</script>

<p x-text="route.path"></p>

RouteInfo:

PropertyTypeDescription
pathstringwindow.location.pathname
fullPathstringpathname + search + hash
hashstringwindow.location.hash
queryRecord<string, string>Parsed search params
paramsRecord<string, string>Route params (from dynamic routes like /users/:id)

useSearchParams

useSearchParams(): SearchParamsRef

Reactive URL search parameters. Syncs with popstate and stx:navigate events.

<script>
const search = useSearchParams()

// Read
const page = search.get('page')

// Write (updates URL and state)
search.set('page', '2')
search.setAll({ page: '1', sort: 'name' })
</script>

SearchParamsRef:

Property/MethodTypeDescription
dataSignal<Record<string, string>>Reactive params signal
get(key)(key: string) => string | undefinedRead a param
has(key)(key: string) => booleanWhether the param is present
set(key, value, options?)(key, value, options?: { replace?: boolean }) => voidSet a param. Pushes a history entry unless replace
delete(key, options?)(key, options?: { replace?: boolean }) => voidRemove a param. Pushes unless replace
setAll(obj, options?)(obj, options?: { replace?: boolean }) => voidSet several params. Pushes unless replace

Consuming a one-shot param — an OAuth callback result, ?checkout=success, a flash token — needs { replace: true }. Under the default push, the URL still carrying the param stays in history, so Back replays the callback.

DOM

useRef

useRef(name: string): { current: HTMLElement | null, value: HTMLElement | null }

Gets a DOM element reference by its ref="name" attribute.

<input ref="searchInput" type="text" />

<script>
const input = useRef('searchInput')

onMount(() => {
  input.current.focus()
})
</script>

Both .current and .value return the same element (.value is an alias for compatibility).

useClickOutside

useClickOutside(target: string | HTMLElement, handler: (event: PointerEvent) => void): { remove: () => void }

Fires handler when a pointerdown occurs outside the target element.

<script>
useClickOutside('#dropdown', () => {
  isOpen.set(false)
})
</script>

Auto-cleans up on component destroy.

useEventListener

useEventListener(event: string, handler: (e: Event) => void, options?: EventListenerOptions): void

Adds an event listener that is automatically removed on component destroy.

<script>
useEventListener('resize', () => {
  width.set(window.innerWidth)
}, { target: window, passive: true })
</script>

Options:

OptionTypeDescription
targetEventTarget | stringTarget element or selector (default: window)
capturebooleanUse capture phase
passivebooleanPassive listener
oncebooleanFire once

Data Fetching

useFetch

useFetch(url: string | (() => string), options?: FetchOptions): FetchResult

Declarative data fetching with signals.

<script>
const { data, loading, error, refetch } = useFetch('/api/users')

// With reactive URL
const userId = state(1)
const { data: user } = useFetch(() => `/api/users/${userId()}`)

// With options
const { data: posts } = useFetch('/api/posts', {
  method: 'GET',
  headers: { Authorization: 'Bearer token' },
  transform: (result) => result.data,
  immediate: true,
})
</script>

FetchOptions:

OptionTypeDefaultDescription
methodstring'GET'HTTP method
headersRecord<string, string>{ 'Content-Type': 'application/json' }Request headers
bodyany—Request body (auto-stringified if object)
initialDataanynullInitial data before fetch completes
transform(result: any) => any—Transform response data
immediatebooleantrueFetch on mount
onError(error: Error) => void—Error callback

FetchResult:

PropertyTypeDescription
dataSignal<T | null>Response data
loadingSignal<boolean>First-load state. A background refetch leaves it alone
isFetchingSignal<boolean>True while any request is open, background ones included
errorSignal<string | null>Error message
refetch(options?: { background?: boolean }) => Promise<void>Re-fetch data
isLoadingboolean (getter)Convenience: loading()
hasErrorboolean (getter)Convenience: !!error()
isEmptyboolean (getter)Convenience: !loading() && !data()

When url is a function, it is watched with effect() — changing the signal it reads triggers a refetch.

Background refresh

refetch({ background: true }) updates data without touching loading and without clearing the current error, so a poll cannot flash a spinner over a populated view or blank a message that is still true. The error clears when the background request actually succeeds.

Bind isFetching when you want a subtle "refreshing…" indicator during a poll:

<script>
const { data, loading, isFetching, refetch } = useFetch('/api/rows')

onMount(() => {
  const id = setInterval(() => refetch({ background: true }), 60_000)
  onDestroy(() => clearInterval(id))
})
</script>

<Spinner :if="loading" />
<span :if="isFetching" class="text-xs opacity-60">refreshing…</span>
<ul :for="row in data()" :key="row.id"><li x-text="row.name"></li></ul>

useQuery

useQuery(url: string | (() => string), options?: QueryOptions): QueryResult

Advanced data fetching with caching, stale-while-revalidate, and auto-refetch.

<script>
const { data, loading, isStale, refetch, invalidate } = useQuery('/api/users', {
  staleTime: 30000,    // 30s before data is considered stale
  cacheTime: 300000,   // 5min cache lifetime
  refetchOnFocus: true,
  refetchInterval: 60000, // re-fetch every 60s
  transform: (res) => res.users,
  onSuccess: (data) => console.log('Loaded', data.length, 'users'),
})
</script>

QueryOptions (extends FetchOptions):

OptionTypeDefaultDescription
staleTimenumber0ms before cached data is stale
cacheTimenumber300000 (5 min)ms before cache entry is evicted
cacheKeystringURLCustom cache key
initialDataanynullInitial data
transform(result: any) => any—Transform response
refetchOnFocusbooleanfalseRefetch when tab regains focus
refetchIntervalnumber—Auto-refetch interval in ms
immediatebooleantrueFetch on mount
onSuccess(data: any) => void—Success callback
onError(error: Error) => void—Error callback
headersRecord<string, string>{ 'Content-Type': 'application/json' }Request headers

QueryResult:

PropertyTypeDescription
dataSignal<T | null>Response data
loadingSignal<boolean>First-load state. A background refetch leaves it alone
isFetchingSignal<boolean>True while any request is open, background ones included
errorSignal<string | null>Error message
isStaleSignal<boolean>Whether current data is stale
refetch(options?: { background?: boolean }) => Promise<void>Force re-fetch
invalidate(options?: { background?: boolean }) => Promise<void>Clear cache and re-fetch

refetchInterval and refetchOnFocus are background refreshes: they update data without touching loading, so a poll does not put a spinner over a view that already has data. Bind isFetching for a subtle in-flight indicator.

useMutation

useMutation(url: string | (() => string), options?: MutationOptions): MutationResult

Handles POST/PUT/DELETE operations with optimistic updates and query invalidation.

<script>
const { mutate, loading, error } = useMutation('/api/users', {
  method: 'POST',
  onSuccess: (data) => {
    navigate(`/users/${data.id}`)
  },
  invalidateQueries: ['/api/users'],
  optimisticData: (body) => ({ ...body, id: 'temp' }),
})

function createUser() {
  mutate({ name: 'Alice', email: 'alice@example.com' })
}
</script>

MutationOptions:

OptionTypeDefaultDescription
methodstring'POST'HTTP method
headersRecord<string, string>{ 'Content-Type': 'application/json' }Request headers
transform(result: any) => any—Transform response
onSuccess(data: any) => void—Success callback
onError(error: Error) => void—Error callback
invalidateQueriesstring[]—Cache keys to invalidate on success
optimisticDataany | (body: any) => any—Optimistic update data (rolled back on error)

MutationResult:

PropertyTypeDescription
dataSignal<T | null>Response data
loadingSignal<boolean>Loading state
errorSignal<string | null>Error message
mutate(body: any) => Promise<T>Execute the mutation
reset() => voidReset data, error, and loading state

cachedQuery

cachedQuery<T>(key: string, options?: CachedQueryOptions<T>): CachedQuery<T>

Data a screen shows at once from last time, refreshed behind, kept per signed-in account (setKeptScope). Calling it again with the same key returns the same query.

<script>
const calendar = cachedQuery('calendar', {
  url: () => '/api/calendar?month=' + month(),
  scope: () => auth.user()?.id,
  staleTime: 2 * 60_000,
  initial: {},
})
calendar.data()          // last time's calendar, at once
await calendar.load()    // fetched again when older than staleTime
await calendar.refresh() // a pull to refresh: past every cache
</script>
MemberDescription
data / loading / isFetching / error / updatedAtSignals
load(force?)Fetch when stale. Callers share one request for the same URL
refresh()Fetch now. Never joins a request already out: it waits for it and asks once more
set(value) / invalidate()A local edit, kept like an answer / count the data as stale

A request for another url() is never answered by one for the URL before, and an answer for a URL the query no longer reads is not shown. Writes waiting in the outbox with affects: [key or url] stay applied on top of data() until they reach the server.

sharedRequest

sharedRequest<K = string, T = unknown>(options?: { covers?: (running: K, wanted: K) => boolean }): {
  run: (key: K, start: () => Promise<T>) => Promise<T>
  fresh: (key: K, start: () => Promise<T>) => Promise<T>
  latest: () => () => boolean
  readonly busy: boolean
}

One request for every caller that wants the same answer: the in-flight bookkeeping a store otherwise writes by hand. cachedQuery and the @stacksjs/components chat are built on it.

MemberDescription
run(key, start)Join a request in flight whose key covers key (or a fresh rerun waiting to start), or start one
fresh(key, start)Never joins. If a request for key is out, waits for it to settle (it may predate a change just made) and starts once more; calls made meanwhile share that one rerun
latest()A checker that stays true until latest() is called again: replaces const mine = ++request counters
busyTrue while a request is out or a rerun is waiting
coversWhether a running key answers a wanted one (default Object.is), e.g. a month's request answering for one of its days
<script>
const list = sharedRequest()

function load() {
  // Every component that mounts at once shares one request.
  return list.run(url(), () => fetch(url()).then(r => r.json()))
}
async function save(row) {
  await fetch('/api/rows', { method: 'POST', body: JSON.stringify(row) })
  // A load already out was read before the save: wait for it, then ask again.
  rows.set(await list.fresh(url(), () => fetch(url()).then(r => r.json())))
}
async function search(q) {
  const isLatest = list.latest()
  const found = await list.run('/api/search?q=' + q, () => fetch('/api/search?q=' + q).then(r => r.json()))
  if (isLatest()) results.set(found) // an older search that lands last is dropped
}
</script>

Replacing hand-written in-flight code:

BeforeAfter
if (inflight) return inflight; inflight = start().finally(() => inflight = null)shared.run(key, start)
if (inflight) await inflight; return load()shared.fresh(key, start)
const mine = ++request; …; if (mine !== request) returnconst isLatest = shared.latest(); …; if (!isLatest()) return
if (inflight) { pending = true; return }if (shared.busy) { pending = true; return }

useAsync

useAsync(fn: (...args: any[]) => Promise<T>, options?: { immediate?: boolean }): AsyncResult

Wraps any async function with loading/error/data tracking.

<script>
const { execute, data, isLoading, error } = useAsync(async (query) => {
  const res = await fetch(`/api/search?q=${query}`)
  return res.json()
})

function search(query) {
  execute(query)
}
</script>

AsyncResult:

PropertyTypeDescription
state'idle' | 'loading' | 'success' | 'error' (getter)Current state
isLoadingboolean (getter)Whether loading
dataT | null (getter)Result data
errorError | null (getter)Error if failed
execute(...args) => Promise<T | null>Run the async function
subscribe(fn) => () => voidSubscribe to state changes

Head & SEO

useHead

useHead(config: HeadConfig): void

Manages document head tags at runtime. Works on page load and SPA navigation.

<script>
useHead({
  title: 'My Page',
  meta: [
    { name: 'description', content: 'Page description' },
    { property: 'og:title', content: 'My Page' },
  ],
  link: [
    { rel: 'canonical', href: 'https://example.com/page' },
    { rel: 'stylesheet', href: '/styles/extra.css' },
  ],
  script: [
    { src: 'https://example.com/analytics.js', async: true },
  ],
  bodyAttrs: { class: 'dark-mode' },
  htmlAttrs: { lang: 'en' },
})
</script>

HeadConfig:

FieldTypeDescription
titlestringSets document.title
metaArray<{ name?: string, property?: string, content: string }>Upserts <meta> tags
linkArray<Record<string, string>>Appends <link> tags (deduped by rel+href)
scriptArray<{ src?: string, innerHTML?: string, async?: boolean, defer?: boolean }>Appends <script> tags
bodyAttrs{ class?: string }Adds classes to <body>
htmlAttrsRecord<string, string>Sets attributes on <html>. class merges (it never clobbers the pre-paint color-mode class); everything else is set outright

Called from <script server>, htmlAttrs also lands in the server-rendered <html> tag — the way to put a class on the root element from a layout, e.g. for design tokens scoped as html.marketing { --bg: … }. The router reconciles those across SPA navigation. See Document Shell.

useSeoMeta

useSeoMeta(config: SeoMetaConfig): void

Convenience wrapper for common SEO meta tags. Internally calls useHead().

<script>
useSeoMeta({
  title: 'Product Page',
  description: 'The best product ever made.',
  ogTitle: 'Product Page',
  ogDescription: 'The best product ever made.',
  ogImage: 'https://example.com/og-image.jpg',
  ogType: 'website',
  ogUrl: 'https://example.com/product',
  twitterCard: 'summary_large_image',
  twitterTitle: 'Product Page',
  twitterDescription: 'The best product ever made.',
  twitterImage: 'https://example.com/twitter-image.jpg',
  robots: 'index, follow',
  canonical: 'https://example.com/product',
})
</script>

SeoMetaConfig:

FieldMeta tagDescription
title<meta name="title"> + document.titlePage title
description<meta name="description">Page description
ogTitle<meta property="og:title">Open Graph title
ogDescription<meta property="og:description">Open Graph description
ogImage<meta property="og:image">Open Graph image URL
ogType<meta property="og:type">Open Graph type
ogUrl<meta property="og:url">Open Graph URL
twitterCard<meta name="twitter:card">Twitter card type
twitterTitle<meta name="twitter:title">Twitter title
twitterDescription<meta name="twitter:description">Twitter description
twitterImage<meta name="twitter:image">Twitter image URL
robots<meta name="robots">Robots directive
canonical<meta property="og:url">Canonical URL

Timers & Utilities

useDebounce

useDebounce(fn: (...args: any[]) => void, delay?: number): DebouncedFn

Returns a debounced version of fn. Default delay: 250ms. Auto-cleans up on destroy.

<script>
const debouncedSearch = useDebounce((query) => {
  fetch(`/api/search?q=${query}`)
}, 300)
</script>

<input @input="debouncedSearch($event.target.value)" />

DebouncedFn:

MethodDescription
debounced(...args)Call the debounced function
debounced.cancel()Cancel pending execution
debounced.flush()Execute immediately if pending
debounced.pending()Returns true if a call is pending

useDebouncedValue

useDebouncedValue(getter: () => T, delay?: number): { value: T, subscribe: (fn) => () => void }

Returns a debounced snapshot of a value. Default delay: 250ms.

<script>
const query = state('')
const debouncedQuery = useDebouncedValue(() => query(), 300)

debouncedQuery.subscribe((val) => {
  console.log('Debounced:', val)
})
</script>

useThrottle

useThrottle(fn: (...args: any[]) => void, limit?: number): ThrottledFn

Returns a throttled version of fn. Default limit: 250ms. Auto-cleans up on destroy.

<script>
const throttledScroll = useThrottle(() => {
  console.log('Scroll position:', window.scrollY)
}, 100)

useEventListener('scroll', throttledScroll, { passive: true })
</script>

ThrottledFn:

MethodDescription
throttled(...args)Call the throttled function
throttled.cancel()Cancel pending trailing call

useInterval

useInterval(interval?: number, options?: IntervalOptions): IntervalHandle
useInterval(fn: (count: number) => void, ms?: number, options?: IntervalOptions): IntervalHandle
useInterval(fn: (count: number) => void, options?: IntervalOptions): IntervalHandle

Starts a repeating interval. Default: 1000ms. Auto-cleans up on destroy.

Both call forms work: pass a delay for a bare counter, or a callback to run something on each tick.

<script>
const timer = useInterval(1000)
timer.subscribe((count) => {
  console.log('Tick:', count)
})

// Callback form
const poll = useInterval(() => refresh(), 60_000)
poll.pause()
</script>

There is no start() / stop() / isActive — resume() and pause() are the names. A declaration of that shape shipped for a while with nothing behind it, so calls against it typechecked clean and threw on mount.

counter is a plain number, not a signal, so an effect cannot track it. Use subscribe to react to a tick.

IntervalOptions:

OptionTypeDefaultDescription
immediatebooleanfalseTick once on start/resume instead of waiting out the first interval
enabledboolean | (() => boolean)trueGate ticks; re-evaluated on every tick when it's a function
whileVisiblebooleanfalseSkip ticks while document.hidden

IntervalHandle:

Property/MethodTypeDescription
counternumber (getter)Number of ticks
pause()() => voidPause the interval
resume()() => voidResume the interval
reset()() => voidReset counter and restart
subscribe(fn)(fn: (count) => void) => () => voidListen to ticks

useTimeout

useTimeout(callback: () => void, delay?: number): TimeoutHandle

Executes callback after delay ms. Default: 1000ms. Auto-cleans up on destroy.

<script>
const { isPending, stop } = useTimeout(() => {
  showBanner.set(false)
}, 5000)
</script>

TimeoutHandle:

Property/MethodTypeDescription
isPendingboolean (getter)Whether the timeout is still waiting
start()() => voidRestart the timeout
stop()() => voidCancel the timeout
subscribe(fn)(fn: (pending) => void) => () => voidListen to pending state changes

useToggle

useToggle(initial?: boolean): [ref, toggle, set]

Simple boolean toggle.

<script>
const [isOpen, toggle, setOpen] = useToggle(false)
</script>

<button @click="toggle()">Toggle</button>
<div x-show="isOpen.value">Content</div>

Returns:

IndexTypeDescription
[0] ref{ value: boolean, subscribe: (fn) => () => void }Current value
[1] toggle() => voidToggle the value
[2] set(value: boolean) => voidSet explicitly

useCounter

useCounter(initial?: number, options?: { min?: number, max?: number }): CounterHandle

Numeric counter with bounds.

<script>
const counter = useCounter(0, { min: 0, max: 100 })
</script>

<button @click="counter.dec()">-</button>
<span x-text="counter.count"></span>
<button @click="counter.inc()">+</button>

CounterHandle:

Property/MethodTypeDescription
countnumber (getter)Current count
inc(step?)(step?: number) => voidIncrement (default step: 1)
dec(step?)(step?: number) => voidDecrement (default step: 1)
set(value)(value: number) => voidSet to specific value (clamped)
reset()() => voidReset to initial value
subscribe(fn)(fn: (count) => void) => () => voidListen to changes

Storage & Persistence

useLocalStorage

useLocalStorage<T>(key: string, defaultValue: T): Signal<T>

Returns a signal backed by localStorage. Changes are persisted automatically. Syncs across tabs via the storage event.

<script>
const theme = useLocalStorage('theme', 'light')

// Read
console.log(theme()) // 'light' (or stored value)

// Write (persists to localStorage)
theme.set('dark')
</script>

useSessionStorage

useSessionStorage<T>(key: string, defaultValue: T): Signal<T>

Same shape as useLocalStorage, but backed by sessionStorage — values live only for the duration of the browser tab/session. Cross-tab sync via the storage event is filtered to the sessionStorage storage area so a localStorage write in another tab won't trip the signal.

<script>
const draftCart = useSessionStorage('draft-cart', [])

draftCart.set([...draftCart(), item])
</script>

Both return the same signal whether they come from the bare global in a <script client> block, import … from '@stacksjs/stx', or '@stacksjs/stx/composables'.

Changed in #1797. The /composables subpath used to return a StorageRef (.value, .get(), .remove(), .subscribe()) while the runtime returned a signal, so .value worked or silently yielded undefined depending on which entry point an import resolved to. Migration is narrow — .value reads, .value = x writes and .set() all still work, because Signal carries a Vue-compatible .value accessor. Only .get() → s() and .remove() need changing.

useStorage

useStorage<T>(key: string, defaultValue: T, options?: UseStorageOptions<T>): StorageRef<T>

The richer object API, unchanged: .value, .get(), .set(), .remove(), .subscribe(), plus storage: 'local' | 'session', a custom serializer, and mergeDefaults. Reach for it when you need remove(), a non-JSON encoding, or subscription with the previous value; otherwise prefer the signals above.

const cart = useStorage('cart', [], { storage: 'session', mergeDefaults: true })
cart.subscribe((next, prev) => sync(next, prev))
cart.remove()

useCookie

useCookie(name: string, options?: UseCookieOptions): Signal<string>

Reactive string-valued cookie binding. The signal reads the cookie at construction and writes back on every .set(value). Setting .set('') deletes the cookie (Max-Age=0).

Declaring a cookie never writes one. Construction is a pure read, so a view that only needs the value can say useCookie('auth-token') with no options and cannot disturb the attributes another declaration set. Before this, constructing re-serialised the cookie from whatever options the reader passed — so a read-only declaration with no maxAge silently turned a 30-day cookie into a session cookie that died at browser close.

The corollary: a defaultValue does not reach document.cookie until something writes. If you want it persisted, write it: const c = useCookie(n, { … }); if (!c()) c.set(fallback).

Options:

OptionTypeDefaultNotes
defaultValuestring''Returned when the cookie isn't present. Not written until a .set()
pathstring'/'Cookie path attribute
domainstring(none)Cookie domain attribute
maxAgenumber(none)Seconds; sets Max-Age=N
expiresDate | number | string(none)Sets Expires=... (used when maxAge not provided). A number is seconds from now; a string is anything Date can parse
sameSite'Strict' | 'Lax' | 'None''Lax'SameSite attribute
securebooleanauto on HTTPSSets the Secure flag
encode / decode(string) => stringencodeURIComponent/decodeURIComponentCustom serialization
<script>
const token = useCookie('auth-token', {
  maxAge: 60 * 60 * 24 * 30, // 30 days
  sameSite: 'Lax',
})

// Read
if (!token()) navigate('/login', true)

// Write (writes `auth-token=<encoded>; Path=/; Max-Age=2592000; SameSite=Lax`)
token.set(jwt)

// Delete
token.set('')
</script>

Cookies don't fire a storage event, so cross-tab updates aren't auto-reflected. If you need that, poll on visibilitychange in user-land.

useReactiveProp

useReactiveProp<T>(
  name: string,
  defaultValue: T,
  options?: { parse?: (v: string | null) => T },
): Signal<T>

Used inside a component's <script client> block to bridge a parent's clientReactive attribute (e.g. <Dialog :open="modalOpen()">) into the component's local state. Returns a signal whose value tracks the named attribute on the component's root element — when the parent's expression changes and the runtime updates the attribute, a MutationObserver forwards the change into this signal.

<!-- Inside Dialog.stx -->
<script client>
const isOpen = useReactiveProp('open', {{ open }})

function close() {
  isOpen.set(false)
  emit('close')
}
</script>

<div :show="isOpen()" role="dialog">
  <slot />
</div>

Default parse heuristic (override via options.parse for typed props):

Attribute valueReturned
"" or "true"true
"false"false
Numeric stringNumber(v)
Otherwisethe original string
<!-- Typed: parse JSON for an object-valued prop -->
const items = useReactiveProp('items', [], { parse: (v) => JSON.parse(v || '[]') })

<!-- Typed: keep value as string even when it parses as boolean -->
const role = useReactiveProp('role', '', { parse: (v) => v == null ? '' : String(v) })

One-way (parent → child). For two-way binding, the component still emits events (@input / @change / @close) and the parent updates its source signal.

Color Mode

useColorMode

useColorMode(options?: ColorModeOptions): ColorModeHandle

Manages light/dark/auto color mode with persistence, system preference detection, and cross-tab sync.

<script>
const colorMode = useColorMode()
</script>

<button @click="colorMode.toggle()">
  <span x-text="colorMode.isDark ? 'Light mode' : 'Dark mode'"></span>
</button>

ColorModeOptions:

OptionTypeDefaultDescription
storageKeystring'stx-color-mode'localStorage key
initialMode'light' | 'dark' | 'auto' | 'system''auto'Initial mode. 'system' is an alias for 'auto'
darkClassstring | null'dark'Class added to <html> when dark. null opts out
attributestring | nullnullAttribute set to the resolved mode, e.g. data-theme. Applied alongside darkClass
autoValue'auto' | 'system'stored value, else 'auto'Spelling written back for "follow the system"
disableTransitionsbooleantrueDisable transitions during toggle

attribute and darkClass are complements, not alternatives — the class drives a utility framework's dark: variants while the attribute drives everything else, and apps generally need both. Set darkClass: null for attribute-only.

Both 'auto' and 'system' are accepted wherever a preference is read (storage, cross-tab sync, set()). Whichever spelling is already in storage is the one written back, so an app with its own pre-paint script keeps reading a value it recognises. A stored value that isn't a preference at all is left untouched until the user makes an explicit choice — initialisation never writes.

ColorModeHandle:

Property/MethodTypeDescription
mode'light' | 'dark' (getter)Resolved mode
preference'light' | 'dark' | 'auto' (getter)User preference
isDarkboolean (getter)Whether dark mode is active
set(mode)(mode: 'light' | 'dark' | 'auto') => voidSet mode
toggle()() => voidToggle between light and dark
subscribe(fn)(fn: (mode, preference) => void) => () => voidListen to changes

Applying the theme before first paint

useColorMode runs as part of the client runtime — it reads storage after the bundle has loaded and hydration has begun. That is too late to decide the theme: every cold load paints the default theme first and then corrects it, so any user whose stored preference differs from the default sees a flash.

Declare app.colorMode in stx.config.ts and stx inlines a small render-blocking script at the top of <head>, above the stylesheets, which stamps the root element before the browser paints:

// stx.config.ts
export default {
  app: {
    colorMode: { storageKey: 'theme', attribute: 'data-theme' },
  },
}

It takes the same options as the composable, and publishes them to the client — so a bare useColorMode() picks up the configured storage key and attribute instead of repeating them at the call site:

<script>
// Already agrees with what the boot script applied pre-paint.
const colorMode = useColorMode()
</script>

This replaces the hand-written theme-bootstrap snippet apps otherwise inline in their layout, and removes the risk of that snippet drifting from the composable's configuration.

Notes:

  • The boot script mirrors useColorMode exactly — both apply the class and the attribute, both accept auto and system, and the script publishes the spelling it found so the composable writes the same one back. Mirroring matters more than being lenient: if the two disagreed, hydration would undo what the boot script applied and the flash would come back.
  • Storage failures degrade to initialMode rather than throwing. The script sits above the application shell, where an uncaught error would block the rest of the document.
  • Injection is idempotent and applies to auto-generated shells, layouts that render their own <head>, and statically built pages alike. Pages with no <head> (SPA fragments) are skipped.

useDark

useDark(options?: ColorModeOptions): DarkHandle

Simplified dark mode toggle. Wraps useColorMode().

<script>
const dark = useDark()
</script>

<button @click="dark.toggle()">Toggle dark mode</button>

DarkHandle:

Property/MethodTypeDescription
isDarkboolean (getter)Whether dark mode is active
toggle()() => voidToggle dark mode
set(dark)(dark: boolean) => voidSet dark mode on/off
subscribe(fn)(fn: (isDark) => void) => () => voidListen to changes

Communication

useWebSocket

useWebSocket(url: string, options?: WebSocketOptions): WebSocketHandle

Reactive WebSocket connection with auto-reconnect and channel subscriptions.

<script>
const { status, lastMessage, send, subscribe } = useWebSocket('wss://example.com/ws', {
  reconnect: true,
  maxReconnects: 10,
})

// Send data
send({ type: 'ping' })

// Channel-based subscriptions
const chat = subscribe('chat-room')
chat.listen('message', (data) => {
  messages.update(arr => [...arr, data])
})
</script>

WebSocketOptions:

OptionTypeDefaultDescription
immediatebooleantrueConnect on creation
reconnectbooleantrueAuto-reconnect on close
maxReconnectsnumber10Max reconnection attempts
reconnectDelaynumber1000Base delay between reconnects (ms)
onOpen(ws: WebSocket) => void—Connection opened callback
onMessage(data: any, event: MessageEvent) => void—Message received callback
onError(event: Event) => void—Error callback
onClose(event: CloseEvent) => void—Connection closed callback

WebSocketHandle:

Property/MethodTypeDescription
wsSignal<WebSocket | null>WebSocket instance
statusSignal<'CONNECTING' | 'OPEN' | 'CLOSED'>Connection status
lastMessageSignal<any>Last received message (auto-parsed JSON)
errorSignal<Event | null>Last error event
send(data)(data: any) => voidSend data (auto-stringified)
close()() => voidClose connection
connect()() => voidOpen/reopen connection
subscribe(channel)(channel: string) => ChannelHandleSubscribe to a channel

ChannelHandle:

MethodDescription
listen(event, handler)Listen for events on the channel. Returns this for chaining
leave()Unsubscribe from the channel

Reconnection uses exponential backoff: delay * min(attempt, 5). Auto-cleans up on component destroy.

Dependency Injection

provide

provide(name: string, value: any): void

Makes a value globally accessible by name. Uses window[name] under the hood.

<script>
// Provider component
provide('Modal', ModalComponent)
provide('theme', { primary: '#6366f1' })
</script>
<script>
// Consumer component — access the provided value directly
const theme = window.theme
</script>

Cross-References

  • defineStore / useStore — See Stores Reference for full store documentation
  • Vue compatibility aliases: ref = state, computed = derived, watchEffect = effect; reactive is a per-property proxy and watch is described above