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:

source
<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.

source
<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>
output
Measured after mount

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 onMount runs before its children's.
  • A returned function is discarded. Use onDestroy for teardown, including from inside onMount itself.
  • A throw aborts that component's mount and propagates, so an enclosing ErrorBoundary shows its fallback.
source
<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.

source
<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:

source
<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:

source
<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.

source
<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.

source
<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.

source
<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>