Getting started
Introduction
Choo is a 4kb framework for building SPAs with functional programming patterns. Uses real DOM diffing (nanomorph) and event-based architecture with just 6 API methods.
npm install choo
Basic app
var html = require("choo/html");
var choo = require("choo");
var app = choo();
app.use(countStore);
app.route("/", mainView);
app.mount("body");
Minimal setup with store and route.
View function
function mainView(state, emit) {
return html`
<body>
<h1>count is ${state.count}</h1>
<button onclick=${onclick}>Increment</button>
</body>
`;
function onclick() {
emit("increment", 1);
}
}
Views receive state and emit function.
Store function
function countStore(state, emitter) {
state.count = 0;
emitter.on("increment", function (count) {
state.count += count;
emitter.emit("render");
});
}
Stores manage state and listen to events.
API methods
Initialize
| Method | Description |
|---|---|
choo([opts]) |
Create new app instance |
var app = choo();
Default configuration.
var app = choo({
history: true, // History API enabled
href: true, // Handle <a> clicks
hash: false, // Hash routing disabled
cache: 100, // Component cache size
});
With custom options.
Register store
| Method | Description |
|---|---|
app.use(callback) |
Register a store |
app.use(function (state, emitter, app) {
state.myData = [];
emitter.on("fetch", function () {
fetch("/api/data")
.then((res) => res.json())
.then((data) => {
state.myData = data;
emitter.emit("render");
});
});
});
Store with async data fetching.
function myStore(state, emitter, app) {
// Store logic
}
myStore.storeName = "myStore";
app.use(myStore);
Named store for debugging.
Register route
| Method | Description |
|---|---|
app.route(path, handler) |
Register route and view |
app.route("/", homeView);
app.route("/about", aboutView);
app.route("/users/:id", userView);
app.route("/files/*", filesView);
app.route("*", notFoundView);
Route patterns with params and wildcards.
Mount and start
| Method | Description |
|---|---|
app.mount(selector) |
Mount on DOM element |
app.start() |
Start app, return tree |
app.toString(path, state) |
Render to string (SSR) |
app.mount("body");
Mount on existing DOM node.
var tree = app.start();
document.body.appendChild(tree);
Manual DOM insertion.
var html = app.toString("/", { name: "Node" });
Server-side rendering to HTML string.
Views and templates
HTML templates
var html = require("choo/html");
function myView(state, emit) {
return html`
<div class="container">
<h1>${state.title}</h1>
<button onclick=${handleClick}>Click Me</button>
</div>
`;
function handleClick() {
emit("update-title", "New Title");
}
}
Template literals with tagged html.
Conditional rendering
function view(state, emit) {
return html`
<div>
${state.loaded
? html`<p>Content: ${state.content}</p>`
: html`<p>Loading...</p>`}
</div>
`;
}
Use ternary expressions for conditionals.
Lists
function listView(state, emit) {
return html`
<ul>
${state.items.map((item) => html` <li id=${item.id}>${item.name}</li> `)}
</ul>
`;
}
Use IDs for efficient reordering.
Attributes
html`
<input
type="text"
value=${state.value}
oninput=${(e) => emit("input", e.target.value)}
/>
`;
Dynamic attributes and event handlers.
State object
Built-in properties
| Property | Type | Description |
|---|---|---|
state.events |
Object | Built-in event names |
state.params |
Object | Route parameters |
state.query |
Object | Query string params |
state.href |
String | Current href (no query) |
state.route |
String | Current route pattern |
state.title |
String | Page title |
state.components |
Object | Component state |
state.cache() |
Function | LRU component cache |
Route parameters
app.route("/users/:id", userView);
function userView(state, emit) {
var userId = state.params.id;
return html`<h1>User ${userId}</h1>`;
}
Access params from state.params.
Query parameters
// URL: /search?q=choo&page=2
function searchView(state, emit) {
var query = state.query.q; // 'choo'
var page = state.query.page; // '2'
return html`<h1>Search: ${query}</h1>`;
}
Access query string from state.query.
Custom state
function myStore(state, emitter) {
state.user = null;
state.isLoggedIn = false;
state.todos = [];
}
Add custom properties in stores.
Events
Built-in events
| Event | Constant | When |
|---|---|---|
'DOMContentLoaded' |
state.events.DOMCONTENTLOADED |
DOM ready |
'render' |
state.events.RENDER |
Trigger re-render |
'navigate' |
state.events.NAVIGATE |
Route changed |
'pushState' |
state.events.PUSHSTATE |
Navigate (history add) |
'replaceState' |
state.events.REPLACESTATE |
Navigate (history replace) |
'popState' |
state.events.POPSTATE |
User hit back |
'DOMTitleChange' |
state.events.DOMTITLECHANGE |
Update page title |
Emitting events
function view(state, emit) {
function onClick() {
emit("custom-event", { data: "value" });
}
return html` <button onclick=${onClick}>Trigger</button> `;
}
Emit custom events with data.
function view(state, emit) {
function navigate() {
emit("pushState", "/about");
}
return html` <button onclick=${navigate}>Go to About</button> `;
}
Navigate programmatically.
Listening to events
function myStore(state, emitter) {
emitter.on("DOMContentLoaded", function () {
emitter.on("custom-event", handleEvent);
});
function handleEvent(data) {
state.value = data;
emitter.emit("render");
}
}
Listen in stores, emit render to update.
Event constants
function myStore(state, emitter) {
emitter.on(state.events.DOMCONTENTLOADED, init);
emitter.on(state.events.NAVIGATE, onNavigate);
function init() {
// Initialization
}
function onNavigate() {
// Route changed
}
}
Use constants for built-in events.
Routing
Route patterns
app.route("/", homeView);
app.route("/about", aboutView);
app.route("/users/:id", userView);
app.route("/blog/:year/:month/:slug", postView);
app.route("/files/*", filesView);
app.route("*", notFoundView);
Static, params, wildcard, and catch-all.
Navigation
// Add to history
emit("pushState", "/about");
// Replace history entry
emit("replaceState", "/login");
// With query string
emit("pushState", "/search?q=choo");
Navigate with pushState or replaceState.
html` <a href="/about">About</a> `;
Links are intercepted automatically.
html`
<a href="https://external.com" target="_blank" rel="noopener noreferrer">
External
</a>
`;
External links with target attribute.
Hash routing
var app = choo({ hash: true });
app.route("#/", homeView);
app.route("#/about", aboutView);
Use hash routing instead of history API.
Route events
function routeStore(state, emitter) {
emitter.on("navigate", function () {
console.log("Route:", state.route);
console.log("Params:", state.params);
console.log("Query:", state.query);
});
}
Listen to navigate event for route changes.
Components
Nanocomponent
var Nanocomponent = require("choo/component");
var html = require("choo/html");
class Button extends Nanocomponent {
constructor() {
super();
this.color = null;
}
createElement(color) {
this.color = color;
return html` <button style="background: ${color}">Click Me</button> `;
}
update(newColor) {
return newColor !== this.color;
}
}
Class-based component with lifecycle.
Using components
function view(state, emit) {
var button = state.cache(Button, "my-button");
return html` <div>${button.render("green")}</div> `;
}
Cache and render components.
Lifecycle methods
| Method | Description |
|---|---|
createElement(args) |
Create element (required) |
update(args) |
Return true to re-render |
beforerender(el) |
Before createElement/update |
load(el) |
After mounted to DOM |
unload(el) |
Before removed from DOM |
afterupdate(el) |
After update renders |
afterreorder(el) |
After element reordered |
class MyComponent extends Nanocomponent {
load(el) {
console.log("Mounted:", el);
}
unload(el) {
console.log("Unmounted:", el);
}
}
Lifecycle hooks for side effects.
Component methods
| Method | Description |
|---|---|
component.render(args) |
Call createElement or update |
component.rerender() |
Force re-render |
component.element |
Current DOM element |
var button = state.cache(Button, "btn");
button.render("red");
Render with arguments.
Server-side rendering
Render to string
var app = require("./app");
var state = {
name: "Node",
user: { id: 123 },
};
var html = app.toString("/", state);
var page = `
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
</head>
<body>
${html}
<script>
window.initialState = ${JSON.stringify(state)}
</script>
<script src="/bundle.js"></script>
</body>
</html>
`;
Server-side render with initial state.
Rehydration
var app = choo();
if (typeof window !== "undefined" && window.initialState) {
Object.assign(app.state, window.initialState);
}
app.use(myStore);
app.route("/", mainView);
app.mount("body");
Rehydrate from initial state on client.
Optimization
List keys
function listView(state, emit) {
return html`
<ul>
${state.items.map(
(item) => html` <li id=${item.id} key=${item.id}>${item.name}</li> `,
)}
</ul>
`;
}
Use IDs for efficient list reordering.
Skip diffing
function view(state, emit) {
var staticNode = html`<div>Static content</div>`;
staticNode.isSameNode = () => true;
return html` <div>${staticNode}</div> `;
}
Mark nodes to skip diffing.
Production build
# Using bankai
bankai build index.js
# Browserify transforms
browserify \
-t unassertify \
-t nanohtml \
-g uglifyify \
index.js > bundle.js
Optimize for production.
Common patterns
Async data loading
function dataStore(state, emitter) {
state.data = [];
state.loading = false;
emitter.on("DOMContentLoaded", function () {
emitter.on("load-data", loadData);
});
function loadData() {
state.loading = true;
emitter.emit("render");
fetch("/api/data")
.then((res) => res.json())
.then((data) => {
state.data = data;
state.loading = false;
emitter.emit("render");
})
.catch((err) => {
state.error = err.message;
state.loading = false;
emitter.emit("render");
});
}
}
Loading states with error handling.
Form handling
function formView(state, emit) {
return html`
<form onsubmit=${onSubmit}>
<input
type="text"
value=${state.formData.name}
oninput=${(e) => updateField("name", e.target.value)}
/>
<button type="submit">Submit</button>
</form>
`;
function updateField(field, value) {
emit("update-field", { field, value });
}
function onSubmit(e) {
e.preventDefault();
emit("submit-form", state.formData);
}
}
Controlled form inputs.
Gotchas
State access
⚠️ state.params and state.query are plain objects
// ✅ Correct
var userId = state.params.id;
// ❌ Wrong - they're NOT Promises
var userId = await state.params.id;
Access route/query params directly.
Component unmounting
⚠️ Components lose state when unmounted
// ❌ Wrong - component unmounts
${showButton ? button.render() : null}
// ✅ Correct - keep mounted
${button.render(showButton)}
Keep components mounted to maintain control.
Root node consistency
⚠️ createElement must return same root type
// ❌ Wrong - changes root type
createElement (type) {
return type === 'button'
? html`<button>Click</button>`
: html`<div>Text</div>`
}
// ✅ Correct - consistent root
createElement (type) {
return html`
<div>
${type === 'button'
? html`<button>Click</button>`
: html`<span>Text</span>`
}
</div>
`
}
Wrap in consistent container element.
Back navigation
⚠️ emit('popState') doesn't navigate back
// ❌ Wrong - popState is an event
emit("popState");
// ✅ Correct - use browser API
window.history.go(-1);
// or
window.history.back();
Use browser history API for back navigation.
External links
⚠️ External links need rel attribute
// ❌ Wrong - Choo will intercept
html`<a href="https://external.com" target="_blank">Link</a>`;
// ✅ Correct - ignored by Choo
html`
<a href="https://external.com" target="_blank" rel="noopener noreferrer">
Link
</a>
`;
Add rel attribute for external links.
Also see
- Choo GitHub Repository - Official source code
- Choo Website - Documentation and guides
- Nanocomponent - Component library
- Nanobus - Event emitter
- Bankai - Build tool for Choo apps
- Nanomorph - Real DOM diffing algorithm