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: A 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, MyRoot> = {
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 #

All methods except read and finalize receive a data object. Every data object includes root: TRoot | undefined, giving access to the root container.

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, tagName, props, scope, root

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")) {
const eventName = key.slice(2).toLowerCase();
node.removeAllListeners(eventName);
if (value) node.on(eventName, value);
} else {
node[key] = value;
}
}
}
}

Parameters: tag, tagName, node, props, oldProps, scope, root, copyProps, isHydrating, quietProps

arrange(data): void #

Organizes child nodes within their parent after child elements are rendered. The remove method handles node removal, so this method is primarily about re-ordering existing nodes in the tree.

arrange({node, children}) {
node.removeChildren();
children.forEach((child, index) => node.addChildAt(child, index));
}

Parameters: tag, tagName, node, props, children, oldProps, root

remove(data): void #

Removes a node when an element is unmounted.

remove({node, parentNode, isNested}) {
node.destroy?.();
// Remove from parent (unless nested removal)
if (!isNested && parentNode.children.includes(node)) {
parentNode.removeChild(node);
}
}

Parameters: node, parentNode, isNested, root

text(data): TNode #

Creates or updates text nodes.

text({value, oldNode}) {
if (oldNode && oldNode.text !== value) {
oldNode.text = value;
return oldNode;
}

return new TextNode(value);
}

Parameters: value, scope, oldNode, hydrationNodes, root

raw(data): ElementValue<TNode> #

Handles raw values that bypass normal element processing.

raw({value}) {
if (typeof value === "string") {
return parseMarkup(value);
}
return value;
}

Parameters: value, scope, hydrationNodes, root

adopt(data): Array<TNode> | undefined #

Adopts existing nodes during hydration (for server-side rendering or state restoration). Should return an array of child nodes if the provided node matches the expected tag, or undefined if hydration should fail.

adopt({tag, node}) {
if (node && node.tagName.toLowerCase() === tag) {
return Array.from(node.children);
}
return undefined;
}

Parameters: tag, tagName, props, node, scope, root

scope(data): TScope | undefined #

Computes scope context for child elements. Useful for passing coordinate systems, themes, or namespaces down the tree. 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;
}

Parameters: tag, tagName, props, scope, root

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 class="ui">
<Score value={gameState.score} />
<button onclick={() => gameState.restart()}>
Restart
</button>
</div>

{/* Canvas Game components will read the provisions */}
<GameCanvas />
</>
);
}
}
Edit on GitHub