Crank Style Guide

The key thesis behind Crank is that JavaScript already has all the primitives you need to build UIs. Code which follows this principle can humorously be called cranky, as in β€œThis file solves the problem in a cranky way. Very nice.” The following are the four core principles behind idiomatic Crank code:

  1. Use the language. Write vanilla JavaScript. Variables are state, control flow is lifecycle, fetch() does data fetching.
  2. Match the platform. Prefer props like class, for, onclick, innerHTML. Use DOM names and conventions.
  3. Own the execution. Avoid unnecessary reactive abstractions. Understanding the execution of components is your job, and this.refresh(() => ...) makes it legible.
  4. Compose uniformly. A component should resemble built-in elements: props in, events out.

For full explanations, see the Components, Lifecycles, and Async Components guides. Many of the conventions described in this document can be fixed automatically through the eslint-plugin-crank package.

Do’s and Don’ts #

Component Structure #

βœ… Do use a plain function when the component has no state or lifecycle needs:

// βœ… plain function for stateless components
function Greeting({name}) {
return <div>Hello, {name}</div>;
}

❌ Don’t use arrow functions for components. They can’t be generators and don’t have their own this, so they can’t be stateful or access the context via this:

// ❌ arrow functions can't be generators or access this
const Counter = () => {
// this.refresh is not available
return <div />;
};

// βœ… function declarations work with this and generators
function *Counter() {
for ({} of this) {
yield <div />;
}
}

βœ… Do use for...of this for component iteration. A while (true) loop renders correctly but never sees prop updates, and a missed yield causes the page to hang:

function *Counter({label}) {
let count = 0;
const onclick = () => this.refresh(() => count++);
// ❌ while (true) β€” label never updates
while (true) {
yield <button onclick={onclick}>{label}: {count}</button>;
}
}

function *Counter({label}) {
let count = 0;
const onclick = () => this.refresh(() => count++);
// βœ… for...of receives fresh props each render
for ({label} of this) {
yield <button onclick={onclick}>{label}: {count}</button>;
}
}

// βœ… for ({} of this) when the component has no props
function *Counter() {
let count = 0;
const onclick = () => this.refresh(() => count++);
for ({} of this) {
yield <button onclick={onclick}>{count}</button>;
}
}

ESLint rule: crank/prefer-props-iterator

❌ Don’t put persistent state inside the loop. It resets on every render:

function *Timer() {
const id = setInterval(() => this.refresh(), 1000);
for ({} of this) {
// ❌ state inside the loop resets every render
let seconds = 0; // reset to 0 every render
seconds++;
yield <p>{seconds}s</p>;
}

clearInterval(id);
}

βœ… Do return null for intentionally empty output, never undefined:

// ❌ implicit undefined return
function MaybeGreeting({name}) {
if (name) {
return <div>Hello {name}</div>;
}
}

function MaybeGreeting({name}) {
if (name) {
return <div>Hello {name}</div>;
}
// βœ… explicit null
return null;
}

βœ… Do use the three-region structure of generator components: setup before the loop, render inside it, cleanup after it:

function *Timer() {
// Setup β€” runs once on mount
let seconds = 0;
const id = setInterval(() => this.refresh(() => seconds++), 1000);

// Render β€” runs on every update
for ({} of this) {
yield <p>{seconds}s</p>;
}

// Cleanup β€” runs once on unmount
clearInterval(id);
}

❌ Don’t return from a generator loop unless you want it to restart from scratch on the next update, losing all local state:

// ❌ return restarts the generator β€” state is lost
function *Greeting({name}) {
for ({name} of this) {
return <div>Hello {name}</div>;
}
}

// βœ… yield preserves state across renders
function *Greeting({name}) {
for ({name} of this) {
yield <div>Hello {name}</div>;
}
}

State Updates #

❌ Don’t mutate state or call refresh() as separate steps. It’s easy to forget one or the other, especially in longer handlers:

function *Counter() {
let count = 0;
const onclick = () => {
// ❌ separate mutation and refresh
count++;
this.refresh();
};

for ({} of this) {
yield <button onclick={onclick}>Count: {count}</button>;
}
}

βœ… Do use the this.refresh(() => ...) callback form. It runs the mutation and triggers a re-render atomically, so you cannot forget one without the other. Group related mutations in a single callback rather than calling refresh multiple times:

function *Counter() {
let count = 0;
const onclick = () => this.refresh(() => count++);

for ({} of this) {
yield <button onclick={onclick}>Count: {count}</button>;
}
}

function *Form() {
let name = "";
let email = "";
const onsubmit = (ev) => {
ev.preventDefault();
const data = new FormData(ev.target);
// βœ… one refresh, multiple mutations
this.refresh(() => {
name = data.get("name");
email = data.get("email");
});
};

for ({} of this) {
yield (
<form onsubmit={onsubmit}>
<input name="name" value={name} />
<input name="email" value={email} />
<button type="submit">Save</button>
<p>{name} ({email})</p>
</form>
);
}
}

ESLint rule: crank/prefer-refresh-callback

❌ Don’t call refresh() during execution or after unmount. It’s a no-op in both cases and will emit warnings:

function *Example() {
// ❌ refresh() during execution is a no-op
this.refresh();

for ({} of this) {
yield <div />;
}

// ❌ refresh() after unmount is a no-op
this.refresh();
}

Props #

❌ Don’t destructure props in the parameter but skip in the for...of binding. name below is captured once and never updated:

function *Greeting({name = "World"}) {
// ❌ props captured once, never updated
for ({} of this) {
yield <p>Hello, {name}</p>;
}
}

❌ Don’t destructure partially. Any prop missing from the for...of binding stays stale:

function *Card({title, count}) {
// ❌ count stays stale
for ({title} of this) {
yield <div>{title}: {count}</div>;
}
}

βœ… Do destructure every prop used in the loop body, with matching defaults in both positions:

function *Greeting({name = "World", formal = false}) {
// βœ… all props destructured with matching defaults
for ({name = "World", formal = false} of this) {
const prefix = formal ? "Dear" : "Hello";
yield <p>{prefix}, {name}</p>;
}
}

ESLint rule: crank/prop-destructuring-consistency

βœ… Do use standard HTML attribute names, not React aliases. They match the DOM and let you paste HTML directly into components:

// ❌ React prop names
function MyForm() {
return (
<div>
<label className="label" htmlFor="name">Name</label>
<input id="name" onClick={handler} />
</div>
);
}

// βœ… standard HTML attributes
function MyForm() {
return (
<div>
<label class="label" for="name">Name</label>
<input id="name" onclick={handler} />
</div>
);
}

βœ… Do use standard SVG attribute names, not React’s camelCase rewrites:

// ❌
<circle strokeWidth="2" fillOpacity={0.5} />

// βœ…
<circle stroke-width="2" fill-opacity={0.5} />

ESLint rule: crank/no-react-svg-props

Events #

❌ Don’t use camelCased event props. Crank passes event attributes directly to the DOM:

// ❌ React-style camelCase
<input onChange={handler} onKeyDown={handler} />

// βœ… DOM event names
<input onchange={handler} onkeydown={handler} />

❌ Don’t pass callback props down through multiple layers. It couples children to their parents and clutters intermediate components:

function *App() {
let todos = [];
const ondelete = (id) => this.refresh(() => {
todos = todos.filter((t) => t.id !== id);
});

for ({} of this) {
// ❌ callback props couple parent and child
yield <TodoList todos={todos} ondelete={ondelete} />;
}
}

βœ… Do use dispatchEvent in children and addEventListener in parents. Custom events bubble up the component tree, just like DOM events:

class TodoDeleteEvent extends CustomEvent {
constructor(id) {
super("tododelete", {bubbles: true, detail: {id}});
}
}

function *TodoItem({todo}) {
const ondelete = () => {
// βœ… events bubble β€” children dispatch, parents listen
this.dispatchEvent(new TodoDeleteEvent(todo.id));
};

for ({todo} of this) {
yield (
<li>
{todo.title}
<button onclick={ondelete}>Delete</button>
</li>
);
}
}

function *App() {
let todos = [];

this.addEventListener("tododelete", (ev) => {
this.refresh(() => {
todos = todos.filter((t) => t.id !== ev.detail.id);
});
});

for ({} of this) {
yield <ul>{todos.map((t) => <TodoItem key={t.id} todo={t} />)}</ul>;
}
}

βœ… Do use inline event handlers freely. There are no stale closures in Crank β€” handlers close over let variables that are reassigned each iteration, so they always see current values:

function *Counter() {
let count = 0;

for ({} of this) {
// βœ… always sees current count β€” no stale closure
yield (
<button onclick={() => this.refresh(() => count++)}>
Count: {count}
</button>
);
}
}

Identity #

βœ… Do use keys to control component identity. Crank matches elements by position, so the same component at the same position reuses its generator state by default. Keys force a fresh component when data identity changes:

// ❌ stale state bleeds across users
yield <UserProfile userId={userId} />;

// βœ… key forces a fresh component
yield <UserProfile key={userId} userId={userId} />;

Positional matching (no keys) is often fine β€” it’s the default behavior, and keying by array index is equivalent to not keying at all. You only need stable keys when items can be reordered, filtered, or removed:

// βœ… positional matching is fine for static lists
yield <ul>{items.map((item) => <li>{item.name}</li>)}</ul>;

// ❌ without stable keys, removing a todo shifts state to the wrong component
yield <ul>{todos.map((t) => <TodoItem todo={t} />)}</ul>;

// βœ… stable key β€” each component tracks its own item
yield <ul>{todos.map((t) => <TodoItem key={t.id} todo={t} />)}</ul>;

βœ… Do use &&, ||, and ?? for conditional rendering. The values true, false, null, and undefined all render as empty but preserve their slot in the children array, so siblings don’t shift positions:

// βœ… falsy values preserve their slot
yield (
<div>
{showHeader && <Header />}
{error || <Main />}
{customFooter ?? <DefaultFooter />}
</div>
);

βœ… Do use distinct component functions when you want Crank to treat elements as different types. Swapping the tag unmounts the old instance and mounts a fresh one:

function *CreateForm() { /* ... */ }
function *EditForm() { /* ... */ }

function *App({mode}) {
for ({mode} of this) {
// switching the tag creates a fresh instance
yield mode === "create" ? <CreateForm /> : <EditForm />;
}
}

Cleanup #

❌ Don’t leave timers, listeners, or subscriptions without cleanup. They outlive the component and leak:

function *Timer() {
let s = 0;
// ❌ no cleanup β€” interval leaks
setInterval(() => this.refresh(() => s++), 1000);
for ({} of this) {
yield <div>{s}</div>;
}
}

βœ… Do clean up after the loop when setup and teardown are both visible in the component body:

function *Timer() {
let s = 0;
const id = setInterval(() => this.refresh(() => s++), 1000);

for ({} of this) {
yield <p>{s}s</p>;
}

// βœ… post-loop cleanup
clearInterval(id);
}

βœ… Do wrap the loop in try/finally when the component might throw:

function *Timer() {
let s = 0;
const id = setInterval(() => this.refresh(() => s++), 1000);
// βœ… try/finally for error safety
try {
for ({} of this) {
yield (
<div>
<p>{s}s</p>
<SomeChild />
</div>
);
}
} finally {
clearInterval(id);
}
}

βœ… Do use this.cleanup() when registering teardown from a helper function, or when you want to colocate cleanup with the variable:

function createInterval(ctx, callback, delay) {
const id = setInterval(callback, delay);
// βœ… this.cleanup() in a helper
ctx.cleanup(() => clearInterval(id));
return id;
}

function *Timer() {
let s = 0;
createInterval(this, () => this.refresh(() => s++), 1000);

for ({} of this) {
yield <p>{s}s</p>;
}
}

ESLint rule: crank/require-cleanup-for-timers

βœ… Do use Symbol.dispose and Symbol.asyncDispose with using declarations for automatic resource cleanup. Generator components are naturally compatible with the Explicit Resource Management proposal:

function *Component() {
using connection = openConnection();
// connection[Symbol.dispose]() is called automatically when the generator returns

for ({} of this) {
yield <div>{connection.status}</div>;
}
}

async function *AsyncComponent() {
await using stream = await openStream();
// stream[Symbol.asyncDispose]() is called automatically when the generator returns

for ({} of this) {
yield <div>{stream.status}</div>;
}
}

DOM Access #

βœ… Do use ref callbacks to capture host elements for later use:

function *Measurable() {
let el = null;
for ({} of this) {
// βœ… ref fires once on first commit
yield <div ref={(node) => el = node} />;
}
}

βœ… Do forward ref to the root host element in wrapper components so callers can access the underlying DOM node:

function MyInput({ref, class: cls, ...props}) {
// βœ… forward ref to the root element
return <input ref={ref} class={"my-input " + cls} {...props} />;
}

βœ… Do use this.after() for DOM operations that need the element to be live, like focus, measurement, and animations. It is one-shot and must be re-registered each render:

function *AutoFocusInput() {
for ({} of this) {
// βœ… after() fires once the element is in the document
this.after((input) => input.focus());
yield <input />;
}
}

Async Components #

❌ Don’t manage loading state manually with flags in a sync generator when an async component would be simpler:

function *UserProfile({userId}) {
let user = null;
// ❌ manual loading flags in a sync generator
let loading = true;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => this.refresh(() => { user = data; loading = false; }));

for ({userId} of this) {
yield loading ? <div>Loading...</div> : <div>{user.name}</div>;
}
}

βœ… Do use an async function component for one-shot data fetching:

// βœ… async function for one-shot fetch
async function UserProfile({userId}) {
const res = await fetch(`/api/users/${userId}`);
const user = await res.json();
return <div>{user.name}</div>;
}

βœ… Do use Suspense from @b9g/crank/async for loading states. It races a fallback against async children so you don't have to wire up the for await...of pattern yourself:

import {Suspense} from "@b9g/crank/async";

// βœ… declarative loading state
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userId={userId} />
</Suspense>
);
}

See the Async Components guide for details on Suspense, SuspenseList, and lazy.

Error Handling #

βœ… Do handle expected failures at the source rather than throwing:

// ❌ throwing for an expected condition
async function UserProfile({userId}) {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) {
throw new Error("Could not load user");
}
const user = await res.json();
return <div>{user.name}</div>;
}

// βœ… handle expected failures with control flow
async function UserProfile({userId}) {
const res = await fetch(`/api/users/${userId}`).catch(() => null);
if (!res?.ok) {
return <div>Could not load user</div>;
}
const user = await res.json();
return <div>{user.name}</div>;
}

Composition #

❌ Don’t use render props or functions-as-children. Generators already encapsulate state:

function *App() {
for ({} of this) {
yield (
<div>
{/* ❌ render props and functions-as-children */}
<DataProvider render={(data) => <Chart data={data} />} />
<MouseTracker>{(pos) => <Tooltip x={pos.x} y={pos.y} />}</MouseTracker>
</div>
);
}
}

βœ… Do treat children as opaque β€” accept it and forward it into the element tree without inspecting or transforming it. For multiple insertion points, use named props as slots:

function Layout({header, sidebar, children}) {
return (
<div class="layout">
<header>{header}</header>
<aside>{sidebar}</aside>
{/* βœ… children forwarded as opaque */}
<main>{children}</main>
</div>
);
}

❌ Don’t use plain strings as provision keys. They risk collisions between unrelated libraries or components:

function *App({children}) {
// ❌ string keys risk collisions
this.provide("theme", "dark");
for ({children} of this) {
yield children;
}
}

function Toolbar() {
const theme = this.consume("theme"); // could collide with another library
return <div class={theme}>Toolbar</div>;
}

βœ… Do use symbols so provisions are private and collision-free:

// βœ… symbol keys are private
const ThemeKey = Symbol("theme");

function *ThemeProvider({theme, children}) {
for ({theme, children} of this) {
this.provide(ThemeKey, theme);
yield children;
}
}

function ThemedButton({children}) {
const theme = this.consume(ThemeKey);
return <button class={theme}>{children}</button>;
}

βœ… Do use provisions when siblings or distant descendants need shared data without prop drilling:

// βœ… provisions for shared data without prop drilling
const LocaleKey = Symbol("locale");

function *App({locale, children}) {
for ({locale, children} of this) {
this.provide(LocaleKey, locale);
yield children;
}
}

function Price({amount}) {
const locale = this.consume(LocaleKey);
return <span>{amount.toLocaleString(locale)}</span>;
}

Reusable Logic #

❌ Don’t extend Context.prototype to share behavior globally. It’s implicit, globally scoped, and can’t run setup logic:

import {Context} from "@b9g/crank";
// ❌ global monkey-patching
Context.prototype.log = function (message) {
console.log(`[${this.props.name}] ${message}`);
};

βœ… Do write plain helper functions that accept a context. They compose, they’re explicit, and they’re just JavaScript:

// βœ… plain helper function
function useInterval(ctx, callback, delay) {
const id = setInterval(callback, delay);
ctx.cleanup(() => clearInterval(id));
return id;
}

function *Timer() {
let seconds = 0;
useInterval(this, () => this.refresh(() => seconds++), 1000);

for ({} of this) {
yield <p>{seconds}s</p>;
}
}

βœ… Do use higher-order components to wrap rendering logic around existing components. For example, a memo wrapper uses <Copy /> to skip re-renders when props haven’t changed:

import {Copy} from "@b9g/crank";

// βœ… higher-order component
function memo(Component) {
return function *Memoized(props) {
yield <Component {...props} />;
for (const newProps of this) {
if (shallowEqual(props, newProps)) {
yield <Copy />;
} else {
yield <Component {...newProps} />;
}

props = newProps;
}
};
}

See Reusable Logic for alternative approaches and tradeoffs.

TypeScript #

βœ… Do annotate this: Context<typeof Component> in generator components. It’s required in strict mode and infers the props type from the component definition:

import type {Context} from "@b9g/crank";

function *Timer(this: Context<typeof Timer>) {
let seconds = 0;
const id = setInterval(() => this.refresh(() => seconds++), 1000);

for ({} of this) {
yield <div>{seconds}s</div>;
}

clearInterval(id);
}

βœ… Do type props inline in the function parameter:

function *Greeting(
this: Context<typeof Greeting>,
{name = "World"}: {name?: string},
) {
for ({name = "World"} of this) {
yield <div>Hello, {name}</div>;
}
}

βœ… Do use Children for the children prop type, including named slots:

import {Children} from "@b9g/crank";

function Layout({header, sidebar, children}: {
header: Children,
sidebar: Children,
children: Children,
}) {
return (
<div class="layout">
<header>{header}</header>
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}

βœ… Do augment EventMap and ProvisionMap for typed events and provisions:

declare global {
module Crank {
interface EventMap {
"tododelete": CustomEvent<{id: string}>;
}

interface ProvisionMap {
theme: "light" | "dark";
}
}
}

Performance #

❌ Don’t redo expensive work on every render when the inputs haven’t changed:

function *Report({data}) {
for ({data} of this) {
// ❌ recomputes every render
const summary = computeExpensiveSummary(data);
yield <div>{summary}</div>;
}
}

βœ… Do cache the result and compare inputs manually. Save current values after yield so they’re available as β€œold” values on the next iteration:

function *Report({data}) {
let oldData;
let summary;

for ({data} of this) {
// βœ… cached with manual comparison
if (data !== oldData) {
summary = computeExpensiveSummary(data);
}

yield <div>{summary}</div>;

oldData = data;
}
}

βœ… Do use copy to preserve subtrees when the child doesn’t need to re-render:

function *Dashboard() {
let tab = "overview";
for ({} of this) {
yield (
<div>
<Tabs ontabchange={(ev) => this.refresh(() => tab = ev.detail)} />
{/* βœ… copy preserves subtrees */}
<Sidebar copy />
{tab === "overview" ? <Overview /> : <Settings />}
</div>
);
}
}

βœ… Do use copy with a prop selector string to leave specific props uncontrolled. The named props keep their previous values while everything else re-renders normally:

function *Form() {
let reset = false;
const onsubmit = (ev) => {
ev.preventDefault();
this.refresh(() => reset = true);
};

for ({} of this) {
yield (
<form onsubmit={onsubmit}>
{/* βœ… value is only patched when reset is true */}
<input name="query" type="text" value="" copy={!reset && "value"} />
<button type="submit">Submit</button>
</form>
);

reset = false;
}
}

βœ… Do use this.schedule(() => this.refresh()) to render a component twice in one pass. The first render produces intermediate output you can inspect, and the scheduled refresh immediately re-renders with the final result. This is useful on the server for extracting CSS from rendered HTML:

function *Page({children}) {
for ({children} of this) {
this.schedule(() => this.refresh());
// First render β€” capture child HTML
const childrenHTML = yield children;
// Extract critical CSS from the rendered output
const {html, css} = extractCritical(childrenHTML);
// Second render β€” final page with inlined styles
yield <>
<style><Raw value={css} /></style>
<div><Raw value={html} /></div>
</>;
}
}
Edit on GitHub