Lifecycle
JAF has four lifecycle hooks, and most components need none of them.
The
<script> body is your setup code, timers and listeners
clean themselves up, and the rest is
onMount, onNavigate, onDestroy and
effect. None of them are imported.
The script body
This is where declarations, state and functions go. It runs before the component's HTML exists, so there is nothing to query yet:
<script>
let count = 0
function increment() {
count++
}
// WRONG: the component's HTML has not been inserted yet, so this is null
const box = document.querySelector('.my-box')
</script>
<div class="my-box">Clicked {count} times</div>
onMount
onMount(callback) runs once the DOM is in place and its
expression bindings have been applied.
Anything that touches a real element - measuring, focusing, handing a node to a chart library - belongs here.
<script>
let width = 0
onMount(() => {
// DOM is inserted AND expression bindings are resolved
const box = document.querySelector('.measure-me')
width = box?.offsetWidth ?? 0
})
</script>
<div class="measure-me">Measured after mount</div>
<p>Width: {width}px</p>
Width: 0px
It is a one-shot hook. The callback runs exactly once per
mount, untracked - reading a reactive variable inside it does
not subscribe, so state changes never re-run it. That is
effect()'s job.
- Remounting - navigation, conditional rendering - runs it again.
- A parent's
onMountruns before its children's. -
A returned function is discarded. Use
onDestroyfor teardown, including from insideonMountitself. - A throw aborts that component's mount and propagates, so an enclosing ErrorBoundary shows its fallback.
<script>
let chart = null
onMount(() => {
// Set up work that needs the real DOM
const canvas = document.querySelector('.chart')
chart = new Chart(canvas, { type: 'line' })
})
// Returning a function from onMount does nothing - clean up here
onDestroy(() => chart?.destroy())
</script>
<canvas class="chart"></canvas>
onNavigate
onNavigate(callback) fires on every route change while the
component stays mounted, and receives the new path. It is for layouts, nav
bars and anything that outlives a single page - a page component does not need
it, because navigation simply mounts it again.
<script>
let lastPath = ''
// Fires on every route change while this component stays mounted
onNavigate((path) => {
lastPath = path
})
</script>
<p>Last route: {lastPath}</p>
The listener is removed for you when the component unmounts.
effect
There is no onUpdate. effect() tracks whatever it
reads and re-runs when that changes:
<script>
let count = 0
effect(() => {
console.log('count changed:', count)
// Runs on initial mount AND every update
})
</script>
Like onMount, a function returned from an effect is discarded.
Undo the previous run's work at the top of the effect, and use
onDestroy for the final teardown:
<script>
let elementId = 'box-1'
let highlighted = null
effect(() => {
// Undo the previous run's work yourself - a returned
// function is NOT called by the framework
highlighted?.classList.remove('highlight')
highlighted = document.getElementById(elementId)
highlighted?.classList.add('highlight')
})
onDestroy(() => highlighted?.classList.remove('highlight'))
</script>
Cleanup
Four browser APIs are tracked and torn down automatically:
setTimeout, setInterval,
document.addEventListener and
window.addEventListener. No onDestroy needed.
<script>
// These are automatically cleaned up - no onDestroy needed!
setInterval(() => console.log('tick'), 1000)
document.addEventListener('keydown', handleKey)
window.addEventListener('resize', handleResize)
function handleKey(e) { console.log('Key:', e.key) }
function handleResize() { console.log('Resized!') }
</script>
Everything else - a chart instance, a WebSocket, a listener on some other element - is yours to clean up.
Callbacks run in reverse registration order, when the component is navigated away from, its parent unmounts, or a condition removes it.
<script>
// A chart instance, a socket, an observer: JAF cannot see these
const chart = new Chart(canvasEl, config)
onDestroy(() => chart.destroy())
// Registered second, so it runs first
onDestroy(() => console.log('tearing down'))
</script>
script once
<script once> runs once for the whole app rather than once
per mount, however often the component is remounted. It is identified by file
path.
Use it for analytics setup, a polyfill, or a one-time library config.
<script once>
// Runs once ever, not once per mount
console.log('App initialized')
// Good for: analytics init, global polyfills, one-time setup
if (!window.myLib) {
window.myLib = initializeLibrary()
}
</script>
<script>
// Normal script runs every mount
let count = 0
</script>
Summary
| You want to | Write |
|---|---|
| Set up state and functions | The <script> body |
| Touch a real DOM element | onMount(() => {...}) |
| React to a route change | onNavigate((path) => {...}) |
| React to a state change | effect(() => {...}) |
| Clean up on unmount | onDestroy(() => {...}) |
| Run something once for the whole app | <script once> |