This guide is the deep technical reference for Preact — its architecture, internals, build pipeline, module map, security posture, code conventions, and contribution workflow. It is written for library maintainers, framework authors, integrators embedding Preact, and engineers who need to understand precisely how Preact's renderer works. Everything here is derived directly from the Preact source at the documented commit.
Important
This guide covers Preact v11.0.0-beta.1 (the
mainbranch — the in-development next major). Internals such as theoptionshook names, vnode flags, and the diff algorithm differ from stable v10. For production integrations, target thev10.xbranch unless you are specifically building against v11.
For the end-user/application-developer API (components, hooks, JSX, compat usage), see the companion User Guide.
Table of Contents
- Executive Summary
- Architecture Overview
- Repository Structure
- The Rendering Pipeline (Deep Dive)
- The
optionsHook System - Module Reference
- The Compat Layer Internals
- Build System & Distribution
- Testing Strategy
- Code Quality & Conventions
- Security Analysis
- Integration & Deployment Concerns
- Known Issues & Roadmap
- Potential Improvements
- Contributing
- Quick Reference
1. Executive Summary
Preact is a ~4 kB Virtual DOM library that implements React's modern component API in a fraction of the size. It is not a fork of React; it is an independent, from-scratch implementation whose public surface deliberately mirrors React so that knowledge and (via preact/compat) code transfer directly.
| Attribute | Value |
|---|---|
| Package | preact |
| Version (this guide) | 11.0.0-beta.1 (pre-release main) |
| License | MIT |
| Runtime dependencies | None (core) |
| Language | JavaScript (ES modules), shipped with hand-authored TypeScript definitions |
| Module formats | CJS, ESM, UMD (per sub-package) |
| Build tool | microbundle |
| Test runner | Vitest (browser mode via Playwright/Chromium) + tsc type tests |
| Source size | ~877 KB across 282 files; core engine ~158 KB |
| Homepage | https://preactjs.com |
| Repository | https://github.com/preactjs/preact |
Design philosophy. Three properties drive nearly every implementation decision:
- Minimal size. The codebase aggressively golfs bytes — bitfield flags instead of booleans, shared
EMPTY_OBJ/EMPTY_ARRsingletons,null/undefinedconstants reused by name, andArray.prototype.someused as a short-circuitableforEach. - Extensibility via
options. All add-ons (hooks,compat,debug,devtools) are built by patching a small set of renderer hook points on a sharedoptionsobject, rather than by forking the core. This is the single most important architectural idea in the project. - React API fidelity. The public API matches React's;
preact/compatcloses the remaining gaps (DOM prop normalization, synthetic-event shims,defaultProps, portals, Suspense, etc.).
2. Architecture Overview
Application code (JSX)
│ compiled to h()/jsx()
▼
┌───────────────────────────────────────────────────────────┐
│ preact (core) │
│ │
│ create-element.js ──► VNode objects │
│ │ │
│ render.js / hydrate ──► diff/index.js (diff) │
│ │ │ │
│ │ ├─ diff/children.js (keyed) │
│ │ ├─ diff/props.js (DOM attrs)│
│ │ └─ diff/catch-error.js │
│ ▼ │
│ component.js (BaseComponent, setState, enqueueRender, │
│ microtask render queue, depth sort) │
│ │ │
│ options.js ◄── the extension seam (hook registry) │
└───────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲
│ │ │ │ (each patches options.*)
preact/hooks preact/compat preact/debug preact/devtools
(state + (React API + (dev warnings (DevTools
effects) DOM norm + + validation) bridge)
Suspense)
▲
│
preact/jsx-runtime (Babel automatic transform)
preact/compat/server (SSR via preact-render-to-string)
preact/test-utils (act/rerender/teardown)
The core owns VNode creation, the diff/commit cycle, the asynchronous render scheduler, and the options registry. Every other package is a consumer of options — it imports options from preact and wraps the existing hook functions, chaining to the previous implementation. This is how hooks, devtools, compat, and debug all coexist and compose without modifying core.
3. Repository Structure
The repository is a single npm package that publishes multiple entry points, each with its own src/, dist/, and package.json (used only for the per-entry module fields), wired together by the root package.json exports map.
preact/
├── src/ Core engine (h, render, diff, Component, options, context)
│ └── diff/ Diffing: index.js, children.js, props.js, catch-error.js
├── hooks/src/ preact/hooks — useState, useEffect, …
├── compat/src/ preact/compat — React compatibility layer
│ ├── suspense.js Suspense + lazy
│ ├── portals.js createPortal
│ ├── memo.js memo
│ ├── forwardRef.js forwardRef
│ ├── PureComponent.js PureComponent
│ ├── Children.js Children utilities
│ ├── render.js DOM prop normalization + event shims + render proxy
│ └── hooks.js useTransition/useDeferredValue/useSyncExternalStore shims
├── compat/ server.js, server.browser.js, scheduler.js, client.* (SSR + shims)
├── debug/src/ preact/debug — dev validation + component stacks
├── devtools/src/ preact/devtools — DevTools bridge
├── jsx-runtime/src/ preact/jsx-runtime — automatic JSX transform
├── test-utils/src/ preact/test-utils — act/rerender/teardown
├── test/ Browser, node, ts, and shared test suites
├── demo/ Local development demos (not published)
├── benchmarks/ Performance benchmarks (not published)
├── config/ compat-entries.js (postbuild entry generation)
├── package.json Root manifest + exports map
├── babel.config.js JSX transform config for tests
├── biome.json Formatter config
├── vitest.config.mjs Test runner config
├── mangle.json Property-mangling rules (size optimization)
└── jsconfig*.json Type-check configs
Note
src/,hooks/src/,compat/src/, etc. are the product.demo/,benchmarks/, andtest/dominate the file count but are development-only and excluded from the published package'sfilesallowlist. The published artifacts are thedist/bundles plus thesrc/sources and.d.tsfiles.
Published entry points (exports map)
| Specifier | Source | Notes |
|---|---|---|
preact |
src/index.js |
Core. Types: src/index.d.ts. |
preact/hooks |
hooks/src/index.js |
|
preact/compat |
compat/src/index.js |
Aliasable as react/react-dom. |
preact/debug |
debug/src/index.js |
Dev only. |
preact/devtools |
devtools/src/index.js |
|
preact/test-utils & preact/compat/test-utils |
test-utils/src/index.js |
|
preact/jsx-runtime & preact/jsx-dev-runtime |
jsx-runtime/src/index.js |
|
preact/compat/jsx-runtime, …/jsx-dev-runtime |
compat/jsx-runtime.* |
React-source JSX wiring. |
preact/compat/server, …/server.browser |
compat/server.* |
SSR (delegates to preact-render-to-string). |
preact/compat/scheduler |
compat/scheduler.* |
React scheduler shim. |
preact/compat/client |
compat/client.* |
react-dom/client shim. |
Each exports entry resolves types, module/import (ESM .mjs), umd, and require (CJS .js) conditions.
4. The Rendering Pipeline (Deep Dive)
VNode shape
createElement/h extract key and ref from props, then call createVNode, which always allocates an object of the same shape (so V8 can use a single hidden class):
const vnode = {
type, props, key, ref,
_children: null, _parent: null, _depth: 0,
_dom: null, _component: null,
constructor: undefined, // set to undefined to prevent JSON injection
_original: ++vnodeId, _index: -1, _flags: 0
};
constructor: undefined is a security measure: isValidElement checks vnode.constructor === undefined, so a JSON payload (whose constructor is Object) can never masquerade as a VNode. See Security Analysis.
render(vnode, parent) flow
- If
parent === document, retarget todocument.documentElement(issue #3794). - Fire
options._root(vnode, parent)if registered. - Determine hydration mode from the vnode's
MODE_HYDRATEflag. - Retrieve the previous tree from
parent._children(Preact stores the last-rendered tree on the container's_childrenproperty — this is how repeatedrender()calls update in place). - Wrap the new vnode in a
Fragmentand store it onparent._children. - Call
diff(...)with a freshcommitQueueandrefQueue. - Call
commitRoot(commitQueue, parent._children, refQueue)to apply refs and run lifecycle/effect callbacks.
hydrate(vnode, parent) simply sets the MODE_HYDRATE flag on the vnode and delegates to render.
The diff() function (src/diff/index.js)
diff() is the heart of the renderer. For a given new/old vnode pair it:
- Bails out early when
newVNode.constructor !== undefined(injection guard). - Resumes suspended hydration when the old vnode carries
MODE_SUSPENDED | MODE_HYDRATE. - Function/class components: instantiates or reuses the component, runs
getDerivedStateFromProps, the pre-render lifecycle methods,shouldComponentUpdate(bail-out path), then callsrender(). Function components are backed by aBaseComponentwhoserenderis a thunk (doRender) calling the function. HandlessetStatecalled during render via a loop capped at 25 iterations. - DOM/text nodes: delegates to
diffElementNodes, which creates/locates the element, diffs props (setPropertyindiff/props.js), tracks SVG/MathML namespaces, handlesdangerouslySetInnerHTML, and recurses intodiffChildren. - Fires
options._diff,options._render, andoptions.diffedhooks at the appropriate phases. - Routes thrown errors (including thrown promises for Suspense) to
options._catchError.
Keyed children diffing (src/diff/children.js)
diffChildren matches old and new children by key (and type), using vnode _flags (INSERT_VNODE, MATCHED) to mark insertions and matches, minimizing DOM moves on reorder. getDomSibling (in component.js) locates the correct DOM insertion point by walking the vnode tree.
Component state & the render scheduler (src/component.js)
BaseComponent provides setState and forceUpdate:
setState(update, callback?)clonesstateinto_nextState(once), merges the update (calling it as a function with a cloned state if it's a function — to play nice with Immer's frozen state, issue #2716), pushes any callback to_stateCallbacks, and callsenqueueRender(this).forceUpdate(callback?)sets theCOMPONENT_FORCEbit (soshouldComponentUpdateis skipped) and enqueues.
enqueueRender adds the component to a module-level rerenderQueue (guarded by the COMPONENT_DIRTY bit to avoid duplicates) and schedules a flush via options.debounceRendering or queueMicrotask. The flush (process) sorts the queue by vnode depth (depthSort) so parents render before children, then re-renders each still-dirty component. New components added mid-flush (e.g. via a provider update) are kept in depth order for single-pass handling.
Note
This asynchronous, depth-sorted, microtask-batched scheduler is what makes
setStatecalls coalesce into a single re-render per tick and guarantees top-down render order.
Commit & refs
commitRoot applies queued refs (applyRef), fires options._commit, then drains each component's _renderCallbacks (lifecycle callbacks like componentDidMount/componentDidUpdate and effect runners installed by hooks). applyRef supports both function refs (storing a returned cleanup on the function itself) and object refs (ref.current = value). unmount tears down a subtree, invoking componentWillUnmount, detaching listeners, and clearing pointers.
5. The options Hook System
options (from src/options.js) is a single shared object — initially just { _catchError }. The core calls optional hook functions on it at well-defined points; add-ons register by overwriting a hook and chaining to the previous value.
Public option hooks (Options interface)
| Hook | Fired when |
|---|---|
vnode(vnode) |
Every time a VNode is created. |
unmount(vnode) |
Immediately before a vnode unmounts. |
diffed(vnode) |
After a vnode has rendered/diffed. |
event(e) |
When a DOM event is dispatched (compat shims synthetic-event methods here). |
requestAnimationFrame(cb) |
Scheduler override for after-paint effects. |
debounceRendering(cb) |
Override the render-queue flush scheduler. |
useDebugValue(value) |
Forward custom-hook debug labels to DevTools. |
attr(name, value) |
Customize attribute serialization for the precompiled JSX transform. |
Internal (underscore-prefixed) hooks
These are private but are the extension points the first-party add-ons use: _catchError, _diff (before diff), _render (before component render), _commit, _root, _hook (on each hook invocation), _hydrationMismatch, _addHookName, __suspenseDidResolve. They are mangled in production builds (see mangle.json) and are not a stable public API.
Caution
Do not depend on the internal underscore-prefixed
optionshooks or vnode fields from application code. They are mangled, unstable across versions, and exist solely so thathooks/compat/debug/devtoolscan extend core. Build on the publicOptionsinterface only.
Example of the pattern (preact/hooks installing its effect machinery):
import { options as _options } from 'preact';
const options = _options;
let oldBeforeRender = options._render;
options._render = vnode => {
if (oldBeforeRender) oldBeforeRender(vnode); // chain
// …hooks-specific logic…
};
Every add-on follows this "save old, install new, call old" discipline so multiple packages can layer hooks on the same point.
6. Module Reference
Core (src/)
| File | Responsibility |
|---|---|
index.js |
Public barrel — re-exports render, hydrate, h/createElement, Fragment, createRef, isValidElement, Component, cloneElement, createContext, toChildArray, options. |
create-element.js |
createElement/h, createVNode, createRef, Fragment, isValidElement. |
component.js |
BaseComponent (setState/forceUpdate), enqueueRender, render queue + process, getDomSibling, renderComponent. |
render.js |
render/hydrate entry points. |
clone-element.js |
cloneElement. |
create-context.js |
createContext (provider/consumer via subscriber Set, shouldComponentUpdate-driven propagation). |
options.js |
The shared options registry (seeded with _catchError). |
constants.js |
Bitfield flags (MODE_HYDRATE, MODE_SUSPENDED, INSERT_VNODE, MATCHED, FORCE_PROPS_REVALIDATE, COMPONENT_*), namespace URIs (SVG/XHTML/MathML), shared NULL/UNDEFINED/EMPTY_OBJ/EMPTY_ARR singletons. |
util.js |
isArray, slice, assign (= Object.assign), removeNode. |
diff/index.js |
diff, commitRoot, diffElementNodes, applyRef, unmount. |
diff/children.js |
diffChildren (keyed reconciliation), toChildArray. |
diff/props.js |
setProperty — applies DOM attributes, events, styles. |
diff/catch-error.js |
_catchError — walks up to the nearest error boundary / getDerivedStateFromError / componentDidCatch. |
index.d.ts, dom.d.ts, jsx.d.ts, internal.d.ts |
Public and internal type definitions. dom.d.ts carries the exhaustive HTML/SVG attribute and event-handler type surface, including Signalish<T> for @preact/signals interop. |
Hooks (hooks/src/)
index.js installs options._diff, _render, diffed, _commit, unmount, _root hooks to drive a per-component hook list (__hooks._list) and effect queue (_pendingEffects). Hook types are numbered (getHookState(index, type)): 1 useState, 2 useReducer, 3 useEffect, 4 useLayoutEffect, 5 useRef, 6 useImperativeHandle, 7 useMemo, 8 useCallback, 9 useContext, 10 useErrorBoundary, 11 useId. Effects run after paint via afterNextFrame (rAF + 35 ms setTimeout fallback for background tabs).
Diff sub-modules, compat, debug, devtools, jsx-runtime, test-utils
Covered in their dedicated sections (Compat Internals) and the User Guide. devtools/src is a thin bridge that initializes the DevTools connection and exposes addHookName. jsx-runtime/src implements jsx/jsxs/jsxDEV (all aliased to one optimized createVNode) plus the precompiled-transform helpers jsxTemplate/jsxAttr/jsxEscape.
7. The Compat Layer Internals
preact/compat (compat/src/) is where React fidelity lives. It is the most behaviorally dense add-on.
compat/src/render.js — normalization seam
Installs several options hooks:
options.vnode— setsvnode.$$typeof = Symbol.for('react.element')on every vnode (soreact-ischecks pass), appliesdefaultProps, movesreffrom props to the vnode field for class components, and callshandleDomVNodefor string-typed nodes.handleDomVNode— the prop-normalization table:className⇄class(with a non-enumerableclassNamegetter),onChange→oninputfor textinput/textarea(except file/checkbox/radio),onDoubleClick→ondblclick,onFocus/onBlur→onfocusin/onfocusout, camelCase SVG props → dashed (CAMEL_PROPSregex), numericstylevalues →px(unless matched byIS_NON_DIMENSIONAL),defaultValue/defaultCheckedfallbacks,download={true}→"",translate="no"→false, and arrayvaluefor<select multiple>.options.event— adds React synthetic-event shims:persist()(no-op),nativeEvent,isPropagationStopped(),isDefaultPrevented().options._render/diffed— trackcurrentComponent(forreact-relay) and sync<textarea>value.Component.prototype.isReactComponent = trueandUNSAFE_*lifecycle aliasing viaObject.definePropertygetters/setters.render/hydrateproxies — accept an optionalcallback, return the root component reference, and clear existing container DOM (textContent = '') on first render (React parity, issues #1727/#1828).- Exposes
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIREDwith aReactCurrentDispatcherfor libraries that reach into React internals.
Suspense & lazy (compat/src/suspense.js)
Suspense is a hand-rolled Component subclass (custom prototype assignment to save bytes). It patches options._catchError to intercept thrown promises: it walks up to the nearest component with _childDidSuspend, parks the suspended subtree (cloning it into a detached <div> so its effects/cleanups pause), renders the fallback, and restores + forceUpdates the real subtree when the promise resolves. During hydration it keeps the SSR markup on screen instead of swapping in the fallback. lazy(loader) returns a component that throws the load promise until it resolves, then renders exports.default || exports; it caches the promise/result/error across renders.
Other compat modules
| Module | Behavior |
|---|---|
memo.js |
Wraps a component with a shouldComponentUpdate using shallowDiffers (or a custom comparer); sets displayName = 'Memo(...)' and _forwarded/isReactComponent. |
PureComponent.js |
Component subclass with shallowDiffers-based shouldComponentUpdate; sets isPureReactComponent. |
forwardRef.js |
Wraps a render fn, passing props.ref as the second arg; tags it with Symbol.for('react.forward_ref') and a render property (mobx-react relies on this). |
portals.js |
createPortal renders into a "fake" DOM parent that proxies insertBefore to the real container, wrapped in a ContextProvider so context flows across the portal. |
Children.js |
map/forEach/count/only/toArray over the (flattened) children — mostly passthrough to toChildArray. |
hooks.js |
React 18 concurrency shims: startTransition runs synchronously, useDeferredValue returns its argument, useTransition returns [false, startTransition], useInsertionEffect = useLayoutEffect, and a real useSyncExternalStore implementation. |
8. Build System & Distribution
Toolchain
| Concern | Tool |
|---|---|
| Bundler | microbundle (per-package, --raw --no-generateTypes -f cjs,esm,umd) |
| Formatter | Biome (biome format) |
| Linter | oxlint + tsc type-checking (jsconfig-lint.json) |
| Tests | Vitest (browser mode via Playwright Chromium) + tsc type tests |
| JSX (tests) | Babel (@babel/plugin-transform-react-jsx) |
| Size tracking | gzip/brotli badges via badgesize/unpkg (README) |
| Node version | pinned to 20.19.1 via Volta |
npm scripts
| Script | Action |
|---|---|
build |
Runs all build:* in parallel (core, debug, devtools, hooks, test-utils, compat, jsx), then postbuild. |
build:compat |
microbundle with --globals 'preact/hooks=preactHooks' (UMD global mapping). |
postbuild |
node ./config/compat-entries.js generates the compat sub-entry files. |
dev, dev:hooks, dev:compat |
microbundle watch mode. |
test |
build → lint → test:unit. |
test:unit |
Vitest (minified run) + TS type tests in parallel. |
test:vitest / :min |
Vitest run (with COVERAGE/MINIFY env flags). |
test:ts:core / :compat |
tsc -p against the test/ts and compat/test/ts projects. |
lint |
oxlint + tsc. |
format / format:check |
Biome formatting. |
Property mangling (mangle.json)
To hit the 4 kB target, internal property names (the underscore-prefixed vnode/component/options fields) are renamed at build time via babel-plugin-transform-rename-properties. Each sub-package has its own mangle.json so internal names stay consistent across the core and add-ons that share the renamed shapes.
Important
Because internal fields are mangled, source-level field names (
_children,_component,_diff, …) shown in this guide are the unmangled development names. Never reference them from outside the library — in a production bundle they will have short, unstable names.
Distribution artifacts
The published files allowlist ships, per entry point: dist/*.{js,mjs,umd.js}, the src/ sources, the .d.ts types, and the per-entry package.json. main/module/umd:main/source/types plus the exports map route consumers to the right artifact for their module system. publishConfig.provenance: true enables npm build provenance attestation.
9. Testing Strategy
| Suite | Location | Tool | Purpose |
|---|---|---|---|
| Browser behavior | test/browser/ |
Vitest + Playwright Chromium | The bulk of coverage — rendering, lifecycles, keys, focus, hydration, SVG/MathML, events, refs, fragments. |
| Lifecycle matrix | test/browser/lifecycles/ |
Vitest | Each lifecycle method individually. |
| Node/build artifact | test/node/ |
Vitest + chai | Asserts the built bundle exports the expected public API (createElement, h, Component, render, hydrate). |
| Shared | test/shared/ |
Vitest | createElement/createContext/isValidElement semantics shared across environments. |
| TypeScript types | test/ts/, compat/test/ts/ |
tsc |
Compile-time type assertions (*.test-d.tsx) for the .d.ts surface, including JSX namespace, DOM attributes, refs, HOCs. |
| Per-package | hooks/test/, compat/test/, debug/test/, devtools/test/, jsx-runtime/test/, test-utils/test/ |
Vitest | Each add-on's own suite. |
Tests use preact/test-utils' act/setupRerender/teardown to flush the async scheduler deterministically. Coverage is collected with @vitest/coverage-v8 and reported to Coveralls.
Tip
Tests run against the minified build by default (
test:vitest:min,MINIFY=true). This catches mangling/minification regressions that a source-only test run would miss — a deliberate choice for a size-critical library.
10. Code Quality & Conventions
Byte-golfing idioms (intentional, pervasive):
- Bitfield flags on
vnode._flagsandcomponent._bitsinstead of multiple booleans (constants.js). Array.prototype.someused as a short-circuitable iterator (returning truthy to stop).- Shared singletons
EMPTY_OBJ,EMPTY_ARR, and namedNULL/UNDEFINEDconstants (mangler renames them to short tokens; reusing names improves compression). - Same-shape VNode allocation from a single call site so V8 keeps one hidden class.
- Custom prototype assignment (
Suspense.prototype = new Component()) instead ofclass extendsto shave bytes.
Conventions:
- Pure ES modules, no transpiled
classin core (@ts-ignorenotes "we don't use TS to downtranspile"). - Type safety via JSDoc
@type/@paramannotations checked bytsc(jsconfig-lint.json) — the source is.js, not.ts, but fully type-checked. - Formatting enforced by Biome; linting by oxlint; pre-commit via
nano-staged+ Husky. - Hand-authored
.d.tsfiles (not generated) give precise public types, including an exhaustive DOM attribute/event surface indom.d.tsand@preact/signalsinterop via theSignalish<T>type.
Complexity hotspots: src/diff/index.js (the diff function, ~330 lines, deeply branched between component and element paths, hydration, and Suspense bailouts) and src/diff/children.js (keyed reconciliation) are the highest-complexity, most safety-critical files. They are also the most thoroughly tested.
11. Security Analysis
Preact is a client-side rendering library with no network, filesystem, auth, or secret-handling surface — its threat model is XSS/DOM-injection within the rendering path. The relevant, source-grounded items:
| Area | Finding |
|---|---|
| JSON / prototype injection | createVNode sets constructor: undefined, and isValidElement/diff reject any object whose constructor !== undefined. This prevents arbitrary JSON (whose constructor is Object) from being treated as a renderable VNode — a deliberate injection guard. |
dangerouslySetInnerHTML |
Honored verbatim in diffElementNodes (sets dom.innerHTML). This is the primary XSS vector in any VDOM library: never pass untrusted/unsanitized HTML. Sanitize upstream. |
| Text escaping | Normal children are set via dom.data/text nodes (not innerHTML), so interpolated strings are inherently escaped. The SSR JSX-attr serializer (jsxAttr/encodeEntities in jsx-runtime) HTML-escapes attribute values. |
| Trusted Types | Conditional dangerouslySetInnerHTML can trip a Trusted Types policy (community issue #4934). When deploying under a strict Trusted-Types CSP, route HTML through an approved policy. |
| SES / lockdown | Running under SES lockdown() has surfaced a TypeError (issue #5109). Test Preact explicitly if you ship into a hardened/locked-down realm. |
No eval/dynamic code |
The core does not use eval, Function(), or dynamic import of remote code. lazy() imports modules your own code references. |
| Supply chain | Zero runtime dependencies in core — a minimal supply-chain surface. SSR adds preact-render-to-string. npm provenance attestation is enabled (publishConfig.provenance). |
Caution
The single highest-risk API is
dangerouslySetInnerHTML. Treat any value flowing into it as untrusted by default and sanitize with a vetted sanitizer (e.g. DOMPurify) before render. Thedangerouslyprefix is intentional.
Note
preact/debug's_catchErrorre-throws handled errors on asetTimeoutto emit awindowerror event (so dev overlays like Next.js work). This is development-only behavior and must not ship to production.
12. Integration & Deployment Concerns
React drop-in via aliasing
Map react and react-dom to preact/compat (and react/jsx-runtime to preact/jsx-runtime) in your bundler. Most React libraries then work unchanged. Edge cases arise with libraries that depend on React-internal behavior or precise event timing — known community reports include floating-ui event processing differences (#4373), @radix-ui slider/tooltip behaviors (#3666, #3805), and jsx-email rendering (#5106). Validate ecosystem libraries against your app.
Bundler configuration
- Ensure tree-shaking is enabled so unused sub-packages drop out.
- Use the
import/module(ESM) conditions for the smallest output; theumdbuild is only for script-tag usage. - For TypeScript drop-in, set
pathsforreact/react-domand the JSXjsxImportSource.
SSR deployment
Use preact/compat/server's renderToString (sync) or the streaming functions for SSR; require preact-render-to-string >= 6.5.0 for streaming. Hydrate on the client with hydrate(). Keep server and client renders deterministic to avoid hydration mismatches (use useId() for IDs).
Production hardening checklist
- [ ]
preact/debugis excluded from production bundles. - [ ] All
dangerouslySetInnerHTMLinputs are sanitized. - [ ] A Content-Security-Policy (and Trusted Types policy, if used) is configured and tested with Preact.
- [ ] SSR output is deterministic (no
Date.now()/Math.random()in first render). - [ ]
preact/devtoolsis gated to the environments where you want DevTools. - [ ] React aliases (if using compat) resolve correctly in the production build, not just dev.
13. Known Issues & Roadmap
From the community issue tracker at the documented commit (representative, not exhaustive):
Confirmed bugs
| Issue | Summary |
|---|---|
| #5109 | TypeError when SES lockdown is enabled. |
| #4737 | <select> value update fails when set to a newly added <option> simultaneously. |
| #3242 | Focus lost during list re-order. |
React-compat parity requests
| Issue | Summary |
|---|---|
| #4977 | Support React 19 <Activity />. |
| #4756 | Support use(promise) in preact/compat. |
| #4972 | useSyncExternalStore third argument (getServerSnapshot). |
| #4927 | useEffectEvent. |
| #4994 | JSX types reflect core + compat even when compat is unused. |
| #4299 | Run unmount hooks during commit phase for React consistency. |
Forward-looking
| Issue | Summary |
|---|---|
| #2621 | "The road to Preact 11" — the umbrella tracking issue for the major this guide documents. |
| #5034 | RFC: Streaming SSR hydration coordination for Preact core. |
| #4934 | Conditional dangerouslySetInnerHTML + Trusted Types. |
Important
Because this is the v11 pre-release branch, expect API churn and incomplete features relative to stable v10. Track the "road to Preact 11" issue (#2621) and the release notes before adopting in production.
14. Potential Improvements
Observations for maintainers and integrators (derived from the source, not prescriptive):
- Core render callback parity. v11 core
render/hydratedropped the thirdcallbackargument (it now lives only in compat). Migration guidance / a codemod would smooth the v10→v11 transition for apps that usedrender(vnode, parent, cb). - JSX type split (#4994). The published types merge core and compat surfaces; splitting them would prevent compat-only APIs from type-checking in plain-Preact apps.
- Trusted Types support. First-class Trusted-Types-aware HTML handling (beyond raw
innerHTML) would help strict-CSP deployments (#4934). - SES compatibility. Hardening against
lockdown()realms (#5109) would broaden where Preact can run. - Streaming hydration coordination (#5034). Bringing streaming-SSR hydration into core (rather than relying solely on
preact-render-to-string) is an active RFC.
15. Contributing
| Step | Command |
|---|---|
| Install (pins Node via Volta, installs Playwright Chromium) | npm install |
| Run the demo app | npm run dev (microbundle watch) |
| Build all packages | npm run build |
| Lint + typecheck | npm run lint |
| Format | npm run format |
| Full test (build + lint + unit) | npm test |
| Unit tests only | npm run test:unit |
| Type tests | npm run test:ts |
Conventions to follow when contributing:
- Author in ES modules with JSDoc types; the source is type-checked by
tsceven though it's.js. - Keep bundle size in mind — size is a first-class review gate (gzip/brotli badges track it).
- Add tests under the matching suite (
test/browser, per-package*/test, and*.test-d.tsxfor types). - Run Biome format and oxlint before committing (enforced via Husky + nano-staged pre-commit).
- New renderer behavior almost always means a new or modified
optionshook interaction — preserve the "save old, install new, chain" pattern.
See CONTRIBUTING.md in the repository for the full process.
16. Quick Reference
Vnode flags (constants.js)
| Flag | Bit | Meaning |
|---|---|---|
MODE_HYDRATE |
1<<5 |
Attaching to existing DOM without diffing. |
MODE_SUSPENDED |
1<<7 |
Suspended on the previous render. |
INSERT_VNODE |
1<<2 |
Needs insertion during child patching. |
MATCHED |
1<<1 |
Matched with another vnode during diff. |
FORCE_PROPS_REVALIDATE |
1<<0 |
Re-validate props (post-suspense). |
Component bits
| Bit | Value | Meaning |
|---|---|---|
COMPONENT_PROCESSING_EXCEPTION |
1<<0 |
Processing an exception. |
COMPONENT_PENDING_ERROR |
1<<1 |
Has a pending error. |
COMPONENT_FORCE |
1<<2 |
Force update (skip shouldComponentUpdate). |
COMPONENT_DIRTY |
1<<3 |
Queued for update. |
Namespaces
| Constant | URI |
|---|---|
SVG_NAMESPACE |
http://www.w3.org/2000/svg |
XHTML_NAMESPACE |
http://www.w3.org/1999/xhtml |
MATH_NAMESPACE |
http://www.w3.org/1998/Math/MathML |
Entry-point cheat sheet
| Need | Import |
|---|---|
| Render UI | preact |
| State/effects in function components | preact/hooks |
| React API / ecosystem libs | preact/compat |
| Dev warnings + DevTools | preact/debug |
| DevTools in production | preact/devtools |
| Automatic JSX transform | preact/jsx-runtime |
| SSR | preact/compat/server (+ preact-render-to-string) |
| Tests | preact/test-utils |