Crank.js

The Just JavaScript UI Framework

npm create crank

What is Crank.js?

Crank is a JavaScript/TypeScript library for building websites and applications. It is a UI framework where components are defined with plain JavaScript functions, generators, and promises.

Why is Crank “Just JavaScript?”

Many web frameworks claim to be “just JavaScript.” Few have as strong a claim as Crank.

It starts with the idea that you can write components with all of JavaScript’s built-in function syntaxes.

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

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

for ({} of this) {
yield <p>{seconds} second{seconds !== 1 && "s"}</p>;
}

clearInterval(interval);
}

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

async function Definition({word}) {
// API courtesy https://en.wiktionary.org
const res = await fetch(`https://en.wiktionary.org/api/rest_v1/page/definition/${word.toLowerCase()}`);
if (!res.ok) {
return <p>No definition found for {word}</p>;
}

const data = await res.json();
const [{partOfSpeech, definitions}] = data.en || Object.values(data)[0];
const {definition} = definitions[0];
return <>
<p><b>{word}</b> <i>{partOfSpeech.toLowerCase()}.</i></p>
<p innerHTML={definition} />
</>;
}

// TODO: Uncomment me.
//renderer.render(<Definition word="framework" />, document.body);

Crank components work like normal JavaScript, using standard control-flow. Props can be destructured. Promises can be awaited. Updates can be iterated. State can be held in scope.

The result is a simpler developer experience, where you spend less time writing framework integrations and more time writing vanilla JavaScript.

Three reasons to choose Crank

Reason #1: It’s declarative

Crank works with JSX. It uses tried-and-tested virtual DOM algorithms. Simple components can be defined with functions which return elements.

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

function Greeting({name = "World"}) {
return <p>Hello {name}.</p>;
}

function RandomName() {
const names = ["Alice", "Bob", "Carol", "Dave"];
const randomName = names[Math.floor(Math.random() * names.length)];

// TODO: Uncomment the button.
return (
<div>
<Greeting name={randomName} />
{/*
<button onclick={() => this.refresh()}>Random name</button>
*/}
</div>
);
}

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

Don’t think JSX is vanilla enough? Crank provides a tagged template function which does roughly the same thing.

import {jsx, renderer} from "@b9g/crank/standalone";

function Star({cx, cy, r=50, ir, p=5, fill="red"}) {
cx = parseFloat(cx);
cy = parseFloat(cy);
r = parseFloat(r);
ir = ir == null ? r * 0.4 : parseFloat(ir);
p = parseFloat(p);
const points = [];
const angle = Math.PI / p;
for (let i = 0, a = Math.PI / 2; i < p * 2; i++, a += angle) {
const x = cx + Math.cos(a) * (i % 2 === 0 ? r : ir);
const y = cy - Math.sin(a) * (i % 2 === 0 ? r : ir);
points.push([x, y]);
}

return jsx`
<polygon points=${points} fill=${fill} />
`;
}

function Stars({width, height}) {
const points = 5;
return jsx`
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 ${width} ${height}"
width=${width}
height=${height}
style="border: 1px solid currentcolor"
>
<!--
Refactoring this to be less repetitive has been left
as an exercise for the reader.
-->
<${Star} p=${points} cx="70" cy="70" r="50" fill="red" />
<${Star} p=${points} cx="80" cy="80" r="50" fill="orange" />
<${Star} p=${points} cx="90" cy="90" r="50" fill="yellow" />
<${Star} p=${points} cx="100" cy="100" r="50" fill="green" />
<${Star} p=${points} cx="110" cy="110" r="50" fill="dodgerblue" />
<${Star} p=${points} cx="120" cy="120" r="50" fill="indigo" />
<${Star} p=${points} cx="130" cy="130" r="50" fill="purple" />
</svg>
`;
}

const inspirationalWords = [
"I believe in you.",
"You are great.",
"Get back to work.",
"We got this.",
];

function MotivationalPoster() {
return jsx`
<p>${inspirationalWords[Math.floor(Math.random() * inspirationalWords.length)]}</p>
`;
}

renderer.render(jsx`
<div
class="motivational-poster"
style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
"
>
<${Stars} width=${200} height=${200} />
<${MotivationalPoster} />
</div>
`, document.body);

Reason #2: It’s predictable

Crank uses generator functions to define stateful components. You store state in local variables, and yield rather than return to keep it around.

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

function Greeting({name = "World"}) {
return <p>Hello {name}.</p>;
}

function *CyclingName() {
const names = ["Alice", "Bob", "Carol", "Dave"];
let i = 0;
for ({} of this) {
yield (
<div>
<Greeting name={names[i % names.length]} />
<button onclick={() => this.refresh()}>Cycle name</button>
</div>
)

i++;
}
}

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

Components rerender based on explicit refresh() calls. This level of precision means you can be as messy as you need to be.

Never memoize a callback ever again.

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

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

const toggleInterval = () => {
if (interval == null) {
startInterval();
} else {
clearInterval(interval);
interval = null;
}

this.refresh();
};

const resetInterval = () => {
this.refresh(() => {
seconds = 0;
clearInterval(interval);
interval = null;
});
};

// The context passed to a Crank component is an iterable of props.
for ({} of this) {
// Welcome to the render loop.
// Most generator components should use render loops even if they do not
// use props.
// The render loop provides useful behavior like preventing infinite loops
// because of a forgotten yield.
yield (
<div>
<p>{seconds} second{seconds !== 1 && "s"}</p>
<button onclick={toggleInterval}>
{interval == null ? "Start timer" : "Stop timer"}
</button>
{" "}
<button onclick={resetInterval}>Reset timer</button>
</div>
);
}

// You can place cleanup code after the loop.
clearInterval(interval);
}

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

Reason #3: It’s promise-friendly.

Any component can be made asynchronous with the async keyword. This means you can await fetch() directly in any component, client or server.

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

async function Definition({word}) {
// API courtesy https://en.wiktionary.org
const res = await fetch(`https://en.wiktionary.org/api/rest_v1/page/definition/${word.toLowerCase()}`);
if (!res.ok) {
return (
<div>No definition found for {word}</div>
);
}

const data = await res.json();
const [{partOfSpeech, definitions}] = data.en || Object.values(data)[0];
const {definition} = definitions[0];
return (
<div>
<p><b>{word}</b> <i>{partOfSpeech.toLowerCase()}.</i></p>
<p innerHTML={definition} />
</div>
);
}

function *Dictionary() {
let word = "";
const onsubmit = (ev) => {
ev.preventDefault();
const formData = new FormData(ev.target);
const word1 = formData.get("word");
if (word1.trim()) {
this.refresh(() => word = word1);
}
};

for ({} of this) {
yield (
<>
<form
action=""
method="get"
onsubmit={onsubmit}
style="margin-bottom: 15px"
>
<div style="margin-bottom: 15px">
<label for="name">Define:</label>{" "}
<input type="text" name="word" id="word" required />
</div>
<div>
<input type="submit" value="Search" />
</div>
</form>
{word && <Definition word={word} />}
</>
);
}
}

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

Async generator functions let you write components that are both async and stateful. Crank uses promises wherever they make sense, and has a rich async execution model which allows you to do things like racing components to display loading states.

import {renderer} from "@b9g/crank/dom";
import {Suspense} from "@b9g/crank/async";
function describeWeather(code) {
if (code === 0) return "Clear";
if (code <= 3) return "Partly cloudy";
if (code <= 48) return "Foggy";
if (code <= 67) return "Rainy";
if (code <= 77) return "Snowy";
if (code <= 82) return "Showers";
return "Stormy";
}

function WeatherCard({city, conditions, temperature, windspeed}) {
return (
<div style={`
padding: 10px;
margin: 10px 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
border: 1px solid currentcolor;
border-radius: 10px;
`}>
<pre>{city}</pre>
<pre>{conditions}</pre>

<pre>{temperature}</pre>
<pre>{windspeed}</pre>
</div>
);
}

async function *LoadingWeatherCard() {
let count = 0;
const interval = setInterval(() => {
this.refresh(() => count++);
}, 250);

this.cleanup(() => clearInterval(interval));

for ({} of this) {
yield (
<WeatherCard
city={"Loading" + ".".repeat(count % 4)}
conditions="??"
temperature="__°C"
windspeed="__ km/h"
/>
);
}
}

async function CityWeather({city, throttle}) {
if (throttle) {
await new Promise((r) => setTimeout(r, 2000));
}
// Geocoding and weather data courtesy https://open-meteo.com
const geoRes = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`,
);
const {results: [place] = []} = await geoRes.json();
if (!place) {
return <div>No weather found for {city}.</div>;
}

const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}&longitude=${place.longitude}&current_weather=true`,
);
const {current_weather: weather} = await res.json();
return (
<WeatherCard
city={place.name}
conditions={describeWeather(weather.weathercode)}
temperature={`${weather.temperature}°C`}
windspeed={`${weather.windspeed} km/h`}
/>
);
}

function CityWeatherCard({city, throttle}) {
return (
<Suspense fallback={<LoadingWeatherCard />}>
<CityWeather city={city} throttle={throttle} />
</Suspense>
);
}

function *WeatherStation() {
let city = "New York";
let throttle = false;
const onsubmit = (ev) => {
ev.preventDefault();
const city1 = new FormData(ev.target).get("city");
if (city1.trim()) {
this.refresh(() => city = city1);
}
};

const toggleThrottle = () => {
this.refresh(() => throttle = !throttle);
};

for ({} of this) {
yield (
<div>
<form action="" method="get" onsubmit={onsubmit}>
<label for="city">City:</label>
{" "}
<input type="text" name="city" id="city" value={city} required />
{" "}
<input type="submit" value="Get weather" />
{" "}
<button type="button" onclick={toggleThrottle}>
{throttle ? "Unthrottle" : "Throttle"} API
</button>
</form>
<CityWeatherCard city={city} throttle={throttle} />
</div>
);
}
}

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

From the Blog