@pyreon/preact-compat provides a Preact-compatible API surface -- h, render, Component, hooks, and Preact Signals -- all backed by Pyreon's fine-grained reactive engine. It mirrors Preact's module structure with three entry points: the core API, a hooks submodule, and a signals submodule.
Installation
npm install @pyreon/preact-compatbun add @pyreon/preact-compatpnpm add @pyreon/preact-compatyarn add @pyreon/preact-compatQuick Start
Replace your Preact imports:
// Before
import { h, render, Fragment } from 'preact'
import { useState, useEffect } from 'preact/hooks'
import { signal, computed } from '@preact/signals'
// After
import { h, render, Fragment } from '@pyreon/preact-compat'
import { useState, useEffect } from '@pyreon/preact-compat/hooks'
import { signal, computed } from '@pyreon/preact-compat/signals'import { h, render } from '@pyreon/preact-compat'
import { useState, useEffect } from '@pyreon/preact-compat/hooks'
const Counter = () => {
const [count, setCount] = useState(0)
useEffect(() => {
document.title = `Count: ${count()}`
})
return (
<div>
<p>Count: {count()}</p>
<button onClick={() => setCount((prev) => prev + 1)}>+1</button>
</div>
)
}
render(<Counter />, document.getElementById('app')!)Key Differences from Preact
| Behavior | Preact | @pyreon/preact-compat |
|---|---|---|
| Component execution | Re-runs render on every state change | Runs once (setup phase) |
useState getter | Returns the value directly | Returns a getter function -- call count() to read |
useEffect deps | Controls when the effect re-runs | Deps array is ignored -- Pyreon tracks dependencies automatically |
useCallback | Memoizes across renders | No-op -- returns fn as-is |
useMemo | Returns the memoized value | Returns a getter function -- call value() to read |
useLayoutEffect | Fires synchronously before paint | Same as useEffect |
Signals .value | Native Preact Signals API | Wrapped Pyreon signals with the same .value interface |
| Class components | Full lifecycle support | setState and forceUpdate work; lifecycle methods are not called |
| Hooks rules | Must be called at top level | No restrictions -- call anywhere in component setup |
Reading State
The most important change: useState returns a getter function, not a raw value.
// Preact
const [count, setCount] = useState(0)
console.log(count) // 0
// Pyreon
const [count, setCount] = useState(0)
console.log(count()) // 0 -- note the function callNo Stale Closures
In Preact, closures capture the value at render time. In Pyreon, signal reads always return the current value:
function Timer() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
// In Preact, this would capture the initial value without deps
// In Pyreon, count() always returns the current value
console.log('Count:', count())
setCount((prev) => prev + 1)
}, 1000)
return () => clearInterval(id)
}, [])
return <p>{count()}</p>
}Signals Compatibility
If you use Preact Signals (@preact/signals), the @pyreon/preact-compat/signals module provides the same .value interface:
// Before (Preact Signals)
import { signal, computed, effect } from '@preact/signals'
const count = signal(0)
count.value++
console.log(count.value)
// After (Pyreon)
import { signal, computed, effect } from '@pyreon/preact-compat/signals'
const count = signal(0)
count.value++ // same API
console.log(count.value) // same APIModule Structure
@pyreon/preact-compat mirrors Preact's multi-module structure:
| Import | Provides |
|---|---|
@pyreon/preact-compat | Core API: h, render, hydrate, Fragment, Component, createContext, createRef, cloneElement, toChildArray, isValidElement, options |
@pyreon/preact-compat/hooks | Hooks: useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef, useReducer, useId, useContext, useErrorBoundary |
@pyreon/preact-compat/signals | Signals: signal, computed, effect, batch |
Core API (@pyreon/preact-compat)
h / createElement
function h(type: string | ComponentFn, props: Props | null, ...children: VNodeChild[]): VNodePreact's hyperscript function. Maps directly to Pyreon's h(). createElement is an alias.
import { h, createElement } from '@pyreon/preact-compat'
const vnode = <div class="box">Hello</div>
const same = <div class="box">Hello</div>All element types:
// HTML element
<div class="container">Content</div>
// Component
<MyComponent name="Alice" />
// Fragment
<Fragment><span>A</span><span>B</span></Fragment>
// SVG element
<svg viewBox="0 0 100 100">
<circle cx={50} cy={50} r={40} fill="red" />
</svg>Fragment
The fragment symbol for grouping children without a wrapper DOM element.
import { h, Fragment } from '@pyreon/preact-compat'
const items = (
<Fragment>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</Fragment>
)render
function render(vnode: VNodeChild, container: Element): voidMounts a VNode tree into a DOM container. Maps to Pyreon's mount().
import { h, render } from '@pyreon/preact-compat'
render(<div>Hello</div>, document.getElementById('app')!)
// Or with JSX
render(<App />, document.getElementById('app')!)hydrate
function hydrate(vnode: VNodeChild, container: Element): voidHydrates server-rendered HTML. Maps to Pyreon's hydrateRoot(). Use this when your HTML is pre-rendered on the server and you want to attach event handlers and reactive behavior on the client.
import { h, hydrate } from '@pyreon/preact-compat'
// Server-rendered HTML is already in #app
hydrate(<App />, document.getElementById('app')!)Component
class Component<P extends Props, S extends Record<string, unknown>> {
props: P
state: S
setState(partial: Partial<S> | ((prev: S) => Partial<S>)): void
forceUpdate(): void
render(): VNodeChild
}A class-based component with setState and forceUpdate. State changes are backed by a Pyreon signal, so setState triggers reactive updates through Pyreon's batching system.
import { Component, render } from '@pyreon/preact-compat'
class Counter extends Component<{}, { count: number }> {
constructor(props: {}) {
super(props)
this.state = { count: 0 }
}
render() {
return (
<div>
<p>Count: {this.state.count}</p>
<button onClick={() => this.setState((prev) => ({ count: prev.count + 1 }))}>
Increment
</button>
</div>
)
}
}setState with partial state:
class Form extends Component<{}, { name: string; email: string; submitted: boolean }> {
constructor(props: {}) {
super(props)
this.state = { name: '', email: '', submitted: false }
}
render() {
return (
<form
onSubmit={(e: SubmitEvent) => {
e.preventDefault()
this.setState({ submitted: true })
}}
>
<input
value={this.state.name}
onInput={(e: InputEvent) => this.setState({ name: (e.target as HTMLInputElement).value })}
/>
<input
value={this.state.email}
onInput={(e: InputEvent) =>
this.setState({ email: (e.target as HTMLInputElement).value })
}
/>
<button type="submit">Submit</button>
</form>
)
}
}Difference from Preact: Lifecycle methods (componentDidMount, componentWillUnmount, shouldComponentUpdate, componentDidUpdate, componentWillReceiveProps, getSnapshotBeforeUpdate) are not called. Use hooks for lifecycle logic. If you need class component lifecycle behavior, refactor to functional components with hooks.
cloneElement
function cloneElement(vnode: VNode, props?: Props, ...children: VNodeChild[]): VNodeClones a VNode with merged props. If new children are provided, they replace the original children. The key can be overridden via props.
const original = (
<div class="a" id="x">
child
</div>
)
const cloned = cloneElement(original, { class: 'b' })
// cloned.props.class === 'b', cloned.props.id === 'x'
// Override children
const withNewChildren = cloneElement(original, null, 'new child')
// Override key
const withNewKey = cloneElement(original, { key: 'new-key' })Real-world use case -- adding props to children:
function Toolbar(props: { children: VNode[] }) {
return (
<div class="toolbar">
{props.children.map((child) => cloneElement(child, { class: 'toolbar-button' }))}
</div>
)
}toChildArray
function toChildArray(children: VNodeChild | VNodeChild[]): VNodeChild[]Flattens nested children into a flat array, filtering out null, undefined, and booleans.
toChildArray(['a', ['b', ['c']], null, false, 'd'])
// => ['a', 'b', 'c', 'd']
// Useful for manipulating children
function FilteredList(props: { children: VNodeChild }) {
const items = toChildArray(props.children)
return <ul>{items.slice(0, 5)}</ul> // show max 5
}isValidElement
function isValidElement(x: unknown): x is VNodeReturns true if the value is a VNode (has type, props, and children properties).
const vnode = <div>Hello</div>
isValidElement(vnode) // true
isValidElement('string') // false
isValidElement(null) // false
isValidElement({ type: 'div', props: {}, children: [] }) // truecreateContext / useContext
Re-exports from @pyreon/core. Create and consume context values.
import { createContext, useContext } from '@pyreon/preact-compat'
const Theme = createContext('light')
function ThemedButton() {
const theme = useContext(Theme) // 'light'
return <button class={theme}>Click me</button>
}Context with Provider pattern:
import { createContext, useContext } from '@pyreon/preact-compat'
import { withContext } from '@pyreon/core'
const LocaleContext = createContext('en')
function LocaleProvider(props: { locale: string; children: VNodeChild }) {
return withContext(LocaleContext, props.locale, () => props.children)
}
function Greeting() {
const locale = useContext(LocaleContext)
const messages: Record<string, string> = {
en: 'Hello!',
es: 'Hola!',
fr: 'Bonjour!',
}
return <p>{messages[locale] ?? messages.en}</p>
}
// Usage
render(
<LocaleProvider locale="es">
<Greeting /> {/* renders "Hola!" */}
</LocaleProvider>,
document.getElementById('app')!,
)createRef
function createRef<T>(): { current: T | null }Creates a mutable ref object with an initial current value of null.
import { createRef } from '@pyreon/preact-compat'
const inputRef = createRef<HTMLInputElement>()
// Later, after mount
inputRef.current?.focus()options
const options: Record<string, unknown>An empty object exposed for compatibility with Preact plugins that inspect options._hook, options.vnode, options._diff, etc. No hooks are active -- this is a stub.
// This will not throw, but the hook will not be called
options.vnode = (vnode) => {
/* not called */
}Hooks (@pyreon/preact-compat/hooks)
useState
function useState<T>(initial: T | (() => T)): [() => T, (v: T | ((prev: T) => T)) => void]Returns [getter, setter]. The getter is a Pyreon signal -- call it as a function to read.
const [name, setName] = useState('Alice')
console.log(name()) // 'Alice'
setName('Bob')
setName((prev) => prev + '!')
// Lazy initializer
const [cache, setCache] = useState(() => buildInitialCache())Real-world useState patterns:
// Toggle
function useToggle(initial = false) {
const [value, setValue] = useState(initial)
const toggle = () => setValue((prev) => !prev)
return [value, toggle] as const
}
// Counter with bounds
function useBoundedCounter(min: number, max: number, initial: number) {
const [count, setCount] = useState(Math.max(min, Math.min(max, initial)))
return {
count,
increment: () => setCount((prev) => Math.min(max, prev + 1)),
decrement: () => setCount((prev) => Math.max(min, prev - 1)),
reset: () => setCount(initial),
}
}
// Previous value tracking
function usePrevious<T>(getter: () => T) {
const ref = useRef<T>()
useEffect(() => {
ref.current = getter()
})
return ref
}useEffect
function useEffect(fn: () => CleanupFn | void, deps?: unknown[]): voidRuns a reactive side effect. The deps array is ignored -- Pyreon auto-tracks signal reads. Return a cleanup function for disposal.
Mount-only: Pass [] to run once on mount (wrapped in runUntracked).
// Runs every time name() changes
useEffect(() => {
document.title = name()
})
// Runs once on mount
useEffect(() => {
const ws = new WebSocket('/stream')
ws.onmessage = (e) => setMessages((prev) => [...prev, JSON.parse(e.data)])
return () => ws.close()
}, [])Data fetching pattern:
function UserProfile(props: { userId: () => number }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const id = props.userId()
setLoading(true)
setError(null)
const controller = new AbortController()
fetch(`/api/users/${id}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then((data) => {
setUser(data)
setLoading(false)
})
.catch((err) => {
if (err.name !== 'AbortError') {
setError(String(err))
setLoading(false)
}
})
return () => controller.abort()
})
return () => {
if (loading()) return <div class="skeleton" />
if (error()) return <div class="error">{error()}</div>
return (
<div>
<h2>{user()!.name}</h2>
<p>{user()!.email}</p>
</div>
)
}
}useLayoutEffect
Alias for useEffect. No layout/passive distinction in Pyreon.
useMemo
function useMemo<T>(fn: () => T, _deps?: unknown[]): () => TReturns a computed getter. Deps are ignored.
const [items, setItems] = useState([1, 2, 3])
const sum = useMemo(() => items().reduce((a, b) => a + b, 0))
console.log(sum()) // 6
// Filtered + sorted list
const [filter, setFilter] = useState('')
const filteredItems = useMemo(() => items().filter((item) => item.name.includes(filter())))
const sortedItems = useMemo(() => [...filteredItems()].sort((a, b) => a.name.localeCompare(b.name)))useCallback
function useCallback<T extends (...args: unknown[]) => unknown>(fn: T, _deps?: unknown[]): TReturns fn as-is. Components run once, so callbacks never go stale.
useRef
function useRef<T>(initial?: T): { current: T | null }Returns a { current } object. If initial is provided, current is set to it; otherwise it defaults to null.
// DOM ref
const inputRef = useRef<HTMLInputElement>()
// later: inputRef.current?.focus()
// Mutable value store
const renderCount = useRef(0)
renderCount.current!++useReducer
function useReducer<S, A>(
reducer: (state: S, action: A) => S,
initial: S | (() => S),
): [() => S, (action: A) => void]Returns [getter, dispatch]. Dispatch applies the reducer and updates the underlying signal.
type Action =
| { type: 'add'; text: string }
| { type: 'remove'; id: number }
| { type: 'toggle'; id: number }
interface Todo {
id: number
text: string
done: boolean
}
function todoReducer(state: Todo[], action: Action): Todo[] {
switch (action.type) {
case 'add':
return [...state, { id: Date.now(), text: action.text, done: false }]
case 'remove':
return state.filter((t) => t.id !== action.id)
case 'toggle':
return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t))
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(todoReducer, [])
return (
<div>
<button onClick={() => dispatch({ type: 'add', text: 'New todo' })}>Add</button>
<ul>
{() =>
todos().map((todo) => (
<li
onClick={() => dispatch({ type: 'toggle', id: todo.id })}
style={todo.done ? 'text-decoration: line-through' : ''}
>
{todo.text}
</li>
))
}
</ul>
</div>
)
}useId
function useId(): stringReturns a stable unique string (e.g. :r0:) scoped to the current component. Deterministic and hydration-safe.
function LabeledInput(props: { label: string }) {
const id = useId()
return (
<>
<label for={id}>{props.label}</label>
<input id={id} />
</>
)
}useContext
Re-export from @pyreon/core.
useErrorBoundary
function useErrorBoundary(handler: (error: Error) => boolean | void): voidWraps Pyreon's onErrorCaptured. Register a handler for errors thrown in child components.
function SafeZone(props: { children: VNodeChild }) {
const [error, setError] = useState<string | null>(null)
useErrorBoundary((err) => {
setError(String(err))
return true // handled
})
return () =>
error() ? (
<div class="error">
<p>Error: {error()}</p>
<button onClick={() => setError(null)}>Dismiss</button>
</div>
) : (
props.children
)
}Signals (@pyreon/preact-compat/signals)
This module provides a Preact Signals-compatible API with .value accessors, backed by Pyreon's reactive primitives. Use this when migrating from @preact/signals.
signal
function signal<T>(initial: T): WritableSignal<T>
interface WritableSignal<T> {
value: T // get (tracked) / set
peek(): T // get (untracked)
}Create a writable signal with .value accessor syntax.
import { signal } from '@pyreon/preact-compat/signals'
const count = signal(0)
count.value++ // write
console.log(count.value) // read (tracked)
console.log(count.peek()) // read (untracked)Using signals in components:
import { signal, computed, effect } from '@pyreon/preact-compat/signals'
import { h, render } from '@pyreon/preact-compat'
// Global signals (can be shared across components)
const todos = signal<Array<{ id: number; text: string; done: boolean }>>([])
const filter = signal<'all' | 'active' | 'done'>('all')
const filteredTodos = computed(() => {
const list = todos.value
switch (filter.value) {
case 'active':
return list.filter((t) => !t.done)
case 'done':
return list.filter((t) => t.done)
default:
return list
}
})
const remaining = computed(() => todos.value.filter((t) => !t.done).length)
function TodoApp() {
effect(() => {
document.title = `${remaining.value} remaining`
})
return (
<div>
<p>{remaining.value} remaining</p>
<ul>
{filteredTodos.value.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
)
}computed
function computed<T>(fn: () => T): ReadonlySignal<T>
interface ReadonlySignal<T> {
readonly value: T
peek(): T
}Create a derived signal. Reads via .value are tracked.
import { signal, computed } from '@pyreon/preact-compat/signals'
const count = signal(3)
const doubled = computed(() => count.value * 2)
console.log(doubled.value) // 6
count.value = 10
console.log(doubled.value) // 20Chained computeds:
const price = signal(100)
const quantity = signal(2)
const taxRate = signal(0.08)
const subtotal = computed(() => price.value * quantity.value)
const tax = computed(() => subtotal.value * taxRate.value)
const total = computed(() => subtotal.value + tax.value)
console.log(total.value) // 216effect
function effect(fn: () => void | (() => void)): () => voidRuns fn reactively -- re-executes whenever tracked signal reads change. Returns a dispose function. Optionally return a cleanup function from fn.
const dispose = effect(() => {
console.log('Count is', count.value)
})
// With cleanup
const dispose = effect(() => {
const handler = () => console.log('resize')
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)
})
// Stop tracking
dispose()batch
function batch<T>(fn: () => T): TGroups multiple .value writes into a single reactive flush.
batch(() => {
count.value = 10
name.value = 'Alice'
})
// Effects that depend on both run only onceMigrating from Preact Signals to Pyreon Signals
The .value accessor API is identical between @preact/signals and @pyreon/preact-compat/signals. The migration is a simple import swap:
// Before
import { signal, computed, effect, batch } from '@preact/signals'
// After
import { signal, computed, effect, batch } from '@pyreon/preact-compat/signals'If you want to migrate further to native Pyreon signals (getter function pattern instead of .value), the changes are:
// Preact Signals style
const count = signal(0)
count.value++
console.log(count.value)
// Native Pyreon style
import { signal } from '@pyreon/reactivity'
const count = signal(0)
count.update((n) => n + 1)
console.log(count())Real-World Migration Examples
Converting a Preact App Entry Point
// Before (Preact)
import { h, render } from 'preact'
import { Router, Route } from 'preact-router'
const App = () => (
<Router>
<Route path="/" component={Home} />
<Route path="/about" component={About} />
</Router>
)
render(<App />, document.body)
// After (Pyreon)
import { h, render } from '@pyreon/preact-compat'
// Note: preact-router will need to be replaced with @pyreon/router
const App = () => (
<div>
<Home />
</div>
)
render(<App />, document.getElementById('app')!)Converting a Component with Lifecycle Methods
// Before (Preact class component)
import { Component, h } from 'preact'
class Timer extends Component {
state = { seconds: 0 }
interval = null
componentDidMount() {
this.interval = setInterval(() => {
this.setState((prev) => ({ seconds: prev.seconds + 1 }))
}, 1000)
}
componentWillUnmount() {
clearInterval(this.interval)
}
render() {
return <p>Seconds: {this.state.seconds}</p>
}
}
// After (Pyreon functional component with hooks)
import { h } from '@pyreon/preact-compat'
import { useState, useEffect } from '@pyreon/preact-compat/hooks'
function Timer() {
const [seconds, setSeconds] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setSeconds((prev) => prev + 1)
}, 1000)
return () => clearInterval(id)
}, [])
return <p>Seconds: {seconds()}</p>
}Converting Signals-Based State Management
// Before (@preact/signals)
import { signal, computed } from '@preact/signals'
const cart = signal<CartItem[]>([])
const totalPrice = computed(() =>
cart.value.reduce((sum, item) => sum + item.price * item.quantity, 0),
)
const itemCount = computed(() => cart.value.reduce((sum, item) => sum + item.quantity, 0))
function addToCart(item: CartItem) {
const existing = cart.value.find((i) => i.id === item.id)
if (existing) {
cart.value = cart.value.map((i) => (i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i))
} else {
cart.value = [...cart.value, { ...item, quantity: 1 }]
}
}
// After (@pyreon/preact-compat/signals) -- exact same code!
import { signal, computed } from '@pyreon/preact-compat/signals'
// ... all code remains identicalHandling Third-Party Preact Libraries
Libraries that depend on Preact internals may not work. Libraries that use the public API (hooks, h, Component) are more likely to work with alias configuration:
// vite.config.ts
export default defineConfig({
resolve: {
alias: {
preact: '@pyreon/preact-compat',
'preact/hooks': '@pyreon/preact-compat/hooks',
'@preact/signals': '@pyreon/preact-compat/signals',
},
},
})Known limitations with aliasing:
Libraries that use
preact/compat(the React-compatibility layer for Preact) may need additional configurationLibraries that access
options._diff,options._commit, or other internal hooks will not receive notificationsLibraries that use
__H(internal hooks state) or other underscore-prefixed internals will not work
Migration Checklist
Replace
preactimports with@pyreon/preact-compat,preact/hookswith@pyreon/preact-compat/hooks, and@preact/signalswith@pyreon/preact-compat/signals.Change state reads from
counttocount()for hook-based code. Signals-based code using.valueworks without changes.Remove dependency arrays from
useEffectanduseMemo(or leave them -- they are ignored).Replace class component lifecycle methods (
componentDidMount,componentWillUnmount, etc.) with hooks (useEffectfor mount/unmount logic).Verify any
optionsplugin code -- theoptionsobject is an empty stub.Check
toChildArrayusage -- should work identically.Test
cloneElementusage -- props merging behavior is the same.Replace
preact-routeror other Preact-specific router with@pyreon/router.Test
isValidElement-- checks fortype,props, andchildrenproperties.Review any code that depends on re-render behavior -- Pyreon components run once; derive state reactively instead.