Custom Renderers
Build rEFui renderers for novel platforms: map your platform’s primitives to the small nodeOps interface and let signals drive updates. Prefer reusing a DOM shim (undom-ng) when possible; write a renderer only when you need platform-specific behavior. If your platform already exposes a DOM-like API, you can often reuse createDOMRenderer with a custom doc.
When to write one
- Your platform exposes node-like handles but no DOM (e.g., terminal UI, canvas scene graph, native UI toolkit).
- You need custom prop/event normalization or lifecycle hooks that differ from the DOM renderer.
If you already have a DOM-like API
- Supply a
docthat implementscreateElement,createElementNS,createTextNode,createComment,createDocumentFragment, and event methods. - Call
createDOMRenderer({ doc, rendererID, namespaces, tagNamespaceMap, tagAliases, propAliases, onDirective, macros }). - You inherit the DOM renderer’s behavior: signal-aware text nodes/props, event normalization (passive/once fallbacks), namespaces, aliases, and macros.
Minimal interface (nodeOps)
Implement these methods and pass them to createRenderer(nodeOps):
isNode(value): booleancreateNode(tag): NodecreateTextNode(text): NodecreateAnchor(name?): Node(used for comment/placeholder anchors)createFragment(name?): Fragment(can be a lightweight grouping/anchor)getParent(node): Node | Fragment | null(optional parent-ownership optimization)removeNode(node|fragment)clearChildren(parent, first, last): boolean(optional) clears all children only whenfirstandlastprove that the requested fragment owns the complete child rangeappendNode(parent, ...children)insertBefore(node, ref)setProps(node|fragment, props: Record<string, unknown>)
setProps receives host props with the renderer metadata keys $ref and
children omitted. When neither metadata key is present, it may receive the
caller's original props object. Make it idempotent and do not mutate that
object. Normalize events, styles, and platform-specific attributes here. Clean
up in removeNode when needed. When a prop value is a signal, subscribe and
update the native prop/handler on change (see DOM/HTML renderers).
getParent is optional. Without it, the renderer core keeps parent entries and
removes an already-parented node before calling the host append/insert method.
If it is implemented, return the node's current physical host parent (or
null when detached); the core then tracks only logical fragment ownership.
In that mode, appendNode and insertBefore must move already-parented
ordinary nodes, matching DOM behavior.
clearChildren is an optional performance capability, not a required tree
operation. Return false without changing the tree when the parent contains
anything before first or after last. When it returns true, the renderer
core restores the fragment anchors; the caller can then dispose the former
children normally. The DOM renderer implements this with textContent = '';
tree renderers can clear their child collection. Renderers that omit it
automatically keep the individual-removal fallback.
Rendering flow
const R = createRenderer(nodeOps)R.render(root, App)or use JSX (classic:jsxFactory: 'R.c',jsxFragment: 'R.f'; automatic:jsxImportSource: 'refui').- Signals drive retained updates; only touched nodes call
setProps/append/remove.
Fragments
If the platform lacks fragments, return a lightweight child container from
createFragment. The renderer core recognizes and expands fragments it creates;
the host appendNode/insertBefore operations only need to move or flatten the
raw fragment container they receive.
Props and events
- Events: detect keys like
on:clickor normalize your own; attach/detach listeners insetProps. - Styles: map objects/strings to platform styling APIs.
- Refs:
$refreceives your node/handle; ensure it’s stable. - Text/props reactivity: follow DOM/HTML renderers—subscribe when values are signals so text/content/props update automatically.
Suggested structure
import { createRenderer, isSignal, watch } from 'refui'
const nodeOps = {
isNode: (n) => !!n && n.type === 'node',
createNode: (tag) => platformCreate(tag),
createTextNode(text) {
// mirror DOM/HTML behavior: track signal text with an owned effect
if (isSignal(text)) {
const n = platformCreateText('')
watch(() => platformSetText(n, String(text.get() ?? '')))
return n
}
return platformCreateText(String(text ?? ''))
},
createAnchor: () => platformCreateComment(''),
createFragment: () => platformCreateFragment(),
getParent: (node) => platformParent(node), // optional
removeNode: platformRemove,
clearChildren(parent, first, last) {
if (!platformOwnsCompleteRange(parent, first, last)) return false
platformClearChildren(parent)
return true
},
appendNode(parent, ...kids) { kids.forEach(k => platformAppend(parent, k)) },
insertBefore(node, ref) { platformInsertBefore(node, ref) },
setProps(node, props) { platformSetProps(node, props) }
}
export const R = createRenderer(nodeOps)
Prefer shims when available
- DOM-like APIs: undom-ng
- NativeScript: DOMiNATIVE
- Embedded/desktop: Resonance runtime (LVGL/Dear ImGui) + undom-ng
Debugging tips
- Log every
nodeOpscall initially (like the console renderer example) to verify ordering. - Ensure
setPropshandles prop removal (unset handlers/styles). - If updates don’t appear, check
isNode/isFragmentguards—they gate all tree ops.
Custom Render Targets
If you have a DOM-like API available on your target platform, you can often pass its doc implementation to createDOMRenderer instead of building a full nodeOps implementation from scratch. This allows you to leverage the existing DOM renderer logic for reactive properties and text.