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:
- Use the language. Write vanilla JavaScript. Variables are state, control flow is lifecycle,
fetch()does data fetching. - Match the platform. Prefer props like
class,for,onclick,innerHTML. Use DOM names and conventions. - Own the execution. Avoid unnecessary reactive abstractions. Understanding the execution of components is your job, and
this.refresh(() => ...)makes it legible. - 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 componentsfunction 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 thisconst Counter = () => {// this.refresh is not availablereturn <div />;};// β function declarations work with this and generatorsfunction *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 updateswhile (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 renderfor ({label} of this) {yield <button onclick={onclick}>{label}: {count}</button>;}}// β for ({} of this) when the component has no propsfunction *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 renderlet seconds = 0; // reset to 0 every renderseconds++;yield <p>{seconds}s</p>;}clearInterval(id);}
β
Do return null for intentionally empty output, never undefined:
// β implicit undefined returnfunction MaybeGreeting({name}) {if (name) {return <div>Hello {name}</div>;}}function MaybeGreeting({name}) {if (name) {return <div>Hello {name}</div>;}// β explicit nullreturn 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 mountlet seconds = 0;const id = setInterval(() => this.refresh(() => seconds++), 1000);// Render β runs on every updatefor ({} of this) {yield <p>{seconds}s</p>;}// Cleanup β runs once on unmountclearInterval(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 lostfunction *Greeting({name}) {for ({name} of this) {return <div>Hello {name}</div>;}}// β yield preserves state across rendersfunction *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 refreshcount++;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 mutationsthis.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-opthis.refresh();for ({} of this) {yield <div />;}// β refresh() after unmount is a no-opthis.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 updatedfor ({} 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 stalefor ({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 defaultsfor ({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 namesfunction MyForm() {return (<div><label className="label" htmlFor="name">Name</label><input id="name" onClick={handler} /></div>);}// β standard HTML attributesfunction 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 childyield <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 listenthis.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 closureyield (<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 usersyield <UserProfile userId={userId} />;// β key forces a fresh componentyield <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 listsyield <ul>{items.map((item) => <li>{item.name}</li>)}</ul>;// β without stable keys, removing a todo shifts state to the wrong componentyield <ul>{todos.map((t) => <TodoItem todo={t} />)}</ul>;// β stable key β each component tracks its own itemyield <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 slotyield (<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 instanceyield 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 leakssetInterval(() => 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 cleanupclearInterval(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 safetytry {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 helperctx.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 returnsfor ({} of this) {yield <div>{connection.status}</div>;}}async function *AsyncComponent() {await using stream = await openStream();// stream[Symbol.asyncDispose]() is called automatically when the generator returnsfor ({} 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 commityield <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 elementreturn <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 documentthis.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 generatorlet 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 fetchasync 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 statefunction 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 conditionasync 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 flowasync 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 collisionsthis.provide("theme", "dark");for ({children} of this) {yield children;}}function Toolbar() {const theme = this.consume("theme"); // could collide with another libraryreturn <div class={theme}>Toolbar</div>;}
β Do use symbols so provisions are private and collision-free:
// β symbol keys are privateconst 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 drillingconst 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-patchingContext.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 functionfunction 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 componentfunction 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 renderconst 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 comparisonif (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 HTMLconst childrenHTML = yield children;// Extract critical CSS from the rendered outputconst {html, css} = extractCritical(childrenHTML);// Second render β final page with inlined stylesyield <><style><Raw value={css} /></style><div><Raw value={html} /></div></>;}}