πŸš‚πŸš‹πŸš‹πŸš‹ - sturdy frontend framework
JavaScript Shell
Latest commit eea83d2 Jan 21, 2017 @yoshuawuyts 4.1.0

README.md

choo

πŸš‚πŸš‹πŸš‹πŸš‹πŸš‹πŸš‹
Fun functional programming
A 5kb framework for creating sturdy frontend applications


The little framework that could. Built with ❀︎ by Yoshua Wuyts and contributors

Table of Contents

Table of Contents
  • Features
  • Demos
  • Example
  • Philosophy
  • Concepts
  • Badges
  • API
  • FAQ
  • Installation
  • See Also
  • Support
  • 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 and subscriptions 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

    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.

    var html = require('choo/html')
    var choo = require('choo')
    var app = choo()
    
    app.model({
      state: { title: 'Not quite set yet' },
      reducers: {
        update: function (state, data) {
          return { title: data }
        }
      }
    })
    
    function mainView (state, prev, send) {
      return html`
        <main>
          <h1>Title: ${state.title}</h1>
          <input type="text" oninput=${update}>
        </main>
      `
    
      function update (e) {
        send('update', e.target.value)
      }
    }
    
    app.router(['/', mainView])
    
    var tree = app.start()
    document.body.appendChild(tree)

    To run it, save it as client.js and run with bankai. bankai is convenient but any browserify based tool should do:

    # run and reload on port 8080
    $ bankai client.js -p 8080 --open
    
    # compile to static files in `./dist/`
    $ bankai build index.js dist/
    
    # deploy to github pages using `tschaub/gh-pages`
    $ gh-pages -d dist/

    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. Roughly speaking there are two parts to choo: the views and the models. Models take care of state and logic, and views are responsible for displaying the interface and responding to user interactions.

    All of choo's state is contained in a single object and whenever it changes the views receive a new version of the state which they can use to safely render a complete new representation of the DOM. The DOM is efficiently updated using DOM diffing/patching.

    The logic in choo exist in three different kinds of actions, each with their own role: effects, subscriptions and reducers.

    • Effects makes an asynchronous operation and calls another action when it's done.

    • Subscriptions (called once when the DOM loads) listens for external input like keyboard or WebSocket events and then calls another action.

    • Reducers receives the current state and returns an updated version of the state which is then sent to the views.

     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚  Subscriptions ──     User ───┐
     └─ Effects  ◀──────             β–Ό
     β”Œβ”€ Reducers ◀─────┴─────────── DOM ◀┐
     β”‚                                    β”‚
     β””β–Ά 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 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:

    var app = choo()
    app.model({
      namespace: 'todos',
      state: { items: [] },
      reducers: {
        add: function (state, data) {
          return { 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 be safely shielded, with only a few points touching every part of your application.

    Effects

    effects are similar to reducers except instead of modifying the state they cause side effects by interacting servers, databases, DOM APIs, etc. Often they'll call a reducer when they're done to update the state. For instance, you may have an effect called getUsers that fetches a list of users from a server API using AJAX. Assuming the AJAX request completes successfully, the effect can pass off the list of users to a reducer called receiveUsers which simply updates the state with that list, separating the concerns of interacting with an API from updating the application's state.

    This is an example effect that is called once when the application loads and calls the 'todos:add' reducer when it receives data from the server:

    var choo = require('choo')
    var http = require('xhr')
    var app = choo()
    
    app.model({
      namespace: 'todos',
      state: { values: [] },
      reducers: {
        add: function (state, data) {
          return { todos: data }
        }
      },
      effects: {
        addAndSave: function (state, data, send, done) {
          var opts = { body: data.payload, json: true }
          http.post('/todo', opts, function (err, res, body) {
            if (err) return done(err)
            data.payload.id = body.id
            send('todos:add', data, function (err, value) {
              if (err) return done(err)
              done(null, value)
            })
          })
        }
      },
      subscriptions: {
        'called-once-when-the-app-loads': function (send, done) {
          send('todos:addAndSave', done)
        }
      }
    })

    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:

    var choo = require('choo')
    
    var app = choo()
    app.model({
      namespace: 'app',
      effects: {
        print: function (state, data) {
          console.log(data.payload)
        }
      },
      subscriptions: {
        callDog: function (send, done) {
          setInterval(function () {
            var data = { payload: 'dog?', myOtherValue: 1000 }
            send('app:print', data, function (err) {
              if (err) return done(err)
            })
          }, 1000)
        }
      }
    })

    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.

    var app = choo()
    app.router({ default: '/404' }, [
      [ '/', require('./views/empty') ],
      [ '/404', require('./views/error') ],
      [ '/:mailbox', require('./views/mailbox'), [
        [ '/: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.location. To access the route :params you can use state.location.params. If you want to modify the location programmatically the reducer for the location can be called using send('location:set', 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.

    function view (state, prev, send) {
      return html`
        <div>
          <h1>Total todos: ${state.todos.length}</h1>
          <button onclick=${addTodo}>Add</button>
        </div>
      `
    
      function addTodo (e) {
        send('add', { title: 'demo' })
      }
    }

    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.

    Plugins

    Sometimes it's necessary to change the way choo itself works. For example to report whenever an action is triggered, handle errors globally or perist state somewhere. This is done through something called plugins. Plugins are objects that contain hook and wrap functions and are passed to app.use():

    var log = require('choo-log')
    var choo = require('choo')
    var app = choo()
    
    app.use(log())
    
    var tree = app.start()
    document.body.appendChild(tree)

    Generally people using choo shouldn't be too worried about the specifics of plugins, as the internal API is (unfortunately by necessity) quite complex. After all they're the most powerful way to modify a choo application.

    ⚠️ Warning ⚠️: plugins should only be used as a last resort. It creates peer dependencies which makes upgrading versions and switching frameworks a lot harder. Please exhaust all other options before using plugins.

    If you want to learn more about creating your own plugins, and which hooks and wrappers are available, head on over to app.use().

    Badges

    Using choo in a project? Show off which version you've used using a badge:

    built with choo v4

    [![built with choo v4](https://img.shields.io/badge/built%20with%20choo-v4-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 that is passed to app.use().

    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 (state, data).
    • effects: asynchronous operations that don't modify state directly. Triggered by actions, can call actions. Signature of (state, data, 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.location.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.

    app.use(hooks)

    Register an object of hooks on the application. This is useful to extend the way choo works, adding custom behavior and listeners. Generally returning objects of hooks is done by returning them from functions (which we call plugins throughout the documentation).

    There are several hooks and wrappers that are picked up by choo:

    • onError(err, state, createSend): called when an effect or subscription emit an error. If no handler is passed, the default handler will throw on each error.
    • onAction(state, data, name, caller, createSend): called when an action is fired.
    • onStateChange(state, data, prev, caller, createSend): called after a reducer changes the state.
    • wrapSubscriptions(fn): wraps a subscription to add custom behavior
    • wrapReducers(fn): wraps a reducer to add custom behavior
    • wrapEffects(fn): wraps an effect to add custom behavior
    • wrapInitialState(fn): mutate the complete initial state to add custom behavior - useful to mutate the state before starting up

    ⚠️ Warning ⚠️: plugins should only be used as a last resort. It creates peer dependencies which makes upgrading versions and switching frameworks a lot harder. Please exhaust all other options before using plugins.

    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.

    Hooks 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 hooks.

    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(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 a subscription to the browser history API. e.g. updates the internal location.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 internal state.location accordingly.
    • opts.hash: default: false. Enable a subscription to the hash change event, updating the internal state.location state whenever the URL hash changes (eg localhost/#posts/123). Enabling this option automatically disables opts.history and opts.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.location.params is URI partials and send() can be called to trigger actions.

    To create listeners for events, create interpolated attributes on elements.

    var html = require('choo/html')
    html`
      <button onclick=${log}>click for bananas</button>
    `
    
    function log (e) {
      console.log(e)
    }

    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() and e.stopPropagation() 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. πŸš‚πŸš‹πŸš‹πŸš‹

    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 of JSX and babel 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 view react 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 dependencies react seems to use (e.g. react-router and friends) and doesn't help at all with architecture. If react is your jam, and you will not budge, sitting at 3kb 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 and RxJS definitely hasn't made things simpler. Last I checked it was ~200kb in size before including some monstrous extra deps. I guess angular 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 using mercury helped shaped choo 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 of react". The dependence on JSX 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 on virtual-dom and xstream 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, arrow functions and template strings to older browsers. Should be run as a --global transform
    • yo-yoify - replace the internal hyperx dependency with document.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
    • 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

    Sauce Test Status

    Installation

    $ npm install choo

    See Also

    • choo-handbook - the little choo guide
    • awesome-choo - Awesome things related with choo framework
    • kaktus - A new minimalistic web browser
    • 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

    Support

    Creating a quality framework takes a lot of time. Unlike others frameworks, Choo is completely independently funded. We fight for our users. This does mean however that we also have to spend time working contracts to pay the bills. This is where you can help: by chipping in you can ensure more time is spent improving Choo rather than dealing with distractions.

    Sponsors

    Become a sponsor and help ensure the development of independent quality software. You can help us keep the lights on, bellies full and work days sharp and focused on improving the state of the web. Become a sponsor

    Backers

    Become a backer, and buy us a coffee (or perhaps lunch?) every month or so. Become a backer

    License

    MIT