Composables
On this page 20
Composables are reusable functions that encapsulate logic. They are the primary way to share behavior across pages and components in stx. If you find yourself writing the same logic in multiple places, extract it into a composable.
Convention
- Put composables in the
composables/(orfunctions/) directory — stx discovers both - Name them with the
useprefix:useAuth(),useDark(),useFetch() - Export a single function per file
- Composables can use signals, lifecycle hooks, and other composables
composables/
useAuth.ts
useDark.ts
useDebounce.ts
useFetch.ts
useLocalStorage.ts
useMediaQuery.ts
Every .ts file in that directory (except index.ts, types.ts and .d.ts)
is bundled and injected ahead of your page scripts. Override the location with
composablesDir in stx.config.ts:
export default {
composablesDir: 'src/composables',
}
All composables share one scope, so one can call another directly — no import needed between them.
Using a composable in a page
Just call it — no import:
<scriptclient
const { count, doubled, increment } = useCounter(10)
</script>
<px-text=""></p>
<buttonclick="">+1</button>
Every composable is published as a global before your page scripts run, so a
bare call binds. Because count and increment are then top-level names in the
page's own scope, template directives (@click, x-text, :show) resolve them.
Name collisions
If your composable shares a name with a built-in (useFetch, useToggle,
useCounter, …) or with an existing window property (name, status), stx
does not overwrite it — the bare identifier keeps meaning the original. You
get a console warning saying so, rather than a silent surprise.
Rename yours, or reach for it explicitly:
<scriptclient
import { useFetch } from '@composables'
</script>
That destructures from window.__composables after the built-ins are in scope,
so your version wins.
Basic Composable
A composable is just a function that returns reactive state and methods:
// functions/useCounter.ts
import { state, derived } from '@stacksjs/stx'
export function useCounter(initial = 0) {
const count = state(initial)
const doubled = derived(() => count() * 2)
const isPositive = derived(() => count() > 0)
function increment() {
count.update(n => n + 1)
}
function decrement() {
count.update(n => n - 1)
}
function reset() {
count.set(initial)
}
return { count, doubled, isPositive, increment, decrement, reset }
}
Use it in a page:
<scriptclient
const { count, doubled, increment, decrement, reset } = useCounter(10)
</script>
<div>
<p>Count: <spanx-text=""></span></p>
<p>Doubled: <spanx-text=""></span></p>
<buttonclick=""></button>
<buttonclick=""></button>
<buttonclick="">Reset</button>
</div>
Real-World Examples
useAuth
// functions/useAuth.ts
import { state, derived } from '@stacksjs/stx'
export function useAuth() {
const user = state(null)
const token = state(null)
const loading = state(false)
const isAuthenticated = derived(() => !!token())
async function login(email: string, password: string) {
loading.set(true)
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) throw new Error('Login failed')
const data = await res.json()
token.set(data.token)
user.set(data.user)
navigate('/dashboard')
} catch (e) {
throw e
} finally {
loading.set(false)
}
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST' })
token.set(null)
user.set(null)
navigate('/login')
}
async function checkAuth() {
const stored = localStorage.getItem('auth_token')
if (!stored) return
token.set(stored)
try {
const res = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${stored}` },
})
if (res.ok) {
user.set(await res.json())
} else {
token.set(null)
}
} catch {
token.set(null)
}
}
return { user, token, loading, isAuthenticated, login, logout, checkAuth }
}
useDark
// functions/useDark.ts
import { state, effect } from '@stacksjs/stx'
export function useDark() {
const isDark = state(false)
// Initialize from system preference or stored preference
function init() {
const stored = localStorage.getItem('theme')
if (stored) {
isDark.set(stored === 'dark')
} else {
isDark.set(window.matchMedia('(prefers-color-scheme: dark)').matches)
}
}
// Sync to DOM and localStorage
effect(() => {
const dark = isDark()
document.documentElement.classList.toggle('dark', dark)
localStorage.setItem('theme', dark ? 'dark' : 'light')
})
function toggle() {
isDark.set(!isDark())
}
return { isDark, toggle, init }
}
useDebounce
// functions/useDebounce.ts
import { state, effect } from '@stacksjs/stx'
export function useDebounce<T>(source: () => T, delay = 300) {
const debounced = state(source())
let timeout: ReturnType<typeof setTimeout>
effect(() => {
const value = source()
clearTimeout(timeout)
timeout = setTimeout(() => {
debounced.set(value)
}, delay)
})
return debounced
}
useFetch
// functions/useFetch.ts
import { state } from '@stacksjs/stx'
export function useFetch<T>(url: string, options?: RequestInit) {
const data = state<T | null>(null)
const error = state<string | null>(null)
const loading = state(true)
async function execute() {
loading.set(true)
error.set(null)
try {
const res = await fetch(url, options)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.set(await res.json())
} catch (e) {
error.set(e instanceof Error ? e.message : 'Unknown error')
} finally {
loading.set(false)
}
}
async function refresh() {
await execute()
}
// Auto-execute on creation
execute()
return { data, error, loading, refresh }
}
Usage:
<scriptclient
const { data: users, loading, error, refresh } = useFetch('/api/users')
</script>
<divshow="">Loading</div>
<divshow=""class=""x-text=""></div>
<divshow="">
<buttonclick="">Refresh</button>
<ul>
<lifor=""key=""x-text=""></li>
</ul>
</div>
useOptimistic
React-19-style optimistic state, adapted to signals. Shows an immediate value layered on top of a base signal while an async action is in flight, then falls back to the base as the source of truth once the real update lands — so you stop hand-rolling snapshot/rollback in every mutation.
useOptimistic(base, reducer) returns [optimistic, addOptimistic]:
optimistic— a derived signal:basewith every pending action folded in viareducer. Read it in templates as the bare name (optimistic), in scripts as a call (optimistic()).addOptimistic(action)— queues an optimistic action (visible immediately) and returns asettle()to remove it. Pass a promise as the 2nd arg to auto-settle.
Pass the signal (or a getter) as base so the overlay stays reactive — unlike
React there's no render cycle to re-read a plain value.
<scriptclient
const likes = useStore('reviews').likes // a shared signal
const [optimisticLikes, addOptimistic] = useOptimistic(likes, (cur, delta) => cur + delta)
async function toggleLike() {
const settle = addOptimistic(liked() ? -1 : 1) // optimisticLikes() updates instantly
try {
await authFetch('/api/reviews/16/like', { method: 'POST' })
// On success the store updates `likes` → the overlay is discarded automatically.
}
catch {
settle() // On error, base never changed — roll back this entry.
}
}
</script>
<buttonclick="">
♥ <spanx-text=""></span>
</button>
The overlay is discarded the moment base changes (the server/store confirmed the
real value), so the happy path needs no cleanup. For list mutations the reducer can
append: useOptimistic(comments, (list, draft) => [...list, draft]).
Available as a bare identifier in
<script client>and onwindow.stx(window.stx.useOptimistic). Implemented in both reactive impls with parity (signals-api.ts+ the client runtime).
<Suspense> + query suspense mode
<Suspense> is a client loading boundary: one wrapper handles the loading
(and error) state of every data-fetching component inside it, replacing the
three-branch :if / :else-if / :else skeleton pattern.
A descendant opts in by passing { suspense: true } to useQuery / useFetch.
The query registers its loading/error with the nearest <Suspense> ancestor; the
boundary shows the fallback while any descendant query is loading, the
error region if one fails, and the content once they all resolve.
<!-- CommentsList.stx — a component -->
<scriptclient
// suspense mode: this query's loading/error feeds the enclosing <Suspense>.
const { data: comments } = useQuery('/api/comments', { suspense: true })
</script>
<ul>
<lifor=""key=""x-text=""></li>
</ul>
<!-- the page -->
<Suspensefallback="">
<CommentsList />
</Suspense>
Richer fallback / error content can come from named slots instead of the string props:
<Suspense>
<template><Skeletoncount=""/></template>
<template>Couldn't load comments.</template>
<CommentsList />
</Suspense>
Boundaries nest — an inner <Suspense> claims only its own descendant
queries, so an inner section can resolve while an outer one is still pending.
The content is rendered into the DOM but hidden until the queries settle (so the descendant scripts run and register). Suspense queries are client-side, so the fallback shows first on hydration — there's no flash of empty content.
<TransitionGroup>
Animate a keyed list as items are added, removed, and reordered — enter/leave
transitions plus FLIP (smooth position shifts on reorder), without
hand-rolling x-class transition strings.
Wrap a single :for (or x-for) in <TransitionGroup>; its rendered items
become the wrapper's direct children and the runtime animates them:
<TransitionGroupname=""tag=""class="">
<li:for="rin":key="r"class="">{{ r.title }}</li>
</TransitionGroup>
name(defaultv) — the class prefix.tag(defaultdiv) — the wrapper element.
Provide the transition classes for that name (css utilities or plain CSS):
| Phase | Classes |
|---|---|
| enter | name-enter-from → name-enter-active → name-enter-to |
| leave | name-leave-from → name-leave-active → name-leave-to |
| move (reorder) | name-move (applied to elements that shift position) |
.card-enter-active, .card-leave-active, .card-move { transition: all .25s ease; }
.card-enter-from, .card-leave-to { opacity: 0; transform: translateY(8px); }
The first render doesn't animate (only subsequent list changes do). Leaving
elements stay in the DOM until their transition ends — and every phase has a
duration-based timeout fallback, so a missed transitionend can't strand a node.
Use
<TransitionGroup>for lists. For a single element toggled by:if/:show, use<Transition>(from@stacksjs/components).
useLocalStorage
// functions/useLocalStorage.ts
import { state, effect } from '@stacksjs/stx'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const stored = localStorage.getItem(key)
const value = state<T>(stored ? JSON.parse(stored) : defaultValue)
effect(() => {
localStorage.setItem(key, JSON.stringify(value()))
})
function remove() {
localStorage.removeItem(key)
value.set(defaultValue)
}
return { value, remove }
}
Composables Using Other Composables
Composables can call other composables. This lets you build complex behavior from simple pieces:
// functions/useAuthenticatedFetch.ts
import { state } from '@stacksjs/stx'
export function useAuthenticatedFetch<T>(url: string) {
const { token, isAuthenticated } = useAuth()
const data = state<T | null>(null)
const error = state<string | null>(null)
const loading = state(false)
async function execute() {
if (!isAuthenticated()) {
error.set('Not authenticated')
return
}
loading.set(true)
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token()}` },
})
if (res.status === 401) {
navigate('/login')
return
}
data.set(await res.json())
} catch (e) {
error.set(e instanceof Error ? e.message : 'Fetch failed')
} finally {
loading.set(false)
}
}
return { data, error, loading, execute }
}
Before/After: Extracting Composables
Before: God Component (Anti-Pattern)
<scriptclient
// Everything jammed into one script block
const user = state(null)
const token = state(null)
const loading = state(false)
const theme = state('light')
const sidebarOpen = state(true)
const notifications = state([])
const notifCount = derived(() => notifications().filter(n => !n.read).length)
async function login(email, password) {
loading.set(true)
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
const data = await res.json()
token.set(data.token)
user.set(data.user)
loading.set(false)
}
async function logout() {
await fetch('/api/logout', { method: 'POST' })
token.set(null)
user.set(null)
navigate('/login')
}
function toggleTheme() {
const next = theme() === 'light' ? 'dark' : 'light'
theme.set(next)
document.documentElement.classList.toggle('dark', next === 'dark')
localStorage.setItem('theme', next)
}
function toggleSidebar() {
sidebarOpen.set(!sidebarOpen())
}
async function fetchNotifications() {
const res = await fetch('/api/notifications', {
headers: { Authorization: `Bearer ${token()}` },
})
notifications.set(await res.json())
}
onMount(() => {
const stored = localStorage.getItem('theme')
if (stored) theme.set(stored)
fetchNotifications()
})
</script>
After: Composable Extraction
<scriptclient
const { user, isAuthenticated, logout } = useAuth()
const { isDark, toggle: toggleTheme, init: initTheme } = useDark()
const sidebarOpen = state(true)
const { data: notifications, refresh: refreshNotifs } = useAuthenticatedFetch('/api/notifications')
const notifCount = derived(() =>
(notifications() || []).filter(n => !n.read).length
)
function toggleSidebar() {
sidebarOpen.set(!sidebarOpen())
}
onMount(() => {
initTheme()
refreshNotifs()
})
</script>
The page script went from 50 lines to 15. Each composable is independently testable, reusable across pages, and has a single responsibility.
Built-In Composables
stx provides several composables out of the box:
| Composable | Purpose |
|---|---|
useHead() | Set document title, meta tags, link tags at runtime |
useSeoMeta() | Set SEO meta tags (title, description, og:*) |
navigate(url) | SPA navigation (or navigate(url, true) for full reload) |
goBack() | Navigate back in history |
goForward() | Navigate forward in history |
useRoute() | Access current route info (path, query, params) |
useSearchParams() | Reactive URL search params |
onMount(fn) | Run code when page/component mounts |
onDestroy(fn) | Run cleanup when page/component unmounts |
useRef(name) | Get a reference to a DOM element |
Anti-Patterns
Dumping all logic inline in a page. If your
<script client>is longer than 30-40 lines, you almost certainly need to extract composables. Each concern (auth, data fetching, UI state, theme) should be its own composable.
Using
state()for simple boolean flags that are never shared. A localletvariable works fine for values that do not need to reactively update the DOM. Usestate()only when the template needs to react to changes.
Using
window.locationfor navigation. Always usenavigate(url)for SPA transitions. It uses the stx router when available and falls back tolocation.hrefwhen not. Usingwindow.location.href = urldirectly bypasses the router and causes a full page reload.
Using
document.cookiedirectly. Wrap cookie access in a composable (useCookie()) for consistent behavior and testability. Rawdocument.cookieis error-prone and not reactive.
Defining composables inside
<script client>blocks. Composables belong in thefunctions/directory as.tsfiles. Defining them inline means they cannot be reused or tested independently.
Tips
- Name files after the composable:
functions/useAuth.tsexportsuseAuth() - Return an object: Always return an object with named properties, not positional values
- Keep composables pure when possible: Composables that only use signals and computed values (no side effects) are easiest to test
- Use
effect()for side effects: If a composable needs to sync state to localStorage, the DOM, or an API, useeffect()so it runs automatically when dependencies change - Composables run in
<script client>context: They have access todocument,window, signals, and other client-side APIs
Animation frames
useAnimationFrame(callback, { immediate: true, maxDelta: 100 }) runs canvas and
simulation work before each browser paint. The callback receives elapsed
milliseconds and the frame timestamp. The first frame, and the first frame after
resuming a hidden tab, receive a zero delta. maxDelta caps long stalls.
const clock = useAnimationFrame((delta, timestamp) => draw(delta, timestamp))
clock.pause()
clock.resume()
clock.stop() // permanently releases the frame and visibility listener
The composable is delivered on demand in client scripts, registers with STX's
component cleanup, and returns inert controls during server rendering. Imported
TypeScript modules explicitly import it from @stacksjs/stx/composables and own
its stop() lifecycle. Advance a simulation using a fixed-step accumulator when
its results must be independent of display refresh rate.