Three-Phase Functions β
The core of Rasen's component model is the three-phase function β a simple yet powerful pattern that maps directly to component lifecycle.
The Pattern β
const Component = (props) => { // π¦ Phase 1: Setup
// Initialize reactive state
const count = ref(0)
return (host) => { // π Phase 2: Mount
// Mount to host, establish subscriptions
const stop = getReactiveRuntime().subscribe(count, (value) => {
// Update the host's representation
})
return () => { // π§Ή Phase 3: Unmount
// Cleanup resources
stop()
}
}
}Everything you build on top of the paradigm β the element factories, when, each, a compiled template β is just a Mountable with phases like these.
Phase 1: Setup β
The outermost function is called during component creation. This is where you:
- Receive props
- Initialize reactive state
- Compute derived values
- Prepare anything that should exist before mounting
const UserCard = (props: { userId: string }) => {
// Setup phase: create reactive state
const user = ref<User | null>(null)
const loading = ref(true)
const error = ref<Error | null>(null)
// Can also do async work
fetchUser(props.userId)
.then(data => user.value = data)
.catch(err => error.value = err)
.finally(() => loading.value = false)
return (host) => {
// Mount phase...
}
}Phase 2: Mount β
The second function is called when the component is mounted to a host. This is where you:
- Create DOM elements or other host representations
- Set up watchers for reactive updates
- Attach event listeners
- Initialize side effects
import { getReactiveRuntime, ref } from '@rasenjs/core'
const Counter = () => {
// core's built-in ref is callable: count() reads, count(v) writes
const count = ref(0)
return (host: HTMLElement) => {
// Create elements
const container = document.createElement('div')
const display = document.createElement('span')
const button = document.createElement('button')
button.textContent = '+'
button.addEventListener('click', () => count(count() + 1))
// Setup reactive updates. subscribe() has no `immediate` option β the
// callback never fires for the initial value, so write it yourself.
display.textContent = `Count: ${count()}`
const stopWatch = getReactiveRuntime().subscribe(count, (value) => {
display.textContent = `Count: ${value}`
})
// Mount to host
container.appendChild(display)
container.appendChild(button)
host.appendChild(container)
return () => {
// Unmount phase...
}
}
}If the component is wrapped in com() you do not have to keep the stop function around: subscriptions created during mount are collected into the component's effect scope and stopped on unmount.
Phase 3: Unmount β
The innermost function is called when the component is removed. This is where you:
- Stop watchers
- Remove event listeners
- Clean up any resources
- Remove DOM elements
return () => {
// Stop all watchers
stopWatch()
// Remove event listeners
button.removeEventListener('click', handleClick)
// Remove from DOM
container.remove()
// Clean up any other resources
abortController.abort()
}Why Three Phases? β
1. Closures Naturally Isolate State β
Each phase creates a closure that captures variables from the previous phase. This means:
- Setup state is accessible in mount and unmount
- Mount-time variables (like DOM elements) are accessible in unmount
- No need to store references in class properties or hooks
const Timer = () => {
const elapsed = ref(0) // Captured by mount phase
return (host) => {
let intervalId: number // Captured by unmount phase
intervalId = setInterval(() => {
elapsed.value++
}, 1000)
// ... mount logic
return () => {
clearInterval(intervalId) // Can access intervalId
}
}
}2. Avoids React's Stale Closure Problem β
In React, the stale closure problem occurs because:
// React - problematic
function Counter() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
console.log(count) // Always logs initial value!
}, 1000)
return () => clearInterval(id)
}, []) // Empty deps = stale closure
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}In Rasen, this doesn't happen because:
// Rasen - works correctly
const Counter = () => {
const count = ref(0) // Reactive reference, not a value
return (host) => {
const id = setInterval(() => {
console.log(count.value) // Always reads current value!
}, 1000)
// ... mount logic
return () => clearInterval(id)
}
}3. Lifecycle Is Visible in Code Structure β
The nesting of functions directly shows the lifecycle:
const Component = (props) => {
// SETUP: Runs once when component is created
return (host) => {
// MOUNT: Runs once when mounted to host
return () => {
// UNMOUNT: Runs once when removed
}
}
}No need to remember which lifecycle hook to use or when it fires.
Working with Primitive Components β
Primitive components (like div, button) simplify this pattern by encapsulating the mount logic:
// Under the hood, div does roughly this β children are mounted, every other
// prop goes through the shared binding layer (property/attribute dispatch):
const div = (props) => (host: HTMLElement) => {
const el = document.createElement('div')
const stops = []
for (const [key, value] of Object.entries(props)) {
if (key === 'children') continue
if (isEventProp(key)) stops.push(on(el, getEventName(key), value))
else stops.push(bindKey(el, 'div', key, value))
}
const childUnmounts = (Array.isArray(props.children) ? props.children : [props.children])
.map((child) => mountChild(el, child))
host.appendChild(el)
return () => {
stops.forEach((stop) => stop())
childUnmounts.forEach((unmount) => unmount?.())
el.remove()
}
}This means your business components can look simpler:
import { Signal } from 'signal-polyfill'
import { com } from '@rasenjs/core'
import { div, span, button } from '@rasenjs/dom'
// Business component β just returns the mountable from div()
const Counter = com(() => {
const count = new Signal.State(0)
return div(
{},
span({}, 'Count: ', count), // ref child β reactive text
button({ onClick: () => count.set(count.get() + 1) }, '+')
)
})β¦and with JSX the same component reads:
const Counter = com(() => {
const count = new Signal.State(0)
return (
<div>
<span>Count: {count}</span>
<button onClick={() => count.set(count.get() + 1)}>+</button>
</div>
)
})Async setup β
The setup phase may be async: com() detects a returned Promise and adapts the component into Promise<Mountable>. Hosts mount synchronously, so an async component is not mounted directly as a child β reach for lazy(), which owns the pending / error states:
import { lazy } from '@rasenjs/core'
// loader() resolves to the mountable to render
const UserCard = lazy({
loader: () => import('./UserCard').then((m) => m.UserCard({ userId })),
loading: () => spinner()
})If your host drives its own async boundary (the SSR pipeline awaits before serializing), the raw form is available:
const AsyncComponent = com(async (props: { url: string }) => {
const data = await fetch(props.url).then((r) => r.json())
return (host: HTMLElement) => div({}, JSON.stringify(data))(host)
})
// the caller awaits, then mounts
const mountable = await AsyncComponent({ url: '/api/user' })
mount(mountable, document.getElementById('app')!)Next Steps β
- Reactive Runtime β How reactive systems integrate
- Components β Building reusable components
- Render Targets β Different host types