Crank Style Guide
Crank is built on the thesis that JavaScript already has all the primitives you need to build UIs. Idiomatic Crank code can humorously be called cranky. The following are its four core principles:
- Use the language. Write vanilla JavaScript. Variables are state, control flow handles lifecycle,
fetch()does data fetching. - Match the platform. Use DOM names and conventions:
class,for,onclick,innerHTML, not framework-specific alternatives. - Own the execution. Avoid complex reactive abstractions. Explicit
this.refresh()calls make state changes 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:
// β 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 />;}}
β Don't use while (true) for component iteration:
function *Counter({label}) {let count = 0;const onclick = () => this.refresh(() => count++);// β 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
β 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);for ({} of this) {// Render: runs on every updateyield <p>{seconds}s</p>;}// Cleanup: runs once on unmountclearInterval(id);}
β Donβt put persistent state inside the loop. It resets on every render:
function *Counter() {for ({} of this) {// β resets to 0 on every render, counter never advanceslet count = 0;const onclick = () => this.refresh(() => count++);yield <button onclick={onclick}>Count: {count}</button>;}}
β Donβt assume code after yield runs in the current render:
function *Logger() {for ({} of this) {yield <div>Hello</div>;// β this runs on the NEXT render, not after the current oneconsole.log(document.querySelector("div").textContent);}}function *Logger() {for ({} of this) {// β this.after() runs after the current render commitsthis.after(() => console.log(document.querySelector("div").textContent));yield <div>Hello</div>;}}// β for await...of continues past yield immediatelyasync function *Logger() {for await ({} of this) {yield <div>Hello</div>;// runs right after this render commitsconsole.log(document.querySelector("div").textContent);}}
β 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>;}}
β
Do return null for intentionally empty output, never undefined:
// β implicit undefined returnfunction MaybeGreeting({name}) {if (name) {return <div>Hello {name}</div>;}}// β explicit nullfunction MaybeGreeting({name}) {if (name) {return <div>Hello {name}</div>;}return null;}
β Don't use <Fragment> when <> suffices:
// β verboseyield (<Fragment><Header /><Main /></Fragment>);// β short syntaxyield (<><Header /><Main /></>);// β Fragment when a key is neededyield items.map((item) => (<Fragment key={item.id}><dt>{item.term}</dt><dd>{item.definition}</dd></Fragment>));
State Updates #
β Donβt mutate state and call refresh() as separate steps:
function *Counter() {let count = 0;// β easy to forget one or the other in longer handlersconst onclick = () => {count++;this.refresh();};// β refresh(() => ...) runs mutation and re-render atomicallyconst onclick = () => this.refresh(() => count++);for ({} of this) {yield <button onclick={onclick}>Count: {count}</button>;}}
ESLint rule: crank/prefer-refresh-callback
β Donβt call refresh() during execution or after unmount:
function *Example() {// β no-op during execution, emits a warningthis.refresh();for ({} of this) {yield <div />;}// β no-op after unmount, emits a warningthis.refresh();}
Props and Events #
β Donβt destructure props in the parameter but skip them in the for...of binding:
function *Greeting({name = "World"}) {// β name captured once, never updatedfor ({} of this) {yield <p>Hello, {name}</p>;}}function *Greeting({name = "World", formal = false}) {// β destructure every prop used in the body, 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
β Donβt use React-style attribute names:
// β React aliases: Crank uses standard HTML, SVG, and DOM event names<label className="label" htmlFor="name">Name</label><input onChange={handler} onKeyDown={handler} /><circle strokeWidth="2" fillOpacity={0.5} />// β standard DOM names<label class="label" for="name">Name</label><input onchange={handler} onkeydown={handler} /><circle stroke-width="2" fill-opacity={0.5} />
ESLint rules: crank/no-react-props, crank/no-react-event-props, crank/no-react-svg-props
β Donβt pass callback props down through multiple layers:
function *App() {let todos = [];// β couples parent and child, clutters intermediate componentsconst ondelete = (id) => this.refresh(() => {todos = todos.filter((t) => t.id !== id);});for ({} of this) {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 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:
function *Counter() {let count = 0;for ({} of this) {// β no stale closures, let variables are reassigned each iterationyield (<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} />;
β Donβt key by array index:
// β equivalent to no key; state shifts when items reorderyield <ul>{todos.map((t, i) => <TodoItem key={i} todo={t} />)}</ul>;
β Do use stable, unique identifiers as keys so each component tracks its own data across reorders and removals. Positional matching (no keys) is fine for static content:
// β stable key, each component tracks its own itemyield <ul>{todos.map((t) => <TodoItem key={t.id} todo={t} />)}</ul>;// β positional matching is fine for static listsyield <ul>{items.map((item) => <li>{item.name}</li>)}</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 />;}}
β Do use an incrementing counter in the outer scope when you need component instance IDs:
let nextId = 0;function *DynamicField({label}) {// unique per instance, stable across re-rendersconst id = `field-${nextId++}`;for ({label} of this) {yield (<><label for={id}>{label}</label><input id={id} /></>);}}
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} />;}
β Don't place this.schedule() or this.after() outside the loop unless you only want them to fire once:
function *AutoSave({data}) {// β only fires on mount, not on updatesthis.after(() => save(data));for ({data} of this) {yield <Form data={data} />;}}function *AutoSave({data}) {for ({data} of this) {// β fires after every renderthis.after(() => save(data));yield <Form data={data} />;}}
β Don't perform DOM operations that require connected nodes in ref or this.schedule() callbacks:
function *AutoFocusInput() {for ({} of this) {// β ref fires before the node is connected to the documentyield <input ref={(node) => node.focus()} />;}}function *AutoFocusInput() {// β schedule fires before the node is connected to the documentthis.schedule(() => document.querySelector("input").focus());for ({} of this) {yield <input />;}}function *AutoFocusInput() {for ({} of this) {// β after() fires once the node is connectedthis.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>;}}// β 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 #
β Don't throw errors for expected conditions like validation or missing data. Reserve try/catch of yield for truly unexpected errors:
// β throwing for validationfunction ContactForm({email}) {if (!email.includes("@")) {throw new Error("Invalid email");}return <div>Contact: {email}</div>;}function *App() {for ({} of this) {try {yield <ContactForm email={email} />;} catch (err) {yield <div>{err.message}</div>;}}}
β Do handle expected failures with control flow at the source:
// β handle expected failures at the sourceasync 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:
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>);}}
β Don't inspect or transform children:
// β treating children as datafunction Wrapper({children}) {return <div>{children.filter((c) => c.props.visible)}</div>;}// β forward children as-isfunction Wrapper({children}) {return <div>{children}</div>;}
β Do use named props as slots for multiple insertion points:
function Card({title, actions, children}) {return (<div class="card"><header>{title}</header><main>{children}</main><footer>{actions}</footer></div>);}
β Donβt use plain strings as provision keys:
// β string keys risk collisions across librariesthis.provide("theme", "dark");const theme = this.consume("theme");// β symbol keys are privateconst ThemeKey = Symbol("theme");this.provide(ThemeKey, "dark");const theme = this.consume(ThemeKey);
β 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:
import {Context} from "@b9g/crank";// β implicit, globally scoped, canβt run setup logicContext.prototype.setInterval = function (callback, delay) {const id = setInterval(callback, delay);this.cleanup(() => clearInterval(id));return id;};// β 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:
import type {Context} from "@b9g/crank";// required in strict mode, infers props from the component definitionfunction *Greeting(this: Context<typeof Greeting>,{name = "World"}: {name?: string},) {for ({name = "World"} of this) {yield <div>Hello, {name}</div>;}}
β Don't define a separate interface for props:
// β adds indirection and drifts from the actual parameterinterface GreetingProps {name?: string;}function *Greeting(this: Context<typeof Greeting>,{name = "World"}: GreetingProps,) {for ({name = "World"} of this) {yield <div>Hello, {name}</div>;}}// β inline props typefunction *Greeting(this: Context<typeof Greeting>,{name = "World"}: {name?: string},) {for ({name = "World"} of this) {yield <div>Hello, {name}</div>;}}// β use Parameters to extract props from a component elsewheretype GreetingProps = Parameters<typeof Greeting>[0];
β
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>;}}function *Report({data}) {let oldData;let summary;for ({data} of this) {// β cache the result, save old values after yield to compare next iterationif (data !== oldData) {summary = computeExpensiveSummary(data);}yield <div>{summary}</div>;oldData = data;}}
β
Do use copy to skip re-rendering:
// β copy preserves the entire subtree<Sidebar copy />
β
Do use this.schedule(() => this.refresh()) to render a component twice in one pass:
function *Component() {for ({} of this) {// schedule a second render in the same passthis.schedule(() => this.refresh());// first render: yield returns the committed output for inspectionconst output = yield <Child />;// second render: use the inspected output to produce the final resultyield <Final data={process(output)} />;}}