Custom Renderers
Crank's custom renderer API allows you to render components to any target
environment beyond the DOM. Whether you're building games with Canvas/WebGL,
creating terminal UIs, generating images, building emails, or interfacing with
any other graphics system, Crank's RenderAdapter
interface provides the
foundation.
Overview
A custom renderer consists of two main parts:
- RenderAdapter - A set of functions which determine how elements map to your target environment
- Renderer() class - An class which you subclass which orchestrates the rendering process and manages the component tree
import {Renderer, type RenderAdapter} from "@b9g/crank";
export const adapter: RenderAdapter<MyNode, MyScope> = {
create: ({tag, props}) => new MyNode(tag, props),
patch: ({node, props}) => node.update(props),
arrange: ({node, children}) => node.replaceChildren(children),
// ... other methods
};
export class MyRenderer extends Renderer<MyNode, MyScope, MyRoot> {
constructor() {
super(adapter);
}
}
export const renderer = new MyRenderer();
A module which implements a custom renderer should by convention export an adapter, the subclass, and an instance of the subclass for convenience.
RenderAdapter Interface
The RenderAdapter
interface defines how Crank elements are turned into nodes
in your target environment. Each method handles a specific part of the element
lifecycle.
Type Parameters
It’s highly recommended to use TypeScript to write a custom renderer, as this
will help you understand the types of values which are passed to your
RenderAdapter methods. The Renderer
class takes the following type
parameters.
TNode
- The type representing nodes in your target environment.TScope
- Context data passed down the component tree (e.g., coordinate systems, themes). The DOM renderer passes down xmlns info so that SVG elements are properly created.TRoot
- The root container type (defaults toTNode
).TResult
- The type returned when reading element values (defaults toElementValue<TNode>
).
Core Methods
create(data): TNode
Creates a new node when an element is rendered for the first time.
create({tag, props, scope}) {
switch (tag) {
case "sprite":
return new PIXI.Sprite(props.texture);
case "container":
return new PIXI.Container();
default:
throw new Error(`Unknown tag: ${tag}`);
}
}
Parameters:
tag
- Element tag (string or symbol)tagName
- String representation for debuggingprops
- Element props objectscope
- Current scope context
patch(data): void
Updates a node's properties when props change. This is where you implement prop-to-attribute mapping, event listener binding, and property synchronization.
patch: ({node, props, oldProps}) {
for (const [key, value] of Object.entries(props)) {
if (oldProps?.[key] !== value) {
if (key.startsWith("on")) {
// Handle events
const eventName = key.slice(2).toLowerCase();
node.removeAllListeners(eventName);
if (value) node.on(eventName, value);
} else if (key === "texture") {
node.texture = resolveTexture(value);
} else {
node[key] = value;
}
}
}
}
Parameters:
node
- The node to updateprops
- New props objectoldProps
- Previous props (undefined on first render)tag
Element tag (string or symbol)scope
Current scope contextcopyProps
A set of props which should not be updated because the user has provided a copy with meta-prop syntax.isHydrating
Whether you are currently hydratingquietProps
A set of props which should not cause hydration warnings because the user has provided a hydrate with meta-prop syntax.
arrange(data): void
Organizes child nodes within their parent after child elements are rendered. The remove method handles the removal of nodes, so this method is primarily about re-ordering existing nodes in the tree.
arrange({node, children}) {
node.removeChildren(); // Clear existing
children.forEach((child, index) => node.addChildAt(child, index));
}
Parameters:
node
- The parent nodechildren
- Array of child nodes in correct ordertag
,props
oldProps
remove(data): void
Removes a node when an element is unmounted.
remove({node, parentNode, isNested}) => {
// Clean up resources
node.destroy?.();
// Remove from parent (unless nested removal)
if (!isNested && parentNode.children.includes(node)) {
parentNode.removeChild(node);
}
}
text(data): TNode
Creates or updates text nodes.
text: ({value, oldNode}) => {
if (oldNode && oldNode.text !== value) {
oldNode.text = value;
return oldNode;
}
return new Text(value);
}
raw(data): ElementValue<TNode>
Handles raw values that bypass normal element processing.
raw: ({value, scope}) => {
if (typeof value === "string") {
return parseMarkup(value); // Convert string to nodes
}
return value; // Pass through nodes directly
}
Parameters:
node
- The node which has been removedparentNode
- The parent node which this node is being removed fromisNested
- Whether this removal is nested in another removal. Depending on your target environment, you may only need to remove the top-level node from its parent and leave the remaining nodes untouched.
adopt(data): Array<TNode> | undefined
Adopts existing nodes during hydration (for server-side rendering or state restoration).
scope(data): TScope | undefined
Computes scope context for child elements. Useful for passing coordinate systems, themes, or namespaces down the tree. This method is only called once when elements are created.
scope({tag, props, scope}) => {
if (tag === "viewport") {
return {
...scope,
transform: new Transform(props.x, props.y, props.scale)
};
}
return scope;
}
read(value): TResult
Transforms the internal node representation into the public API.
read: (value) => {
if (Array.isArray(value)) {
return value.map(node => node.getPublicAPI());
}
return value?.getPublicAPI();
}
finalize(root): void
Performs final rendering operations (e.g., triggering a render pass).
finalize: (root) => {
if (root instanceof PIXI.Application) {
root.render();
}
}
JSX Type Definitions
To get proper TypeScript support, define JSX types for your custom elements:
declare global {
namespace JSX {
interface IntrinsicElements {
// Canvas elements
rect: {
x?: number;
y?: number;
width?: number;
height?: number;
fill?: string;
children?: any;
};
text: {
x?: number;
y?: number;
children?: string | number;
};
group: {
x?: number;
y?: number;
scale?: number;
children?: any;
};
}
}
}
Advanced Patterns
Bridge Components
Create components that bridge between different renderers:
function* PixiApplication({children, ...props}) {
const app = new PIXI.Application(props);
document.body.appendChild(app.view);
for ({children, ...props} of this) {
pixiRenderer.render(children, app.stage, this);
}
}
The third parameter to render()
allows you to connect the contexts of your
child renderer with your parent renderer.
function* GameUI() {
const gameState = new GameState();
// You can add provision for child Canvas components to read
this.provide("gameState", gameState);
for ({} of this) {
yield (
<>
{/* DOM UI */}
<div className="ui">
<Score value={gameState.score} />
<button onclick={() => gameState.restart()}>
Restart
</button>
</div>
{/* Canvas Game components will read the provisions */}
<GameCanvas />
</>
);
}
}