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:

  1. RenderAdapter - A set of functions which determine how elements map to your target environment
  2. 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.

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:

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:

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:

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:

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 />
</>
);
}
}