Options API
The Options API is Preact’s extension mechanism that allows you to hook into the rendering lifecycle and customize its behavior. This is the foundation for addons like preact/debug, preact/compat, and preact/hooks.
The Options API is primarily for library authors and advanced use cases. Most application developers won’t need to use it directly.
Overview
The options object contains callback functions that Preact invokes at various stages of the rendering process:
import { options } from 'preact';
// Hook into the rendering lifecycle
options.vnode = (vnode) => {
console.log('VNode created:', vnode);
};
Source: src/options.js:1-17
Public Options Hooks
These hooks are part of Preact’s public API and safe to use:
vnode
Invoked whenever a VNode is created, before it’s processed:
options.vnode = (vnode) => {
// Modify or inspect vnodes as they're created
console.log('Creating:', vnode.type);
};
Type: (vnode: VNode) => void
Use cases:
- Inject additional props
- Track component creation
- Implement custom prop transformations
- Add metadata to vnodes
Source: src/index.d.ts:329
Example - Auto-inject props:
options.vnode = (vnode) => {
if (typeof vnode.type === 'function') {
// Add a debug ID to every component
vnode.props = {
...vnode.props,
__debugId: Math.random().toString(36)
};
}
};
diffed
Invoked after a vnode has been diffed and rendered:
options.diffed = (vnode) => {
// Called after component renders
console.log('Rendered:', vnode.type);
};
Type: (vnode: VNode) => void
Use cases:
- Track render completion
- Measure render performance
- Update external state after render
- Implement custom side effects
Source: src/index.d.ts:333
Example - Performance monitoring:
const renderTimes = new Map();
options.vnode = (vnode) => {
if (typeof vnode.type === 'function') {
vnode.startTime = performance.now();
}
};
options.diffed = (vnode) => {
if (vnode.startTime) {
const duration = performance.now() - vnode.startTime;
const name = vnode.type.displayName || vnode.type.name;
console.log(`${name} rendered in ${duration.toFixed(2)}ms`);
}
};
unmount
Invoked immediately before a vnode is unmounted:
options.unmount = (vnode) => {
// Cleanup before unmount
console.log('Unmounting:', vnode.type);
};
Type: (vnode: VNode) => void
Use cases:
- Custom cleanup logic
- Track component lifecycle
- Remove external references
- Clear caches or subscriptions
Source: src/index.d.ts:331
Example - Resource cleanup:
const activeResources = new WeakMap();
options.vnode = (vnode) => {
if (vnode.type === 'img') {
const resource = { src: vnode.props.src };
activeResources.set(vnode, resource);
}
};
options.unmount = (vnode) => {
const resource = activeResources.get(vnode);
if (resource) {
console.log('Cleaning up resource:', resource.src);
activeResources.delete(vnode);
}
};
event
Transform or handle events before they’re dispatched:
options.event = (event) => {
// Modify event before dispatch
console.log('Event:', event.type);
return event;
};
Type: (event: Event) => any
Use cases:
- Event normalization
- Analytics tracking
- Custom event handling
- Polyfill event properties
Source: src/index.d.ts:334
Example - Event analytics:
options.event = (event) => {
if (event.type === 'click') {
analytics.track('click', {
target: event.target.tagName,
timestamp: Date.now()
});
}
return event;
};
requestAnimationFrame
Customize how Preact schedules effects:
options.requestAnimationFrame = (callback) => {
// Custom scheduling
setTimeout(callback, 16);
};
Type: (callback: () => void) => void
Use cases:
- Custom scheduling strategies
- Testing (synchronous effects)
- Server-side rendering
- Performance optimization
Source: src/index.d.ts:335
Example - Synchronous effects for testing:
// In tests
options.requestAnimationFrame = (callback) => {
callback(); // Execute immediately instead of async
};
debounceRendering
Control when state updates trigger re-renders:
options.debounceRendering = (callback) => {
// Batch renders
setTimeout(callback, 0);
};
Type: (callback: () => void) => void
Use cases:
- Batch multiple state updates
- Control render timing
- Testing (synchronous renders)
- Performance optimization
Source: src/index.d.ts:336
Example - Synchronous rendering for tests:
// Store original
const originalDebounce = options.debounceRendering;
// In tests, render synchronously
options.debounceRendering = (callback) => callback();
// Restore after tests
afterEach(() => {
options.debounceRendering = originalDebounce;
});
useDebugValue
Custom display for hook values in DevTools:
options.useDebugValue = (value) => {
console.log('Hook value:', value);
};
Type: (value: string | number) => void
Use cases:
- Custom DevTools integration
- Debugging custom hooks
- Development logging
Source: src/index.d.ts:337
_addHookName
Add custom labels for hooks in DevTools:
options._addHookName = (name) => {
// Called by addHookName() from preact/devtools
console.log('Hook name:', name);
};
Type: (name: string | number) => void
Source: src/index.d.ts:338, devtools/src/index.js:11-12
__suspenseDidResolve
Callback when Suspense boundary resolves:
options.__suspenseDidResolve = (vnode, callback) => {
console.log('Suspense resolved:', vnode);
callback();
};
Type: (vnode: VNode, callback: () => void) => void
Use cases:
- Track Suspense states
- Custom loading behavior
- Analytics
Source: src/index.d.ts:339
Internal Options Hooks
These hooks are used internally and by official addons. They’re not officially public but are stable:
Internal hooks may change between minor versions. Use with caution and test thoroughly when upgrading Preact.
_root
Invoked before rendering begins:
options._root = (vnode, parentNode) => {
// Called by render() before starting
console.log('Render starting');
};
Type: (vnode: VNode, parentNode: Element) => void
Example from preact/debug:
options._root = (vnode, parentNode) => {
if (!parentNode) {
throw new Error(
'Undefined parent passed to render(), this is the second argument.'
);
}
// Validate parent is a valid DOM node
if (parentNode.nodeType !== 1 &&
parentNode.nodeType !== 11 &&
parentNode.nodeType !== 9) {
throw new Error('Expected a valid HTML node as second argument');
}
};
Source: src/internal.d.ts:29, debug/src/debug.js:119-146
_diff
Invoked before a vnode is diffed:
options._diff = (vnode) => {
// Validate vnode before diffing
if (vnode.type === undefined) {
throw new Error('Undefined component passed to createElement()');
}
};
Type: (vnode: VNode) => void
Example from preact/debug:
options._diff = (vnode) => {
// Validate component type
if (vnode.type === undefined) {
throw new Error(
'Undefined component passed to createElement()\n\n' +
'You likely forgot to export your component or might have ' +
'mixed up default and named imports'
);
}
// Validate ref
if (vnode.ref !== undefined &&
typeof vnode.ref !== 'function' &&
typeof vnode.ref !== 'object') {
throw new Error(
'Component\'s "ref" property should be a function or object'
);
}
};
Source: src/internal.d.ts:31, debug/src/debug.js:148-245
_render
Invoked before a component renders:
options._render = (vnode) => {
// Called right before component function/render executes
console.log('Rendering:', vnode.type);
};
Type: (vnode: VNode) => void
Example - Infinite loop detection:
let renderCount = 0;
let currentComponent;
options._render = (vnode) => {
const nextComponent = vnode._component;
if (nextComponent === currentComponent) {
renderCount++;
} else {
renderCount = 1;
}
if (renderCount >= 25) {
throw new Error('Too many re-renders. Infinite loop detected!');
}
currentComponent = nextComponent;
};
Source: src/internal.d.ts:35, debug/src/debug.js:249-272
_commit
Invoked after DOM updates are committed:
options._commit = (vnode, commitQueue) => {
// Called after DOM is updated
commitQueue.forEach(component => {
console.log('Committed:', component);
});
};
Type: (vnode: VNode, commitQueue: Component[]) => void
Use cases:
- Track DOM mutations
- Trigger side effects after paint
- Integration with other libraries
Source: src/internal.d.ts:33
_hook
Invoked when hooks are called:
import { HookType } from 'preact';
options._hook = (component, index, type) => {
if (type === HookType.useState) {
console.log('useState called at index', index);
}
};
Type: (component: Component, index: number, type: HookType) => void
Hook types:
HookType.useState = 1
HookType.useReducer = 2
HookType.useEffect = 3
HookType.useLayoutEffect = 4
HookType.useRef = 5
HookType.useImperativeHandle = 6
HookType.useMemo = 7
HookType.useCallback = 8
HookType.useContext = 9
HookType.useErrorBoundary = 10
HookType.useDebugValue = 11
Source: src/internal.d.ts:3-16, src/internal.d.ts:37
Example - Hook validation:
let hooksAllowed = false;
options._render = () => {
hooksAllowed = true;
};
options.diffed = () => {
hooksAllowed = false;
};
options._hook = (component, index, type) => {
if (!hooksAllowed) {
throw new Error('Hook can only be invoked from render methods.');
}
};
Source: debug/src/debug.js:59, debug/src/debug.js:274-280
_catchError
Called when an error is caught:
options._catchError = (error, vnode, oldVNode, errorInfo) => {
console.error('Error caught:', error);
console.log('Component stack:', errorInfo.componentStack);
};
Type: (error: any, vnode: VNode, oldVNode?: VNode, errorInfo?: ErrorInfo) => void
Example from preact/debug:
options._catchError = (error, vnode, oldVNode, errorInfo) => {
// Add component stack to error info
errorInfo = errorInfo || {};
errorInfo.componentStack = getOwnerStack(vnode);
// Check for missing Suspense boundary
if (typeof error.then === 'function') {
let parent = vnode;
let hasSuspense = false;
for (; parent; parent = parent._parent) {
if (parent._component && parent._component._childDidSuspend) {
hasSuspense = true;
break;
}
}
if (!hasSuspense) {
throw new Error(
`Missing Suspense. The throwing component was: ${getDisplayName(vnode)}`
);
}
}
};
Source: src/internal.d.ts:41-46, debug/src/debug.js:78-117
_hydrationMismatch
Called when hydration finds mismatched nodes:
options._hydrationMismatch = (vnode, excessDomChildren) => {
const expected = vnode.type;
const found = excessDomChildren
.map(child => child && child.localName)
.filter(Boolean);
console.error(
`Expected "${expected}" but found "${found.join(', ')}" during hydration`
);
};
Type: (vnode: VNode, excessDomChildren: Element[]) => void
Source: src/internal.d.ts:48-51, debug/src/debug.js:582-590
Chaining Options
When multiple libraries use the options API, they should chain previous hooks:
import { options } from 'preact';
// Save previous hook
const oldVNode = options.vnode;
// Install new hook that calls previous
options.vnode = (vnode) => {
// Your logic
console.log('My hook:', vnode.type);
// Call previous hook
if (oldVNode) oldVNode(vnode);
};
Example from preact/debug:
export function initDebug() {
// Save all previous hooks
let oldBeforeDiff = options._diff;
let oldDiffed = options.diffed;
let oldVnode = options.vnode;
let oldRender = options._render;
let oldCatchError = options._catchError;
let oldRoot = options._root;
let oldHook = options._hook;
// Install new hooks that call previous ones
options._diff = (vnode) => {
// Debug logic here
validateVNode(vnode);
// Call previous hook
if (oldBeforeDiff) oldBeforeDiff(vnode);
};
options.diffed = (vnode) => {
// Debug logic here
validateChildren(vnode);
// Call previous hook
if (oldDiffed) oldDiffed(vnode);
};
// ... more hooks
}
Source: debug/src/debug.js:56-68
Always chain previous hooks to avoid breaking other libraries that use the options API.
Here’s a complete example building a simple custom devtools:
import { options } from 'preact';
class SimpleDevtools {
constructor() {
this.componentTree = [];
this.renderTimes = new Map();
this.init();
}
init() {
// Chain existing hooks
const oldVNode = options.vnode;
const oldDiffed = options.diffed;
const oldUnmount = options.unmount;
// Track component creation
options.vnode = (vnode) => {
if (typeof vnode.type === 'function') {
vnode.startTime = performance.now();
this.componentTree.push({
name: vnode.type.displayName || vnode.type.name,
props: { ...vnode.props },
created: Date.now()
});
}
if (oldVNode) oldVNode(vnode);
};
// Track render completion
options.diffed = (vnode) => {
if (vnode.startTime) {
const duration = performance.now() - vnode.startTime;
const name = vnode.type.displayName || vnode.type.name;
if (!this.renderTimes.has(name)) {
this.renderTimes.set(name, []);
}
this.renderTimes.get(name).push(duration);
}
if (oldDiffed) oldDiffed(vnode);
};
// Track unmounts
options.unmount = (vnode) => {
if (typeof vnode.type === 'function') {
console.log('Unmounted:', vnode.type.name);
}
if (oldUnmount) oldUnmount(vnode);
};
}
getStats() {
const stats = {};
for (const [name, times] of this.renderTimes) {
const avg = times.reduce((a, b) => a + b, 0) / times.length;
stats[name] = {
renders: times.length,
avgTime: avg.toFixed(2),
totalTime: times.reduce((a, b) => a + b, 0).toFixed(2)
};
}
return stats;
}
}
// Usage
const devtools = new SimpleDevtools();
// Later, get stats
console.table(devtools.getStats());
Use Cases
The Options API enables:
- Debug tools: Validation and error reporting (preact/debug)
- DevTools integration: Component inspection (preact/devtools)
- React compatibility: React API emulation (preact/compat)
- Performance monitoring: Render tracking and profiling
- Analytics: User interaction tracking
- Testing utilities: Synchronous rendering (preact/test-utils)
- Custom renderers: Alternative render targets
- State management: External state integration
Best Practices
- Always chain hooks: Call previous hooks to support multiple libraries
- Minimize overhead: Keep hook logic lightweight
- Clean up: Remove hooks when done (restore previous values)
- TypeScript: Use proper types from
preact/src/internal.d.ts
- Test thoroughly: Options hooks affect all components
- Document: Make it clear your library uses options hooks
- Dev only: Consider only enabling in development mode
TypeScript Support
Extend the Options interface for custom hooks:
import 'preact';
declare module 'preact' {
export interface Options {
// Add your custom hook
_customHook?: (vnode: VNode) => void;
}
}
// Now TypeScript recognizes your custom hook
options._customHook = (vnode) => {
console.log('Custom hook called');
};
Options hooks are called frequently:
vnode: Every element/component creation
_diff: Before every diff
diffed: After every render
_render: Before every component render
Keep logic minimal to avoid performance impact:
// Bad: Expensive operation on every vnode
options.vnode = (vnode) => {
JSON.stringify(vnode); // Slow!
deepClone(vnode.props); // Slow!
};
// Good: Fast checks
options.vnode = (vnode) => {
if (vnode.type === MyComponent) {
// Only process specific components
}
};