@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}`
}, [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
@pyreon/preact-compat runs the value + re-render model: useState returns the value directly (not a getter), the component body re-runs on state change, and useEffect / useMemo / useCallback honor their deps arrays. Most Preact code — including hooks-rules ordering and stale-closure semantics — behaves identically. The genuine differences:
| Behavior | Preact | @pyreon/preact-compat |
|---|---|---|
| Reactive engine | VDOM diff + reconciliation | Pyreon signals driving a per-component re-render |
| Nested child state across an ancestor re-render | Preserved (reconciliation by position/key) | Reset -- a parent re-render rebuilds the child subtree, so a nested component's useState / useReducer / useRef revert to initial and useEffect([]) re-fires. Lift such state up, or avoid re-rendering the ancestor. (memo does not prevent this.) |
| Class lifecycle | Full lifecycle | componentDidMount / componentDidUpdate / componentWillUnmount / shouldComponentUpdate fire; componentDidCatch / getDerivedStateFromError / getDerivedStateFromProps / getSnapshotBeforeUpdate are not implemented (no class-based error boundaries) |
useLayoutEffect | Fires synchronously before paint | Same as useEffect -- Pyreon has no paint distinction |
Signals .value | Native Preact Signals API | Wrapped Pyreon signals with the same .value interface |
version | Real Preact version | Reports 10.0.0-pyreon |
Reading State
useState returns the value directly and the component re-runs on state change -- exactly like Preact.
// Preact
const [count, setCount] = useState(0)
console.log(count) // 0
// @pyreon/preact-compat — identical
const [count, setCount] = useState(0)
console.log(count) // 0Closures follow Preact semantics
useState returns a plain value captured at render time, so a long-lived callback created in useEffect([]) sees the value from the render it was created in — exactly like Preact. Use the updater form to read the latest value, or include the value in the deps array to re-create the callback:
function Timer() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
// `count` here is the value from this render; use the updater form
// to read the latest:
setCount((prev) => prev + 1)
}, 1000)
return () => clearInterval(id)
}, [])
return <p>{count}</p>
}If you want reads that are always current regardless of closure age, use the signals subpath —
signal.valuealways returns the live value.
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 [value, setter] -- the value directly, exactly like Preact. The component re-runs on setState.
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 side effect after render. The deps array is honored (re-runs when a dep changes), exactly like Preact. Return a cleanup function for disposal.
Mount-only: Pass [] to run once on mount; the cleanup runs on unmount.
// Runs whenever `name` changes
useEffect(() => {
document.title = name
}, [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 the memoized value directly (recomputed when deps change) -- exactly like Preact.
const [items, setItems] = useState([1, 2, 3])
const sum = useMemo(() => items.reduce((a, b) => a + b, 0), [items])
console.log(sum) // 6
// Filtered + sorted list
const [filter, setFilter] = useState('')
const filteredItems = useMemo(() => items.filter((item) => item.name.includes(filter)), [items, filter])
const sortedItems = useMemo(() => [...filteredItems].sort((a, b) => a.name.localeCompare(b.name)), [filteredItems])useCallback
function useCallback<T extends (...args: unknown[]) => unknown>(fn: T, deps: unknown[]): TMemoizes fn across re-renders, returning the same reference until a dep changes -- exactly like Preact (useCallback(fn, deps) is useMemo(() => fn, deps)).
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 [state, dispatch] -- the state value directly, exactly like Preact. Dispatch applies the reducer and re-renders the component.
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.Hook state reads are unchanged --
useStatereturns the value directly (count, notcount()), and deps arrays are honored, so most hook code ports as-is.Lift any state that must survive an ancestor re-render -- a parent re-render rebuilds the child subtree, resetting nested
useStateand re-firinguseEffect([]).Replace class-based error boundaries (
componentDidCatch/getDerivedStateFromError) with anErrorBoundarycomponent -- those class lifecycle methods are not implemented (the other four lifecycle methods do fire).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.