choo 
:steam_locomotive::train::train::train::train:🚋 - The little framework that could.
A framework for creating sturdy web applications. Built on years of industry experience it distills the essence of functional architectures into a productive package.
Features
- minimal size: weighing
7kb,choois a tiny little framework - single state: immutable single state helps reason about changes
- small api: with only 6 methods, there's not a lot to learn
- minimal tooling: built for the cutting edge
browserifycompiler - transparent side effects: using
effectsandsubscriptionsbrings clarity to IO - omakase: composed out of a balanced selection of open source packages
- idempotent: renders seemlessly in both Node and browsers
- very cute: choo choo!
Demos
- Input example (@examples directory)
- HTTP effects example (@examples directory)
- Mailbox routing example (@examples directory)
- TodoMVC (github)
Usage
const choo = require('choo')
const app = choo()
app.model({
namespace: 'input',
state: {
title: 'my demo app'
},
reducers: {
update: (action, state) => ({ title: action.payload })
},
effects: {
update: (action, state, send) => (document.title = action.payload)
}
})
const mainView = (params, state, send) => choo.view`
<main class="app">
<h1>${state.title}</h1>
<label>Set the title</label>
<input
type="text"
placeholder=${state.input.title}
oninput=${(e) => send('input:update', { payload: e.target.value })}>
</main>
`
app.router((route) => [
route('/', mainView)
])
const tree = app.start()
document.body.appendChild(tree)
Concepts
choo is a complete framework. It has an answer to pretty most points
- user: 🙆
- DOM: the Document Object Model is what is currently displayed in your browser
- actions: a named event with optional properties attached. Used to call
effectsandreducersthat have been registered inmodels - model: optionally namespaced object containing
subscriptions,effects,reducersand initialstate - subscriptions: read-only data sources that emit
actions - effects: asynchronous functions that emit an
actionwhen done - reducers: synchronous functions that modify
state - state: a single object that contains all the values used in your application
- router: determines which
viewto render - views: take
stateand returns a newDOM treethat is rendered in the browser
┌───────────────────────────┐ ┌────────┐
│ ┌─────────────────┐ │ │ User │
├────│ Subscriptions │ │ └────────┘
│ ├─────────────────┤ │ │
└────│ Effects │◀───┤ ▼
├─────────────────┤ Actions ┌────────┐
│ Reducers │◀───┴─────│ DOM │
Models──────────────┘ └────────┘
│ ▲
State DOM│tree
▼ │
┌────────┐ ┌────────┐
│ Router │─────State ───▶│ Views │
└────────┘ └────────┘
Models
models are objects that contain initial state, subscriptions, effects
and reducers. They're generally grouped around a theme (or domain, if you
like). To provide some sturdiness to your models, they can either be
namespaced or not. Namespacing means that only actions and state inside the
model can be called.
So say we have a todos namespace, an add reducer and a todos model.
Outside the model they're called by send('todos:add') and
state.todos.todos. Inside the namespaced model they're called by
send('add') and state.todos. An example namespaced model:
const app = choo()
app.model({
namespace: 'todos',
model: { todos: [] },
reducers: {
add: (state, action) => ({ todos: state.todos.concat(action.payload) })
}
})
In most cases using namespaces is beneficial, as having clear boundries makes
it easier to follow logic. But sometimes you need to call actions that
operate over multiple domains (such as a "logout" action), or have a
subscription that might trigger multiple reducers (such as a websocket
that calls a different action based on the incoming data).
In these cases you probably want to have a model that doesn't use namespaces,
and has access to the full application state. Try and keep the logic in these
models to a minimum, and declare as few reducers as possible. That way the
bulk of your logic will safely shielded, with only a few points touching every
part of your application.
Effects
Side effects are done through effects declared in app.model(). Unlike
reducers they cannot modify the state by returning objects, but get a
callback passed which is used to emit actions to handle results. Use effects
every time you don't need to modify the state object directly, but wish to
respond to an action.
A typical effect flow looks like:
- An action is received
- An effect is triggered
- The effect performs an async call
- When the async call is done, either a success or error action is emitted
- A reducer catches the action and updates the state
HTTP
choo ships with a built-in http module
that weighs only 2.4kb:
const http = require('choo/http')
const choo = require('choo')
const app = choo()
app.model({
effects: {
'app:error': (state, event_ => console.error(`error: ${event.payload}`)),
'app:print': (state, event) => console.log(`http: ${event.payload}`),
'http:get_json': getJson,
'http:post_json': postJson,
'http:delete': httpDelete
}
})
function getJson (state, action, send) {
http.get('/my-endpoint', { json: true }, function (err, res, body) {
if (err) return send('app:error', { payload: err.message })
if (res.statusCode !== 200 || !body) {
return send('app:error', { payload:'something went wrong' })
}
send('app:print', { payload: body })
})
}
function postJson (state, action, send) {
const body = { foo: 'bar' }
http.post('/my-endpoint', { json: body }, function (err, res, body) {
if (err) return send('app:error', { payload: err.message })
if (res.statusCode !== 200 || !body) {
return send('app:error', { payload:'something went wrong' })
}
send('app:print', { payload: body })
})
}
function httpDelete (state, action, send) {
const body = { foo: 'bar' }
http.post('/my-endpoint', { json: body }, function (err, res, body) {
if (err) return send('app:error', { payload: err.message })
if (res.statusCode !== 200) {
return send('app:error', { payload:'something went wrong' })
}
})
}
Note that http only runs in the browser to prevent accidental requests when
rendering in Node. For more details view the raynos/xhr
documentation.
Subscriptions
Subscriptions are a way of receiving data from a source. For example when
listening for events from a server using SSE or Websockets for a
chat app, or when catching keyboard input for a videogame.
An example subscription that logs "dog?" every second:
const app = choo()
choo.model({
subscriptions: [
(send) => setTimeout(() => send('app:print', { payload: 'dog?' }), 1000)
],
effects: {
'app:print': (state, action) => console.log(action.payload)
}
})
Server Sent Events (SSE)
Server Sent Events (SSE) allow servers to push data to the browser.
They're the unidirectional cousin of websockets and compliment HTTP
brilliantly. To enable SSE, create a new EventSource, point it at a local
uri (generally /sse) and setup a subscription:
const stream = new document.EventSource('/sse')
app.model({
subscriptions: [
function (send) {
stream.onerror = (e) => send('app:error', { payload: JSON.stringify(e) })
stream.onmessage = (e) => send('app:print', { payload: e.data })
}
],
effects: {
'sse:close': () => stream.close()
'app:error': (state, event_ => console.error(`error: ${event.payload}`)),
'app:print': (state, event) => console.log(`sse: ${event.payload}`)
}
})
This code does not handle reconnects, server timeouts, exponential backoff and
queueing data. You might want to use a package from npm or write your
own if you're building something for production.
Keyboard
Most browsers have basic support for keyboard events. To
capture keyboard events, setup a subscription:
app.model({
subscriptions: [
function (send) {
keyboard.onkeypress = (e) => send('app:print', { payload: e.keyCode })
}
],
effects: {
'app:print': (state, event) => console.log(`pressed key: ${event.payload}`)
}
})
WebSockets
WebSockets allow for bidirectional communication between servers and browsers:
const socket = new document.WebSocket('ws://localhost:8081')
app.model({
subscriptions: [
function (send) {
socket.onerror = (e) => send('app:error', { payload: JSON.stringify(e) })
socket.onmessage = (e) => send('app:print', { payload: e.data })
}
],
effects: {
'ws:close': () => socket.close(),
'ws:send': (state, event) => socket.send(JSON.stringify(event.payload)),
'app:error': (state, event_ => console.error(`error: ${event.payload}`)),
'app:print': (state, event) => console.log(`ws: ${event.payload}`)
}
})
This code does not handle reconnects, server timeouts, exponential backoff and
queueing data. You might want to use a package from npm or write your
own if you're building something for production.
Rendering in Node
Sometimes it's necessary to render code inside of Node; for serving first requests, testing or other purposes. Applications that are capable of being rendered in both Node and the browser are called isomorphic.
Rendering in Node is slightly different than in the browser. First off, to
maintain performance all calls to subscriptions, effects, and reducers
are disabled. That means you need to know what the state of your application is
going to be before you render it - no cheating!
Secondly, the send() method inside router and view has been disabled. If
you call it your program will crash. Disabling all these things means that your
program will render O(n), which is super neat. Off to 10.000
QPS we go!
To render in Node call the .toString() method instead of .start(). The
first argument is the path that should be rendered, the second is the state:
const http = require('http')
const client = require('./client') // path to client entry point
http.createServer(function (req, res) {
const html = client.toString('/', { message: 'hello server!' })
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(html)
})
In order to make our choo app call app.start() in the browser and be
require()-able in Node, we check if module.parent exists:
const choo = require('choo')
const app = choo()
app.router((route) => [
route('/', (params, state, send) => choo.view`
<h1>${state.message}</h1>
`)
])
if (module.parent) module.exports = app
else document.body.appendChild(app.start())
API
app = choo()
Create a new choo app
app.model(obj)
Create a new model. Models modify data and perform IO. Takes the following arguments:
- namespace: optional namespace that prefixes the keys in
state,reducersandeffects. Also limitsactionscalled bysend()to in-namespace only. - state: object. Key value store of initial values
- reducers: object. Syncronous functions that modify state. Each function
has a signature of
(action, state) - effects: object. Asyncronous functions that perform IO. Each function has
a signature of
(action, state, send)wheresendis a reference toapp.send()
choo.view`html`
Tagged template string HTML builder. See
yo-yo for full documentation. Views
should be passed to app.router()
app.router(params, state, send)
Creates a new router. See
sheet-router for full
documentation. Registered views have a signature of (params, state, send),
where params is URI partials.
html = app.toString(route, state)
Render the application to a string of HTML. Useful for rendering on the server.
First argument is a path that's passed to the router. Second argument is the
state object. When calling .toString() instead of .start(), all calls to
send() are disabled, and subscriptions, effects and reducers aren't
loaded. See rendering in Node for an in-depth guide.
tree = app.start(opts)
Start the application. Returns a tree of DOM nodes that can be mounted using
document.body.appendChild(). Opts can contain the following values:
- opts.history: default:
true. Enable asubscriptionto the browser history API. e.g. updates the internalstate.locationstate whenever the browser "forward" and "backward" buttons are pressed. - opts.href: default:
true. Handle all relative<a href="<location>"></a>clicks and update internalstate.locationaccordingly.
FAQ
Why did you build this?
choo is nothing but a formalization of how I've been building my applications
for the past year. I originally used virtual-dom with virtual-app and
wayfarer where now it's yo-yo with send-action and sheet-router. The
main benefit of using choo over these technologies separately is that it
becomes easier for teams to pick up and gather around. The code base for choo
itself is super petite (~150 LOC) and mostly acts to enforce structure around
some excellent npm packages. This is my take on modular frameworks; I hope
you'll find it pleasant.
Why is it called choo?
Because I thought it sounded cute. All these programs talk about being
"performant", "rigid", "robust" - I like programming to be light, fun and
non-scary. choo embraces that.
Also imagine telling some business people you chose to rewrite something
critical to the company using the choo framework.
:steam_locomotive::train::train:🚋
How does choo compare to X?
Ah, so this is where I get to rant. choo (chugga-chugga-chugga-choo-choo!)
was built because other options didn't quite cut it for me, so instead of
presenting some faux-objective chart with skewed benchmarks and checklists I'll
give you my opinions directly instead. Ready? Here goes:
- react:
reactis kind of big (155kbwas it?), has a lot of new, odd words and does weird things with versioning. They also like classes a lot, and enforce a lot of abstractions. It also encourages the use ofJSXandbabelwhich break JavaScript, The Language™. And all that without even making clear how code should flow, which is bad in a team setting. I don't like complicated things and in my viewreactis one of them.reactis not for me. - mithril: never used it, never will. I didn't like the API, but if you like it maybe it's worth a shot - the API seems small enough. I wouldn't know how pleasant it is past face value.
- preact: a pretty cool idea; seems to fix most of what is wrong with
react- except what is broken by design (the API). It also doesn't fix the large dependenciesreactseems to use (e.g.react-routerand friends). Ifreactis your jam, and you will not budge, sitting at3kbthis is probably a welcome gift. - angular: definitely not for me. I like small things with a clear mental
model;
angulardoesn't tick any box in my book of nice things. - angular2: I'm not sure what's exactly changed, but I know the addition of
TypeScriptandRxJSdefinitely hasn't made things simpler. Last I checked it was~200kbin size before including some monstrous extra deps. I guessangularand I will just never get along. - mercury: ah,
mercuryis an interesting one. It seemed like a brilliant idea until I started using it - the abstractions felt heavy, and it took team members a long time to pick up. In the end I think usingmercuryhelped greatly in gettingchoowhere it is now. - deku:
dekuis fun. I even contributed a bit in the early days. It could probably best be described as "a functional version ofreact". The dependence onJSXisn't great, but give it a shot if you think it looks neat.
Which packages was choo built on?
- views:
yo-yo - models:
send-action,xtend - routes:
sheet-router - http:
xhr
Does choo use a virtual-dom?
choo uses morphdom, which diffs real DOM nodes instead of virtual
nodes. It turns out that browsers are actually ridiculously good at dealing
with DOM nodes, and it has the added benefit of working with
any library that produces valid DOM nodes. So to put a long answer short:
we're using something even better.
What packages do you recommend to pair with choo?
- tachyons - functional CSS for humans
- sheetify - modular CSS bundler for browserify
- pull-stream - minimal streams
How can I optimize choo?
To bring down file size, consider running the following browserify
transforms:
- unassertify - remove
assert()statements which reduces file size. Use as a--globaltransform - varify - replace
constwithvarstatements. Use as a--globaltransform - uglifyify - minify your code using
UglifyJS2. Use as a
--globaltransform
Hey, doesn't this look a lot like Elm?
Yup, it's greatly inspired by the elm architecture. But contrary to elm,
choo doesn't introduce a completely new language to build web applications.
Is it production ready?
Sure.
Installation
$ npm install choo