pyreon

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

@pyreon/preact-compatstable

Installation

npm install @pyreon/preact-compat
bun add @pyreon/preact-compat
pnpm add @pyreon/preact-compat
yarn add @pyreon/preact-compat

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

BehaviorPreact@pyreon/preact-compat
Reactive engineVDOM diff + reconciliationPyreon signals driving a per-component re-render
Nested child state across an ancestor re-renderPreserved (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 lifecycleFull lifecyclecomponentDidMount / componentDidUpdate / componentWillUnmount / shouldComponentUpdate fire; componentDidCatch / getDerivedStateFromError / getDerivedStateFromProps / getSnapshotBeforeUpdate are not implemented (no class-based error boundaries)
useLayoutEffectFires synchronously before paintSame as useEffect -- Pyreon has no paint distinction
Signals .valueNative Preact Signals APIWrapped Pyreon signals with the same .value interface
versionReal Preact versionReports 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) // 0

Closures 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 subpathsignal.value always 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 API

Module Structure

@pyreon/preact-compat mirrors Preact's multi-module structure:

ImportProvides
@pyreon/preact-compatCore API: h, render, hydrate, Fragment, Component, createContext, createRef, cloneElement, toChildArray, isValidElement, options
@pyreon/preact-compat/hooksHooks: useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef, useReducer, useId, useContext, useErrorBoundary
@pyreon/preact-compat/signalsSignals: signal, computed, effect, batch

Core API (@pyreon/preact-compat)

h / createElement

function h(type: string | ComponentFn, props: Props | null, ...children: VNodeChild[]): VNode

Preact'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): void

Mounts 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): void

Hydrates 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[]): VNode

Clones 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 VNode

Returns 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: [] }) // true

createContext / 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[]): void

Runs 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[]): T

Returns 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[]): T

Memoizes 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 &#123; current &#125; 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(): string

Returns 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): void

Wraps 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) // 20

Chained 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) // 216

effect

function effect(fn: () => void | (() => void)): () => void

Runs 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): T

Groups multiple .value writes into a single reactive flush.

batch(() => {
  count.value = 10
  name.value = 'Alice'
})
// Effects that depend on both run only once

Migrating 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 identical

Handling 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 configuration

  • Libraries that access options._diff, options._commit, or other internal hooks will not receive notifications

  • Libraries that use __H (internal hooks state) or other underscore-prefixed internals will not work

Migration Checklist

  1. Replace preact imports with @pyreon/preact-compat, preact/hooks with @pyreon/preact-compat/hooks, and @preact/signals with @pyreon/preact-compat/signals.

  2. Hook state reads are unchanged -- useState returns the value directly (count, not count()), and deps arrays are honored, so most hook code ports as-is.

  3. Lift any state that must survive an ancestor re-render -- a parent re-render rebuilds the child subtree, resetting nested useState and re-firing useEffect([]).

  4. Replace class-based error boundaries (componentDidCatch / getDerivedStateFromError) with an ErrorBoundary component -- those class lifecycle methods are not implemented (the other four lifecycle methods do fire).

  5. Verify any options plugin code -- the options object is an empty stub.

  6. Check toChildArray usage -- should work identically.

  7. Test cloneElement usage -- props merging behavior is the same.

  8. Replace preact-router or other Preact-specific router with @pyreon/router.

  9. Test isValidElement -- checks for type, props, and children properties.

  10. Review any code that depends on re-render behavior -- Pyreon components run once; derive state reactively instead.

@pyreon/preact-compat