choo
5kb
framework for creating sturdy frontend applications
Handbook | Packages | Contributing | Chat
Table of Contents
Features
- minimal size: weighing
5kb
,choo
is 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
browserify
compiler - transparent side effects: using
effects
andsubscriptions
brings clarity to IO - omakase: composed out of a balanced selection of open source packages
- isomorphic: renders seamlessly in both Node and browsers
- very cute: choo choo!
Demos
- π Input example (repo)
- π HTTP effects example (repo)
- π« Mailbox routing
- π TodoMVC (repo)
- π₯ Choo-firebase
- π± Grow (repo)
note: If you've built something cool using choo
or are using it in
production, we'd love to hear from you!
Example
Let's create an input box that changes the content of a textbox in real time. Click here to see the app running.
const choo = require('choo')
const html = require('choo/html')
const app = choo()
app.model({
state: { title: 'Not quite set yet' },
reducers: {
update: (data, state) => ({ title: data })
}
})
const mainView = (state, prev, send) => html`
<main>
<h1>Title: ${state.title}</h1>
<input
type="text"
oninput=${(e) => send('update', e.target.value)}>
</main>
`
app.router((route) => [
route('/', mainView)
])
const tree = app.start()
document.body.appendChild(tree)
To run it, save it as client.js
and run with budo and
es2020. These tools are convenient but any browserify
based tool should do:
$ budo client.js -p 8080 --open -- -t es2020
And to save the output to files so it can be deployed, open a new terminal and do:
$ mkdir -p 'dist/'
$ curl 'localhost:8080' > 'dist/index.html'
$ curl 'localhost:8080/bundle.js' > 'dist/bundle.js'
All using a couple of shell commands and .js
files, no grandiose boilerplate
needed.
Philosophy
We believe programming should be fun and light, not stern and stressful. It's cool to be cute; using serious words without explaining them doesn't make for better results - if anything it scares people off. We don't want to be scary, we want to be nice and fun, and then casually be the best choice around. Real casually.
We believe frameworks should be disposable, and components recyclable. We don't
like the current state of web development where walled gardens jealously
compete with one another. We want you to be free, not shackled to a damp
dungeon wall. By making the DOM the lowest common denominator, switching from
one framework to another becomes frictionless. Components should run anywhere
that has a DOM, regardless of the framework. choo
is modest in its design; we
don't believe it will be top of the class forever, so we've made it as easy to
toss out as it is to pick up.
We don't believe that bigger is better. Big APIs, big dependencies, large file sizes - we see them as omens of impending userland complexity. We want everyone on a team, no matter the size, to fully understand how an application is laid out. And once an application is built, we want it to be small, performant and easy to reason about. All of which makes for easy to debug code, better results and super smiley faces.
Concepts
choo
cleanly structures internal data flow, so that all pieces of logic can
be combined into a nice, cohesive machine. Internally all logic lives within
models
that contain several properties. subscriptions
are functions that
are called at startup and have send()
passed in, so they act as read-only
sources of data. effects
react to changes, perform an action
and can then
post the results. reducers
take data, modify it, and update the internal
state
.
Communication of data is done using something called actions
. Each action
consists of a unique actionName
and an optional payload of data
, which can
be any value.
When a reducer
modifies state
, the router
is called, which in turn calls
views
. views
take state
and return DOM nodes which are then
efficiently rendered on the screen.
In turn when the views
are rendered, the user
can interact with elements by
clicking on them, triggering actions
which then flow back into the
application logic. This is the unidirectional architecture of choo
.
βββββββββββββββββββ
β Subscriptions ββ€ User ββββ
ββ Effects βββββββ€ βΌ
ββ Reducers βββββββ΄ββActionsββ DOM ββ
β β
ββΆ Router βββββState ββββΆ Views βββββ
- 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
effects
andreducers
that have been registered inmodels
- model: optionally namespaced object containing
subscriptions
,effects
,reducers
and initialstate
- subscriptions: read-only data sources that emit
actions
- effects: asynchronous functions that emit an
action
when done - reducers: synchronous functions that modify
state
- state: a single object that contains all the values used in your application
- router: determines which
view
to render - views: take
state
and returns a newDOM tree
that is rendered in the browser
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 state within the model can be
accessed. Models can still trigger actions on other models, though it's
recommended to keep that to a minimum.
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.items
. Inside the namespaced model they're called by
send('todos:add')
and state.items
. An example namespaced model:
const app = choo()
app.model({
namespace: 'todos',
state: { items: [] },
reducers: {
add: (data, state) => ({ items: state.items.concat(data.payload) })
}
})
In most cases using namespaces is beneficial, as having clear boundaries 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
Examples of effects include: performing
xhr requests
(server requests), calling multiple reducers
, persisting state to
localstorage.
When an effect
is done executing, it should call the done(err, res)
callback. This callback used to communicate when an effect
is done, handle
possible errors and send values back to the caller. You'll probably notice when
applications become more complex, that composing multiple namespaced models
using higher level effects becomes real powerful - without becoming
complicated.
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()
app.model({
namespace: 'app',
subscriptions: [
(send, done) => {
setInterval(() => {
send('app:print', { payload: 'dog?', myOtherValue: 1000 }, (err) => {
if (err) return done(err)
})
}, 1000)
}
],
effects: {
print: (data, state) => console.log(data.payload)
}
})
If a subscription
runs into an error, it can call done(err)
to signal the
error to the error hook.
Router
The router
manages which views
are rendered at any given time. It also
supports rendering a default view
if no routes match.
const app = choo()
app.router('/404', (route) => [
route('/', require('./views/empty')),
route('/404', require('./views/error')),
route('/:mailbox', require('./views/mailbox'), [
route('/:message', require('./views/email'))
])
])
Routes on the router
are passed in as a nested array. This means that the
entry point of the application also becomes a site map, making it easier to
figure out how views relate to each other.
Under the hood choo
uses sheet-router. Internally the
currently rendered route is kept in state.app.location
. If you want to modify
the location programmatically the reducer
for the location can be called
using send('location:setLocation', { location: href })
. This will not work
from within namespaced models
, and usage should preferably be kept to a
minimum. Changing views all over the place tends to lead to messiness.
Views
Views are pure functions that return a DOM tree for the router to render. Theyβre passed the current state, and any time the state changes theyβre run again with the new state.
Views are also passed the send
function, which they can use to dispatch actions that can update the state. For example, the DOM tree can have an onclick
handler that dispatches an add
action.
const view = (state, prev, send) => {
return html`
<div>
<h1>Total todos: ${state.todos.length}</h1>
<button onclick=${(e) => send('add', {title: 'demo'})}>
Add
</button>
</div>`
}
In this example, when the Add
button is clicked, the view will dispatch an add
action that the modelβs add
reducer will receive. As seen above, the reducer will add an item to the stateβs todos
array. The state change will cause this view to be run again with the new state, and the resulting DOM tree will be used to efficiently patch the DOM.
Badges
Using choo
in a project? Show off which version you've used using a badge:
[![built with choo v3](https://img.shields.io/badge/built%20with%20choo-v3-ffc3e4.svg?style=flat-square)](https://github.com/yoshuawuyts/choo)
API
This section provides documentation on how each function in choo
works. It's
intended to be a technical reference. If you're interested in learning choo for
the first time, consider reading through the handbook or
concepts first β¨
app = choo(opts)
Initialize a new choo
app. Takes an optional object of handlers. Handlers can
be:
- onError(err, state, createSend): called when an
effect
orsubscription
emit an error. If no handler is passed, the default handler willthrow
on each error. - onAction(action, state, name, caller, createSend): called when an
action
is fired. - onStateChange(action, state, prev, caller, createSend): called after a
reducer changes the
state
.
createSend()
is a special function that allows the creation of a new named
send()
function. The first argument should be a string which is the name, the
second argument is a boolean callOnError
which can be set to true
to call
the onError
hook istead of a provided callback. It then returns a
send(actionName, data?)
function.
Handlers should be used with care, as they're the most powerful interface into
the state. For application level code it's generally recommended to delegate to
actions inside models using the send()
call, and only shape the actions
inside the handlers.
app.model(obj)
Create a new model. Models modify data and perform IO. Takes the following arguments:
- namespace: namespace the model so that it cannot access any properties and handlers in other models
- state: initial values of
state
inside the model - reducers: synchronous operations that modify state. Triggered by
actions
. Signature of(data, state)
. - effects: asynchronous operations that don't modify state directly.
Triggered by
actions
, can callactions
. Signature of(data, state, send, done)
- subscriptions: asynchronous read-only operations that don't modify state
directly. Can call
actions
. Signature of(send, done)
.
send(actionName, data?[,callback])
Send a new action to the models with optional data attached. Namespaced models
can be accessed by prefixing the name with the namespace separated with a :
,
e.g. namespace:name
.
When sending data from inside a model
it expects exactly three arguments: the name of the action you're calling, the data you want to send, and finally a callback to handle errors through the global onError()
hook. So if you want to send two values, you'd have to either send an array or object containing them.
done(err?, res?)
When an effect
or subscription
is done executing, or encounters an error,
it should call the final done(err, res)
callback. If an effect
was called
by another effect
it will call the callback of the caller. When an error
propegates all the way to the top, the onError
handler will be called,
registered in choo(handlers)
. If no callback is registered, errors will
throw
.
app.router(defaultRoute?, (route) => [routes])
Creates a new router. Takes a function that exposes a single route
function,
and that expects a tree of routes
to be returned. See
sheet-router
for full
documentation. Registered views have a signature of (state, prev, send)
,
where state
is the current state
, prev
is the last state, state.params
is URI partials and send()
can be called to trigger actions. If
defaultRoute
is passed in, that will be called if no paths match. If no
defaultRoute
is specified it will throw instead.
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 an
optional state object. When calling .toString()
instead of .start()
, all
calls to send()
are disabled, and subscriptions
, effects
and reducers
aren't loaded.
tree = app.start(rootId?, opts)
Start the application. Returns a tree of DOM nodes that can be mounted using
document.body.appendChild()
. If a valid id
selector is passed in as the
first argument, the tree will diff against the selected node rather than be
returned. This is useful for rehydration. Opts can contain the
following values:
- opts.history: default:
true
. Enable asubscription
to the browser history API. e.g. updates the internallocation.href
state whenever the browsers "forward" and "backward" buttons are pressed. - opts.href: default:
true
. Handle all relative<a href="<location>"></a>
clicks and update internalstate.location
accordingly. - opts.hash: default:
false
. Enable asubscription
to the hash change event, updating the internalstate.location
state whenever the URL hash changes (eglocalhost/#posts/123
). Enabling this option automatically disablesopts.history
andopts.href
.
view = require('choo/html')`html`
Tagged template string HTML builder. Built on top of yo-yo, bel
and hyperx. To register a view on the router
it should be wrapped
in a function with the signature of (state, prev, send)
where state
is the
current state
, prev
is the last state, state.params
is URI partials and
send()
can be called to trigger actions.
To create listeners for events, create interpolated attributes on elements.
const html = require('choo/html')
html`
<button onclick=${(e) => console.log(e)}>click for bananas</button>
`
Example listeners include: onclick
, onsubmit
, oninput
, onkeydown
,
onkeyup
. A full list can be found at the yo-yo
repo. When
creating listeners always remember to call e.preventDefault()
on the event so
it doesn't bubble up and do stuff like refreshing the full page or the like.
To trigger lifecycle events on any part of a view, set the onload=${(el) => {}}
and onunload=${() => {el}}
attributes. These parameters are useful when
creating self-contained widgets that take care of their own state and lifecycle
(e.g. a maps widget) or to trigger animations. Most elements shouldn't have a
need for these hooks though.
FAQ
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 choo
.
:steam_locomotive::train::train::train:
Why is it a framework, and not a library?
I love small libraries that do one thing well, but when working in a team,
having an undocumented combination of packages often isn't great. choo()
is a
small set of packages that work well together, wrapped in an an architectural
pattern. This means you get all the benefits of small packages, but get to be
productive right from the start without needing to plough through layers of
boilerplate.
Is it called choo, choo.js or...?
It's called "choo", though we're fine if you call it "choo-choo" or "chugga-chugga-choo-choo" too. The only time "choo.js" is tolerated is if / when you shimmy like you're a locomotive.
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. Ready? Here goes:
- react: despite being at the root of a giant paradigm shift for frontend
(thank you forever!),
react
is kind of big (155kb
was it?). They also like classes a lot, and enforce a lot of abstractions. It also encourages the use ofJSX
andbabel
which break JavaScript, The Languageβ’. And all that without making clear how code should flow, which is crucial in a team setting. I don't like complicated things and in my viewreact
is one of them.react
is 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
. However it doesn't fix the large dependenciesreact
seems to use (e.g.react-router
and friends) and doesn't help at all with architecture. Ifreact
is your jam, and you will not budge, sitting at3kb
this is probably a welcome gift. - angular: definitely not for me. I like small things with a clear mental
model;
angular
doesn'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
TypeScript
andRxJS
definitely hasn't made things simpler. Last I checked it was~200kb
in size before including some monstrous extra deps. I guessangular
and I will just never get along. - mercury: ah,
mercury
is 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 usingmercury
helped shapedchoo
greatly, despite not working out for me. - deku:
deku
is fun. I even contributed a bit in the early days. It could probably best be described as "a functional version ofreact
". The dependence onJSX
isn't great, but give it a shot if you think it looks neat. - cycle:
cycle
's pretty good - unlike most frameworks it lays out a clear architecture which helps with reasoning about it. That said, it's built onvirtual-dom
andRxJS
which are a bit heavy for my taste.choo
works pretty well for FRP style programming, but something like inu might be an interesting alternative. - vue: like
cycle
,vue
is pretty good. But it also uses tech that provides framework lock in, and additionally doesn't have a clean enough architecture. I appreciate what it does, but don't think it's the answer.
Why can't send() be called on the server?
In Node, reducers
, effects
and subscriptions
are disabled for performance
reasons, so if send()
was called to trigger an action it wouldn't work. Try
finding where in the DOM tree send()
is called, and disable it when called
from within Node.
Which packages was choo built on?
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.
How can I optimize choo?
choo
really shines when coupled with browserify
transforms. They can do
things like reduce file size, prune dependencies and clean up boilerplate code.
Consider running some of the following:
- unassertify - remove
assert()
statements which reduces file size. Use as a--global
transform - es2020 - backport
const
,fat-arrows
andtemplate strings
to older browsers. Should be run as a--global
transform - yo-yoify - replace the internal
hyperx
dependency withdocument.createElement
calls; greatly speeds up performance too - uglifyify - minify your code using
UglifyJS2. Use as a
--global
transform - bulkify - transform inline bulk-require calls into statically resolvable require maps
- envify - replace
process.env
values with plain strings
Choo + Internet Explorer & Safari
Out of the box choo
only supports runtimes which support:
const
fat-arrow
functions (e.g.() => {}
)template-strings
This does not include Safari 9 or any version of IE. If support for these platforms is required you will have to provide some sort of transform that makes this functionality available in older browsers. The test suite uses es2020 as a global transform, but anything else which might satisfy this requirement is fair game.
Generally for production builds you'll want to run:
$ NODE_ENV=production browserify \
-t envify \
-g yo-yoify \
-g unassertify \
-g es2020 \
-g uglifyify \
| uglifyjs
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.
Browser Test Status
Installation
$ npm install choo
See Also
- choo-handbook - the little
choo
guide - awesome-choo - Awesome things related with choo framework
- budo - quick prototyping tool for
browserify
- stack.gl - open software ecosystem for WebGL
- yo-yo - tiny library for modular UI
- bel - composable DOM elements using template strings
- tachyons - functional CSS for humans
- sheetify - modular CSS bundler for
browserify
- pull-stream - minimal streams
- es2020 - because in hindsight we don't need most of ES6