Components
A component is a reusable piece of UI defined with component() from Arrow.js. Components live in src/components/ and are imported wherever they are needed.
What is a component?
component() wraps your factory function and gives each call site its own stable DOM node. Arrow.js can reuse that node across re-renders and run lifecycle hooks when it unmounts.
A plain function that returns an html template literal also works for purely static markup, but it re-runs and patches in place on every render — no lifecycle, no stable identity. As soon as you need local state or cleanup, use component().
plain function component()
──────────────── ────────────────────────────
returns template returns a factory
patches in place stable DOM node per call site
no lifecycle onCleanup() runs on unmount
no local state can hold reactive stateAnatomy of a component
src/components/UserCard.js is a stateless component that reads props, calls global state, and navigates — the full pattern in one file:
// src/components/UserCard.js
import { component, html } from '@arrow-js/core'
import { go } from '../framework/router.js'
import { userState } from '../state/userState.js'
const STATUS_CLASSES = {
online: 'bg-success-tint text-success',
away: 'bg-warning-tint text-warning',
}
const statusClass = (s) => STATUS_CLASSES[s] ?? 'bg-surface-inset text-fg-soft'
export const UserCard = component((user) => html`
<article class="rounded-panel border border-line bg-surface-raised p-5 shadow-panel">
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3">
<img src="${user.avatar}" alt="" class="h-10 w-10 rounded-full object-cover" />
<div>
<h3 class="font-semibold text-fg">${() => user.name}</h3>
<p class="text-sm text-fg-soft">${() => user.role}</p>
</div>
</div>
<span class="${() => `rounded-full px-2 py-1 text-xs font-semibold ${statusClass(user.status)}`}">
${() => user.status}
</span>
</div>
<div class="mt-3 flex gap-2">
<button @click="${() => go(`/users/${user.id}`)}">View profile</button>
<button @click="${() => userState.removeUser(user.id)}">Remove</button>
</div>
</article>
`)component((props) => ...) — the factory receives props as its argument. Props are plain values — no reactive wrapper needed to read them. Call component() once at module scope and export the result; calling it returns an instance.
${() => user.name} — any slot that references a value that might change must be wrapped in () =>. Arrow.js tracks dependencies lazily by watching the slot be evaluated. A bare ${user.name} is read once at render time and never updates.
Derived values in slots — ${() => statusClass(user.status)} computes inline inside the reactive wrapper. The derivation re-runs whenever user.status changes.
@click="${() => userState.removeUser(user.id)}" — event handlers are arrow functions assigned to the @eventName attribute. Components can read from and write to any global state module directly; there is no prop-drilling or event-emission system.
go('/users/' + user.id) — imperative navigation via the router helper. Components don't need useRouter() for simple navigation; the go function imported from the framework is sufficient.
Local reactive state
src/components/Counter.js holds state that is private to each instance, runs a side-effect watcher, and cleans up when it unmounts:
// src/components/Counter.js
import { component, html, onCleanup, watch } from '@arrow-js/core'
import { hmrState } from '../utils/hmrState.js'
export const Counter = component((props) => {
const state = hmrState(`counter-${props.label}`, { count: 0 })
const [, stopWatch] = watch(() => {
if (state.count > 0 && state.count % 10 === 0) {
console.log(`[Counter] "${props.label}" milestone: ${state.count}`)
}
})
onCleanup(stopWatch)
return html`
<article class="rounded-panel border border-line bg-surface-raised p-5 shadow-panel">
<p class="text-sm font-medium text-fg-soft">${() => props.label}</p>
<p class="mt-3 text-4xl font-bold text-fg">${() => state.count}</p>
<div class="mt-5 flex gap-2">
<button @click="${() => state.count++}">Increment</button>
<button @click="${() => (state.count = 0)}">Reset</button>
</div>
</article>
`
})hmrState(key, initialValue) — creates a reactive() object scoped to this component instance. The key is used only to survive Vite hot-module replacement in dev; it has no meaning at runtime. State resets to zero when the component unmounts (e.g. navigating away from the page).
Instance isolation — two Counter components on the same page each call hmrState() independently and hold separate counts. There is no shared state between instances.
watch(() => { ... }) — runs the callback immediately, then re-runs it whenever any reactive value accessed inside it changes. Returns [data, stopFn]. Destructure to get the stop function.
onCleanup(stopWatch) — registers stopWatch to be called when the component unmounts. Without this, the watcher would continue running after the component is removed from the DOM.
Importing a component
Import components directly from their source files — one file per component, no barrel:
import { UserCard } from '../components/UserCard.js'
import { Link } from '../components/Link.js'Direct imports keep the dependency graph explicit and avoid import cycles through an index file.
Built-in components
| Component | Props | Notes |
|---|---|---|
Link | { to, children, class? } | <a> that navigates via the router instead of reloading. Sets aria-current="page" when the path equals to or is nested under it (/users/42 for to: '/users'; never for '/'); omitted otherwise |
Counter | { label } | Local-state demo: component(), watch(), onCleanup(), hmrState (see above) |
UserCard | a userState.users entry: { id, name, role, team, status, avatar } | status 'online' / 'away' get coloured badges. Render with .key(user.id) |
ThemeToggle | none | <button aria-label="Toggle light/dark mode"> that flips uiState.mode |
ThemeSelector | none | role="group" of five swatch buttons (aria-label, aria-pressed) that set uiState.theme — see Theming. Independent of ThemeToggle |
ErrorCard | message = 'Something went wrong.' | Full-screen panel the router renders when a page fails to load |
LoadingCard | none | Full-screen panel the router renders while status is 'idle' or 'loading' |
Style the active Link with Tailwind's arbitrary variant:
const navClass = 'rounded-control px-3 py-2 text-sm hover:bg-surface-inset [&[aria-current=page]]:bg-brand-tint [&[aria-current=page]]:text-brand'
${Link({ to: '/users', children: 'Users', class: navClass })}Using a component in a page
// src/pages/users/index.js
import { html, nextTick } from '@arrow-js/core'
import { UserCard } from '../../components/UserCard.js'
import { userState } from '../../state/userState.js'
export const meta = { layout: 'menu', title: 'Team' }
function UsersPage() {
return html`
<div>
<button @click="${async () => {
userState.addUser({ name: 'New Member', role: 'Analyst', team: 'Insights' })
await nextTick()
}}">Add member</button>
<div class="grid gap-4 md:grid-cols-2">
${() => userState.users.map((user) => UserCard(user).key(user.id))}
</div>
</div>
`
}
export default UsersPageUserCard(user) — calling the component factory returns an instance. Pass props as the argument; there is no JSX or special syntax.
${() => userState.users.map(...)} — the outer () => makes the entire list reactive. When userState.users changes, Arrow.js re-evaluates the slot and diffs the result.
.key(user.id) — attaches a stable identity to the component instance. Arrow.js uses it to match old instances to new ones during a re-render, reusing existing DOM nodes instead of destroying and recreating them. Always call .key() with a unique, stable value when rendering component() instances in a loop. Without it, every state change tears down and recreates all instances, losing local state.
await nextTick() — waits for Arrow.js to flush pending DOM updates. Use it when you need to interact with the DOM immediately after a state mutation.
Passing props
Props are the argument to the factory function — a plain object, string, number, or anything you pass at the call site. There is no prop declaration system.
// Receiving props
export const Badge = component((props) => html`
<span class="rounded-full px-2 py-1 text-xs">${() => props.label}</span>
`)
// Passing props
${Badge({ label: 'Online' })}Arrow.js does not diff props between renders. If a prop value can change you must read it inside a reactive ${() => ...} slot. A prop read bare (outside () =>) is captured once when the factory runs and never updates:
// Correct — updates when props.label changes
html`<span>${() => props.label}</span>`
// Wrong — captured once, never updates
html`<span>${props.label}</span>`Styling a component
Use semantic token utilities — never Tailwind palette classes (bg-<colour>-<shade>). Token values are overridden per theme and per mode in src/style.css, so a component styled with tokens works in all five themes, light and dark, with no dark: colour overrides.
Chrome — bg-surface-raised, text-fg, border-line, rounded-panel, shadow-panel.
Status colour — success, error, warning, info, each with a -tint background. Use the opacity modifier for a soft border:
<article class="rounded-panel border border-line bg-surface-raised p-5 shadow-panel">
<div class="rounded-panel border border-error/30 bg-error-tint p-4 text-error">
Something went wrong.
</div>See Theming for the full token reference.
Theming in components
Components participate in the theming system through two Tailwind variant classes:
dark: — applies when data-mode="dark" is on <html>. Colour tokens already switch with the mode, so reserve dark: for non-colour differences, like the knob position in ThemeToggle:
class="translate-x-0.5 dark:translate-x-5"Per-theme variants (theme-glass:, theme-brutalist:) — apply only when that specific theme is active. Use for structural differences that go beyond a colour swap:
class="rounded-panel shadow-panel theme-glass:backdrop-blur-md theme-brutalist:border-2"theme-glass:backdrop-blur-md adds a frosted-glass blur only in the Liquid Glass theme. theme-brutalist:border-2 thickens the border only in Neo Brutalism. All other themes are unaffected.
For the full token reference, per-theme variable overrides, and how to add a new theme, see the Theming guide.