Lifecycles

Component lifecycles in Crank are straightforward: they follow the natural flow of JavaScript generator functions. Unlike other frameworks that require special lifecycle methods, Crank lets you write lifecycle logic using normal JavaScript.

The Natural Generator Lifecycle

Generator components have a simple, predictable lifecycle that mirrors the generator execution:

Mount Phase

When a component first renders, the generator function starts executing until it hits the first yield:

import {renderer} from "@b9g/crank/dom";

function *LifecycleDemo() {

// mount phase for logic before rendering
let count = 0;

// This `for...of` loop IS the component lifecycle
for ({} of this) {
// code which executes after we received props
yield (
<div>
<p>Count: {count}</p>
<button onclick={() => this.refresh(() => count++)}>
Increment
</button>
</div>
);
// code which executes before we receive new props
}

// code which executes before we unmount
}

renderer.render(<LifecycleDemo />, document.body);

Update Phase

When this.refresh() is called, the generator resumes from where it paused (after the yield) and continues to the next iteration of the loop. This gives you two important spaces for update logic:

import {renderer} from "@b9g/crank/dom";

function *UpdateDemo({count}) {
let oldCount = null; // What the count was in the previous render

for ({count} of this) {
yield (
<div>
<p>
Current: {count}
{oldCount != null && ` | Previous: ${oldCount}`}
</p>
</div>
);

oldCount = count; // Save current props as "old" for next comparison
}
}

function *App() {
let count = 0;

for ({} of this) {
yield (
<div>
<button onclick={() => this.refresh(() => count++)}>
Increment (count: {count})
</button>
<UpdateDemo count={count} />
</div>
);
}
}

renderer.render(<App />, document.body);
Loading...

The key insight: you have TWO execution spaces in each loop iteration:

Here's a practical example using prop comparison for memoization:

import {renderer} from "@b9g/crank/dom";

function *ExpensiveComponent({items, threshold}) {
let oldItems = [];
let oldThreshold = 0;
let cachedResult = null;

for ({items, threshold} of this) {
// Check if we can use cached result
const itemsChanged = JSON.stringify(items) !== JSON.stringify(oldItems);
const thresholdChanged = threshold !== oldThreshold;

if (!cachedResult || itemsChanged || thresholdChanged) {
// Simulate expensive calculation
cachedResult = items
.filter(item => item.value > threshold)
.map(item => `${item.name}: ${item.value}`)
.join(', ');
}

yield (
<div>
<h3>Filtered Items (threshold: {threshold})</h3>
<p>{cachedResult || "No items match"}</p>
</div>
);

// Save current props as "old" for next comparison
oldItems = [...items];
oldThreshold = threshold;
}
}

function *App() {
let threshold = 50;
const items = [
{name: "Item A", value: 25},
{name: "Item B", value: 75},
{name: "Item C", value: 100}
];

const updateThreshold = () => this.refresh(() =>
threshold = threshold === 50 ? 30 : 50
);

for ({} of this) {
yield (
<div>
<button onclick={updateThreshold}>
Toggle Threshold ({threshold})
</button>
<ExpensiveComponent items={items} threshold={threshold} />
</div>
);
}
}

renderer.render(<App />, document.body);

Unmount Phase

When the component is removed from the tree, the generator exits the for...of loop and any code after it runs as cleanup:

function *Timer({message}, ctx) {
let seconds = 0;
const interval = setInterval(() => ctx.refresh(() => seconds++), 1000);

for ({message} of ctx) {
yield (
<div>{message} {seconds}</div>
);
}
// When the component unmounts, the props iterator returns and code after the
// loop can run
clearInterval(interval);
}

Why You Need Extra Lifecycle Methods

The natural generator lifecycle is perfect for most logic, but sometimes you need more precise timing around DOM operations. The for...of loop pauses at each yield, which means:

For these DOM-specific timings, Crank provides three lifecycle methods:

Schedule: DOM Created but Not Inserted

schedule(callback) runs immediately after DOM nodes are created, but before they're inserted into the document. Useful for immediate DOM setup that doesn't require the element to be live in the document tree.

function *Component() {
this.schedule((el) => {
// Element exists but is NOT in the document yet
el.style.opacity = '0';
});

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

After: DOM Inserted and Live

after(callback) runs after the element is fully rendered and live in the DOM. This is where you'd do things like focusing inputs, measuring elements, or triggering animations that require the element to be visible.

function *Component() {
this.after((el) => {
// Element is now live in the document
el.focus();
console.log('Element is live:', element.getBoundingClientRect());
});

for ({} of this) {
yield <input type="text" />;
}
}

Cleanup: DOM Removed and Unmounted

cleanup(callback) runs when the component is unmounted. Use this for cleaning up event listeners, timers, subscriptions, or performing exit animations.

function *Component() {
const interval = setInterval(() => console.log('tick'), 1000);

this.cleanup(() => {
clearInterval(interval);
console.log('Component cleaned up');
});

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

Execution Order

For any given render:

  1. schedule() callbacks run first (element created but not inserted)
  2. after() callbacks run second (element live in DOM)
  3. cleanup() callbacks run when component unmounts

When to Use Which Method

This timing difference is crucial for choosing the right method:

Use schedule() for:

Use after() for:

The key insight: schedule() happens in the perfect "sweet spot" where you can modify elements without visual flicker, while after() gives you access to the fully live, measurable element.

Promise-based API (0.7+)

All three methods return promises when called without arguments:

async function *Component() {
await this.schedule(); // Wait for DOM creation
await this.after(); // Wait for DOM insertion
// ... component logic
await this.cleanup(); // Wait for cleanup (on unmount)
}

Context State Properties (0.7+)

The context provides two boolean properties to check component state:

Execution State: isExecuting

this.isExecuting is true when the component is currently executing (between yield points). Useful for avoiding redundant refresh calls:

function *Component() {
const handleClick = () => {
// Avoid calling refresh if component is already executing
if (!this.isExecuting) {
this.refresh(() => console.log('Refreshing'));
}
};

for ({} of this) {
yield <button onclick={handleClick}>Click me</button>;
}
}

Mount State: isUnmounted

this.isUnmounted is true after the component has been unmounted. Useful for avoiding work in async operations after unmount:

async function *Component() {
for ({} of this) {
yield <div>Loading...</div>;

try {
const data = await fetch('/api/data');

// Check if component was unmounted during the async operation
if (this.isUnmounted) {
console.log('Component unmounted, skipping update');
break;
}

this.refresh(() => console.log('Got data:', data));
} catch (error) {
if (!this.isUnmounted) {
console.error('Error fetching data:', error);
}
}
}
}

These properties help write safer async code by preventing common issues like calling refresh() on unmounted components or doing unnecessary work after unmount.

The Two-Pass Render Pattern

Using schedule(() => this.refresh()) for components that need to render twice.

A common pattern is to use schedule() to trigger an immediate re-render. This is particularly useful for components that need to render twice - once for initial setup, then again with updated state:

function *TwoPassComponent() {
let secondPass = false;

if (!secondPass) {
// Schedule a refresh to happen after this render
this.schedule(() => this.refresh(() => secondPass = true));
}

for ({} of this) {
if (!secondPass) {
yield <div>First render - setting up...</div>;
} else {
yield <div>Second render - ready!</div>;
}
}
}

This pattern is especially powerful for:

CSS-in-JS extraction during SSR:

function *SSRComponent({children}) {
for ({children} of this) {
// First render to extract styles
this.schedule(() => this.refresh());

const html = yield <div>{children}</div>;

// Extract CSS from the rendered HTML
const {html: finalHtml, css} = extractCritical(html);

// Second render with extracted CSS
yield (
<>
<style>{css}</style>
<div innerHTML={finalHtml} />
</>
);
}
}

Progressive enhancement:

function *ProgressiveComponent() {
let hydrated = false;

// After initial render, mark as hydrated and re-render
this.schedule(() => this.refresh(() => hydrated = true));

for ({} of this) {
if (hydrated) {
yield <InteractiveVersion />;
} else {
yield <StaticVersion />;
}
}
}

Measuring and adjusting:

function *ResponsiveComponent() {
let width = 0;

this.schedule((element) => {
const newWidth = element.offsetWidth;
if (width !== newWidth) {
this.refresh(() => width = newWidth);
}
});

for ({} of this) {
yield (
<div>
Width: {width}px
{width > 500 ? <LargeLayout /> : <SmallLayout />}
</div>
);
}
}

The key insight is that schedule() runs after DOM nodes are created but before they're inserted into the document. This timing makes it the perfect place to trigger another render cycle - you can inspect or modify elements, but the user doesn't see any flicker because nothing has been inserted yet.

Important: Since schedule() fires before DOM insertion, elements passed to schedule callbacks are not yet part of the document tree. Use after() if you need the element to be live in the DOM.

Setup, update and teardown logic

The execution of Crank components is well-defined and well-behaved, so there are no restrictions around where you need to place side-effects. This means much of setup, update and teardown logic can be placed directly in components.

import {renderer} from "@b9g/crank/dom";

function *Blinker({seconds}) {
// setup logic can go at the top of the scope
let blinking = false;
const blink = async () => {
this.refresh(() => blinking = true);
await new Promise((r) => setTimeout(r, 100));
this.refresh(() => blinking = false);
};

let interval = setInterval(blink, seconds * 1000);
let oldSeconds = seconds;

for ({seconds} of this) {
// update logic can go directly in the loop
if (seconds !== oldSeconds) {
blinking = false;
clearInterval(interval);
interval = setInterval(blink, seconds * 1000);
oldSeconds = seconds;
}

console.log(blinking);

yield (
<p style={{"background-color": blinking ? "red" : null}}>
{blinking && "!!!"}
</p>
);
}

// cleanup logic can go at the end of the loop
clearInterval(interval);
}

function *App() {
let seconds = 1;
const onChange = (ev) => this.refresh(() => seconds = ev.target.value);

for ({} of this) {
yield (
<div>
<label for="seconds">Seconds:</label>{" "}
<input
id="seconds"
value={seconds}
type="number"
min="0.25"
max="5"
step="0.25"
onchange={onChange}
/>
<Blinker seconds={seconds} />
</div>
);
}
}

renderer.render(<App />, document.body);
Loading...

Working with the DOM

Logic which needs to happen after rendering, such as doing direct DOM manipulations or taking measurements, can be done directly after a yield in async generator components which use for await...of loops, because the component is continuously resumed until the bottom of the for await loop. Conveniently, the yield expression will evaluate to the rendered result of the component.

async function *Component(this, props) {
for await (props of this) {
const div = yield <div />;
// logic which manipulates the div can go here.
div.innerHTML = props.innerHTML;
}
}

Unfortunately, this approach will not work for code in for...of loops. In a for...of loop, the behavior of yield works such that the component will suspend at the yield for each render, and this behavior holds for both sync and async generator components. This behavior is necessary for sync generator components, because there is nowhere else to suspend, and is mimicked in async generator components, to make refactoring between sync and async generator components easier.

// The following behavior happens in both sync and async generator components
// so long as they use a `for...of` and not a `for await...of` loop.

function *Component(this, props) {
let div = null;

const onclick = () => {
// If the component is only rendered once, div will still be null.
console.log(div);
};
for ({} of this) {
// This does not work in sync components because the function is paused
// exactly at the yield. Only after rendering a second time will cause the
// div variable to be assigned.
div = yield <button onclick={div}>Click me</button>;
// Any code below the yield will not run until the next render.
}
}

Thankfully, the Crank context provides two callback-based methods which allow you to run code after rendering has completed: schedule() and flush().

The schedule() method behaves like code which runs in an async generator’s for await...of loop. It runs immediately after the children DOM nodes are created:

function *Component(this, props) {
for await (props of this) {
this.schedule((div) => {
// the div is
div.innerHTML = props.innerHTML;
});
yield <div />;
}
}

On the other hand, the after() method runs after the result is completely rendered and live in the DOM. This is necessary for use-cases such as auto-focusing inputs after the first render. The reason for the distinction between schedule() and after() is that Crank coordinates async rendering so that the rendering of multiple async siblings happens together, meaning there might be some time before a created DOM node is created but before it is added to its intended parent.

import {renderer} from "@b9g/crank/dom";
function *AutoFocusingInput(props) {
// this.schedule does not work because it fires before the input element is
// added to the DOM
// this.schedule((input) => input.focus());
this.after((input) => input.focus());
for (props of this) {
yield <input {...props}/>;
}
}

function *Component() {
let initial = true;
for ({} of this) {
yield (
<div>
<div>
{initial || <AutoFocusingInput />}
</div>
<div>
<button onclick={() => this.refresh()}>Refresh</button>
</div>
</div>
);

initial = false;
}
}

renderer.render(<Component />, document.body);
Loading...

All schedule() callbacks will always fire before after() callbacks for a given render.

Cleanup logic

While you can use context iterators to write cleanup logic after for...of and for await...of loops, this does not account for errors in components, and it does work if you are not using a render loop. To solve these issues, you can use try/finally block. When a generator component is removed from the tree, Crank calls the return method on the component’s generator object.

You can think of it as whatever yield expression your component was suspended on being replaced by a return statement. This means any loops your component was in when the generator suspended are broken out of, and code after the yield does not execute.

import {renderer} from "@b9g/crank/dom";

function *Cleanup() {
try {
for ({} of this) {
yield "Hi";
}
} finally {
console.log("finally block executed");
}
}

renderer.render(<Cleanup />, document.body);
console.log(document.body); // "Hi"
renderer.render(null, document.body);
// "finally block executed"
console.log(document.body); // ""

The same best practices which apply to try / finally statements in regular functions apply to generator components. In short, you should not yield or return anything in the finally block. Crank will not use the yielded or returned values and doing so might cause your components to inadvertently swallow errors or suspend in unexpected locations.

To write cleanup logic which can be abstractd outside the component function, you can use the cleanup() method on the context. This method is similar to after() and schedule() in that it takes a callback.

import {renderer} from "@b9g/crank/dom";
function addGlobalEventListener(ctx, type, listener, options) {
window.addEventListener(type, listener, options);
// ctx.cleanup allows you to write cleanup logic outside the component
ctx.cleanup(() => window.removeEventListener(type, listener, options));
}

function *KeyboardListener() {
let key = "";
const listener = (ev) => this.refresh(() => key = ev.key);

addGlobalEventListener(this, "keypress", listener);
for ({} of this) {
yield <div>Last key pressed: {key || "N/A"}</div>
}
}

renderer.render(<KeyboardListener />, document.body);
Loading...

The cleanup() method is also useful for refactoring teardown logic.

Async Mount and Unmount

Starting in Crank 0.7, both mounting and unmounting can be asynchronous, enabling powerful patterns like coordinated animations, lazy loading, and complex initialization sequences.

Async Unmount

You can make cleanup operations asynchronous by passing async functions to the cleanup() method. This is particularly useful for exit animations:

import {renderer} from "@b9g/crank/dom";

function *FadeOutComponent() {
// Register async cleanup for smooth exit animation
this.cleanup(async (element) => {
element.style.transition = 'opacity 300ms ease-out';
element.style.opacity = '0';

// Wait for animation to complete before unmounting
await new Promise(resolve => setTimeout(resolve, 300));
console.log('Component faded out and unmounted');
});

for ({} of this) {
yield (
<div style={{
padding: '20px',
background: '#007bff',
color: 'white',
'border-radius': '4px',
opacity: '1'
}}>
I will fade out when unmounted!
</div>
);
}
}

function *App() {
let showComponent = true;
const toggle = () => this.refresh(() => showComponent = !showComponent);

for ({} of this) {
yield (
<div>
<button onclick={toggle}>
{showComponent ? 'Hide' : 'Show'} Component
</button>
{showComponent && <FadeOutComponent />}
</div>
);
}
}

renderer.render(<App />, document.body);
Loading...

Here's a more complex example with staggered letter animations:

import {renderer} from "@b9g/crank/dom";

function *Letter({children, delay = 0}) {
this.cleanup(async (element) => {
// Stagger the exit animation based on delay
await new Promise(resolve => setTimeout(resolve, delay));

element.style.transition = 'all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55)';
element.style.transform = 'translateY(-20px) rotateZ(10deg)';
element.style.opacity = '0';

await new Promise(resolve => setTimeout(resolve, 400));
});

for ({children} of this) {
yield (
<span style={{
display: 'inline-block',
transition: 'all 400ms ease',
transform: 'translateY(0) rotateZ(0deg)'
}}>
{children}
</span>
);
}
}

function *AnimatedText({text}) {
for ({text} of this) {
yield (
<div style={{
'font-size': '24px',
'font-weight': 'bold',
color: '#007bff',
'line-height': '1.5'
}}>
{text.split('').map((char, i) => (
<Letter key={i} delay={i * 50}>
{char === ' ' ? '\u00A0' : char}
</Letter>
))}
</div>
);
}
}

function *LettersDemo() {
let showText = true;
const toggle = () => this.refresh(() => showText = !showText);

for ({} of this) {
yield (
<div>
<button onclick={toggle}>
{showText ? 'Hide' : 'Show'} Animated Text
</button>
{showText && <AnimatedText text="Hello Crank!" />}
</div>
);
}
}

renderer.render(<LettersDemo />, document.body);
Loading...

Async Mount

The schedule() method can also be asynchronous, allowing components to defer their initial mounting:

function *LazyLoadComponent({src}) {
// Async mounting - component waits until image loads
this.schedule(async (img) => {
return new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
});

for ({src} of this) {
yield <img src={src} alt="Lazy loaded" />;
}
}

Complex Coordination

Async mount and unmount enable sophisticated coordination patterns:

function *Modal({children, onClose}) {
// Async mount: slide in from top
this.schedule(async (modal) => {
modal.style.transform = 'translateY(-100%)';
modal.style.transition = 'transform 200ms ease-out';

// Force reflow, then animate in
modal.offsetHeight;
modal.style.transform = 'translateY(0)';

await new Promise(resolve => setTimeout(resolve, 200));
});

// Async unmount: slide out to top
this.cleanup(async (modal) => {
modal.style.transform = 'translateY(-100%)';
await new Promise(resolve => setTimeout(resolve, 200));
});

for ({children, onClose} of this) {
yield (
<div style={{
position: 'fixed',
top: '0',
left: '0',
right: '0',
bottom: '0',
background: 'rgba(0,0,0,0.5)',
display: 'flex',
'align-items': 'center',
'justify-content': 'center'
}}>
<div style={{
background: 'white',
padding: '2rem',
'border-radius': '8px',
'max-width': '500px',
width: '90%'
}}>
{children}
<button onclick={onClose} style={{'margin-top': '1rem'}}>
Close
</button>
</div>
</div>
);
}
}

Promise-based Lifecycle Methods

Starting in 0.7, lifecycle methods return promises when called without arguments, allowing you to await lifecycle events:

async function *Component() {
// Wait for component to be fully mounted
await this.schedule();
console.log('Component is now in the DOM');

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

// Wait for rendering to complete
await this.after();
console.log('Render cycle complete');
}

// Wait for cleanup to finish
await this.cleanup();
console.log('Component cleanup complete');
}

Use Cases for Async Lifecycle

Async mounting is useful for:

Async unmounting enables:

Server-Side Rendering with CSS Extraction

A powerful use case for async mounting is CSS-in-JS extraction during SSR:

import {extractCritical} from '@emotion/server';

function *Root({children}) {
for ({children} of this) {
// First render to extract styles
this.schedule(() => this.refresh());

const html = yield (
<body>
{children}
</body>
);

// Extract critical CSS from the rendered HTML
const {html: finalHtml, css} = extractCritical(html);

// Second render with extracted CSS inlined
yield (
<html>
<head>
<style>{css}</style>
</head>
<body innerHTML={finalHtml} />
</html>
);
}
}

Async lifecycle methods provide fine-grained control over when and how components appear and disappear, enabling smooth user experiences and complex coordination patterns that would be difficult to achieve with synchronous-only lifecycles.

Catching Errors

It can be useful to catch errors thrown by components to show the user an error notification or to notify error-logging services. To facilitate this, Crank will cause yield expressions to rethrow errors which happen when rendering children. You can take advantage of this behavior by wrapping your yield operations in a try / catch block to catch errors caused by children.

import {renderer} from "@b9g/crank/dom";

function *Thrower({shouldThrow}) {
for ({shouldThrow} of this) {
if (shouldThrow) {
throw new Error("Component error triggered!");
}

yield <div style={{color: 'green'}}>✅ Component working fine</div>;
}
}

function *ErrorDemo() {
let shouldThrow = false;

for ({} of this) {
try {
yield (
<div>
<button onclick={() => this.refresh(() => shouldThrow = !shouldThrow)}>
{shouldThrow ? 'Fix Component' : 'Break Component'}
</button>
<Thrower shouldThrow={shouldThrow} />
</div>
);
} catch (err) {
yield (
<div style={{color: 'red', border: '1px solid red', padding: '10px', 'border-radius': '4px'}}>
<div>❌ Error: {err.message}</div>
<button onclick={() => this.refresh(() => shouldThrow = false)}>
Reset Component
</button>
</div>
);
}
}
}

renderer.render(<ErrorDemo />, document.body);
Loading...

Returning values from generator components

When you return from a generator component, the returned value is rendered and the component scope is thrown away, same as would happen when using a function component. This means that the component cannot have local variables which persist across returns.

import {renderer} from "@b9g/crank/dom";
function *Component() {
yield <div>1</div>;
yield <div>2</div>;
return <div>3</div>;
}

function *App() {
for ({} of this) {
yield (
<div>
<Component />
<button onclick={() => this.refresh()}>Refresh</button>
</div>
);
}
}

renderer.render(<App />, document.body);
Loading...