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>
<px-text=""></p>
Signal interface:
| Method | Signature | Description |
|---|---|---|
signal() | () => T | Read the current value |
signal.set(value) | (value: T) => void | Set a new value |
signal.update(fn) | (fn: (current: T) => T) => void | Update based on current value |
signal.subscribe(cb) | (cb: (value: T, prev: T) => void) => () => void | Subscribe to changes. Returns unsubscribe function |
signal._isSignal | true (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>
<px-text=""></p>
<px-text=""></p>
DerivedSignal interface:
| Method | Signature | Description |
|---|---|---|
signal() | () => T | Read the computed value |
signal._isDerived | true (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:
| Option | Type | Default | Description |
|---|---|---|---|
immediate | boolean | true | Run the effect immediately on creation |
name | string | — | 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'.
| Option | Default | Description |
|---|---|---|
immediate | false | Also call back once at creation, with previous undefined |
deep | false (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 |
once | false | Stop 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>
Navigation
navigate
navigate(url: string, forceReload?: boolean): void
Navigates to a URL. Uses the SPA router if available, otherwise falls back to window.location.href.
| Parameter | Type | Default | Description |
|---|---|---|---|
url | string | — | Destination URL |
forceReload | boolean | false | When 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>
<px-text=""></p>
RouteInfo:
| Property | Type | Description |
|---|---|---|
path | string | window.location.pathname |
fullPath | string | pathname + search + hash |
hash | string | window.location.hash |
query | Record<string, string> | Parsed search params |
params | Record<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/Method | Type | Description |
|---|---|---|
data | Signal<Record<string, string>> | Reactive params signal |
get(key) | (key: string) => string | undefined | Read a param |
has(key) | (key: string) => boolean | Whether the param is present |
set(key, value, options?) | (key, value, options?: { replace?: boolean }) => void | Set a param. Pushes a history entry unless replace |
delete(key, options?) | (key, options?: { replace?: boolean }) => void | Remove a param. Pushes unless replace |
setAll(obj, options?) | (obj, options?: { replace?: boolean }) => void | Set 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.
<inputref=""type="" />
<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:
| Option | Type | Description |
|---|---|---|
target | EventTarget | string | Target element or selector (default: window) |
capture | boolean | Use capture phase |
passive | boolean | Passive listener |
once | boolean | Fire 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:
| Option | Type | Default | Description |
|---|---|---|---|
method | string | 'GET' | HTTP method |
headers | Record<string, string> | { 'Content-Type': 'application/json' } | Request headers |
body | any | — | Request body (auto-stringified if object) |
initialData | any | null | Initial data before fetch completes |
transform | (result: any) => any | — | Transform response data |
immediate | boolean | true | Fetch on mount |
onError | (error: Error) => void | — | Error callback |
FetchResult:
| Property | Type | Description |
|---|---|---|
data | Signal<T | null> | Response data |
loading | Signal<boolean> | First-load state. A background refetch leaves it alone |
isFetching | Signal<boolean> | True while any request is open, background ones included |
error | Signal<string | null> | Error message |
refetch | (options?: { background?: boolean }) => Promise<void> | Re-fetch data |
isLoading | boolean (getter) | Convenience: loading() |
hasError | boolean (getter) | Convenience: !!error() |
isEmpty | boolean (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>
<Spinnerif="" />
<spanif=""class="">refreshing</span>
<ulfor=""key=""><lix-text=""></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):
| Option | Type | Default | Description |
|---|---|---|---|
staleTime | number | 0 | ms before cached data is stale |
cacheTime | number | 300000 (5 min) | ms before cache entry is evicted |
cacheKey | string | URL | Custom cache key |
initialData | any | null | Initial data |
transform | (result: any) => any | — | Transform response |
refetchOnFocus | boolean | false | Refetch when tab regains focus |
refetchInterval | number | — | Auto-refetch interval in ms |
immediate | boolean | true | Fetch on mount |
onSuccess | (data: any) => void | — | Success callback |
onError | (error: Error) => void | — | Error callback |
headers | Record<string, string> | { 'Content-Type': 'application/json' } | Request headers |
QueryResult:
| Property | Type | Description |
|---|---|---|
data | Signal<T | null> | Response data |
loading | Signal<boolean> | First-load state. A background refetch leaves it alone |
isFetching | Signal<boolean> | True while any request is open, background ones included |
error | Signal<string | null> | Error message |
isStale | Signal<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:
| Option | Type | Default | Description |
|---|---|---|---|
method | string | 'POST' | HTTP method |
headers | Record<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 |
invalidateQueries | string[] | — | Cache keys to invalidate on success |
optimisticData | any | (body: any) => any | — | Optimistic update data (rolled back on error) |
MutationResult:
| Property | Type | Description |
|---|---|---|
data | Signal<T | null> | Response data |
loading | Signal<boolean> | Loading state |
error | Signal<string | null> | Error message |
mutate | (body: any) => Promise<T> | Execute the mutation |
reset | () => void | Reset 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>
| Member | Description |
|---|---|
data / loading / isFetching / error / updatedAt | Signals |
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.
| Member | Description |
|---|---|
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 |
busy | True while a request is out or a rerun is waiting |
covers | Whether 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:
| Before | After |
|---|---|
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) return | const 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:
| Property | Type | Description |
|---|---|---|
state | 'idle' | 'loading' | 'success' | 'error' (getter) | Current state |
isLoading | boolean (getter) | Whether loading |
data | T | null (getter) | Result data |
error | Error | null (getter) | Error if failed |
execute | (...args) => Promise<T | null> | Run the async function |
subscribe | (fn) => () => void | Subscribe 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:
| Field | Type | Description |
|---|---|---|
title | string | Sets document.title |
meta | Array<{ name?: string, property?: string, content: string }> | Upserts <meta> tags |
link | Array<Record<string, string>> | Appends <link> tags (deduped by rel+href) |
script | Array<{ src?: string, innerHTML?: string, async?: boolean, defer?: boolean }> | Appends <script> tags |
bodyAttrs | { class?: string } | Adds classes to <body> |
htmlAttrs | Record<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:
| Field | Meta tag | Description |
|---|---|---|
title | <meta name="title"> + document.title | Page 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>
<inputinput="" />
DebouncedFn:
| Method | Description |
|---|---|
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:
| Method | Description |
|---|---|
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:
| Option | Type | Default | Description |
|---|---|---|---|
immediate | boolean | false | Tick once on start/resume instead of waiting out the first interval |
enabled | boolean | (() => boolean) | true | Gate ticks; re-evaluated on every tick when it's a function |
whileVisible | boolean | false | Skip ticks while document.hidden |
IntervalHandle:
| Property/Method | Type | Description |
|---|---|---|
counter | number (getter) | Number of ticks |
pause() | () => void | Pause the interval |
resume() | () => void | Resume the interval |
reset() | () => void | Reset counter and restart |
subscribe(fn) | (fn: (count) => void) => () => void | Listen 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/Method | Type | Description |
|---|---|---|
isPending | boolean (getter) | Whether the timeout is still waiting |
start() | () => void | Restart the timeout |
stop() | () => void | Cancel the timeout |
subscribe(fn) | (fn: (pending) => void) => () => void | Listen to pending state changes |
useToggle
useToggle(initial?: boolean): [ref, toggle, set]
Simple boolean toggle.
<script
const [isOpen, toggle, setOpen] = useToggle(false)
</script>
<buttonclick="">Toggle</button>
<divx-show="">Content</div>
Returns:
| Index | Type | Description |
|---|---|---|
[0] ref | { value: boolean, subscribe: (fn) => () => void } | Current value |
[1] toggle | () => void | Toggle the value |
[2] set | (value: boolean) => void | Set 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>
<buttonclick=""></button>
<spanx-text=""></span>
<buttonclick=""></button>
CounterHandle:
| Property/Method | Type | Description |
|---|---|---|
count | number (getter) | Current count |
inc(step?) | (step?: number) => void | Increment (default step: 1) |
dec(step?) | (step?: number) => void | Decrement (default step: 1) |
set(value) | (value: number) => void | Set to specific value (clamped) |
reset() | () => void | Reset to initial value |
subscribe(fn) | (fn: (count) => void) => () => void | Listen 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
/composablessubpath used to return aStorageRef(.value,.get(),.remove(),.subscribe()) while the runtime returned a signal, so.valueworked or silently yieldedundefineddepending on which entry point an import resolved to. Migration is narrow —.valuereads,.value = xwrites and.set()all still work, becauseSignalcarries a Vue-compatible.valueaccessor. 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:
| Option | Type | Default | Notes |
|---|---|---|---|
defaultValue | string | '' | Returned when the cookie isn't present. Not written until a .set() |
path | string | '/' | Cookie path attribute |
domain | string | (none) | Cookie domain attribute |
maxAge | number | (none) | Seconds; sets Max-Age=N |
expires | Date | 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 |
secure | boolean | auto on HTTPS | Sets the Secure flag |
encode / decode | (string) => string | encodeURIComponent/decodeURIComponent | Custom 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 -->
<scriptclient
const isOpen = useReactiveProp('open', {{ open }})
function close() {
isOpen.set(false)
emit('close')
}
</script>
<divshow=""role="">
<slot />
</div>
Default parse heuristic (override via options.parse for typed props):
| Attribute value | Returned |
|---|---|
"" or "true" | true |
"false" | false |
| Numeric string | Number(v) |
| Otherwise | the 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>
<buttonclick="">
<spanx-text=""></span>
</button>
ColorModeOptions:
| Option | Type | Default | Description |
|---|---|---|---|
storageKey | string | 'stx-color-mode' | localStorage key |
initialMode | 'light' | 'dark' | 'auto' | 'system' | 'auto' | Initial mode. 'system' is an alias for 'auto' |
darkClass | string | null | 'dark' | Class added to <html> when dark. null opts out |
attribute | string | null | null | Attribute 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" |
disableTransitions | boolean | true | Disable 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/Method | Type | Description |
|---|---|---|
mode | 'light' | 'dark' (getter) | Resolved mode |
preference | 'light' | 'dark' | 'auto' (getter) | User preference |
isDark | boolean (getter) | Whether dark mode is active |
set(mode) | (mode: 'light' | 'dark' | 'auto') => void | Set mode |
toggle() | () => void | Toggle between light and dark |
subscribe(fn) | (fn: (mode, preference) => void) => () => void | Listen 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
useColorModeexactly — both apply the class and the attribute, both acceptautoandsystem, 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
initialModerather 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>
<buttonclick="">Toggle dark mode</button>
DarkHandle:
| Property/Method | Type | Description |
|---|---|---|
isDark | boolean (getter) | Whether dark mode is active |
toggle() | () => void | Toggle dark mode |
set(dark) | (dark: boolean) => void | Set dark mode on/off |
subscribe(fn) | (fn: (isDark) => void) => () => void | Listen 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:
| Option | Type | Default | Description |
|---|---|---|---|
immediate | boolean | true | Connect on creation |
reconnect | boolean | true | Auto-reconnect on close |
maxReconnects | number | 10 | Max reconnection attempts |
reconnectDelay | number | 1000 | Base 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/Method | Type | Description |
|---|---|---|
ws | Signal<WebSocket | null> | WebSocket instance |
status | Signal<'CONNECTING' | 'OPEN' | 'CLOSED'> | Connection status |
lastMessage | Signal<any> | Last received message (auto-parsed JSON) |
error | Signal<Event | null> | Last error event |
send(data) | (data: any) => void | Send data (auto-stringified) |
close() | () => void | Close connection |
connect() | () => void | Open/reopen connection |
subscribe(channel) | (channel: string) => ChannelHandle | Subscribe to a channel |
ChannelHandle:
| Method | Description |
|---|---|
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;reactiveis a per-property proxy andwatchis described above