Introducing Microcosm: Our Data Layer For React

Nate Hunzaker, Former Development Director

Article Categories: #Code, #News & Culture, #Front-end Engineering

Posted on

Microcosm is our general tool for managing state, splitting up large apps, and structuring our React code.

One of my favorite things about working in client-services is the interval with which we start new work. As a React shop, this means we build a lot of new apps from the ground up.

Along the way, we've distilled what we've learned and baked it into a tool that I, finally, want to talk about.

Microcosm is our general purpose tool for keeping React apps organized. We use it to work with application state, split large projects into manageable chunks, and as the guiding star for our application architecture.

In this post, I'll provide a high level overview of Microcosm and some of the features I find particularly valuable. Don't forget to check out the project on Github!

At a glance #

Microcosm was born out of the Flux mindset. From there it draws similar pieces:

Actions #

Actions are a general abstraction for performing a job. In Microcosm, actions move through a standard lifecycle: (open, update, resolve, reject, cancel).

Actions can process a variety of data types out of the box. For example, a basic networking request might look like:

import request from 'superagent'

function getUser(id) {
  // This will return a promise. Microcosm automatically handles promises.
  return request(`/users/${id}`)
}

let repo = new Microcosm()
let action = repo.push(getUser, '2')

action.onDone(function (user) {
  console.log("Hurrah!")
})

action.onError(function (reason) {
  console.log("Darn!", reason)
})

However they can also expose fine grained control over their lifecycle:

import Microcosm from 'microcosm'
import request from 'superagent'

function getUser(id) {
  return function(action) {
    let xhr = request(`/users/${id}`)

    // The request has started
    action.open(id)

    // Show download progress
    xhr.on('progress', action.update)

    // Make the request cancellable
    action.onCancel(xhr.abort)

    // Normal pass/fail behavior
    xhr.then(action.resolve, action.reject)
  }
}

let repo = new Microcosm()

let action = repo.push(getUser, 2)

action.onUpdate(event => console.log(event.percent)) // 0 ... 10... 20... 70...

// Wait, I no longer care about this!
action.cancel()

Domains #

Domains define the rules in which actions are converted into new state. Conceptually they are sort of like stores in Flux, or reducers in Redux. They register to specific actions, performing some transformation over data:

const Users = {
  getInitialState() {
    return []
  },
  addUser(users, record) {
    return users.concat(record)
  },
  register() {
    return {
      [getUser]: this.addUser
    }
  }
}

repo.addDomain('users', Users)

Basically: mount a data processor at repo.state.users that appends a user to a list whenever getUser finishes.

Effects #

Effects provide an outlet for side-effects after domains have updated state. We use them for flash notifications, persistence in local storage, and other behavior that doesn't relate to managing state:

const Notifier = {
  warn(repo, error) {
    alert(error.message)
  },
  register() {
    return {
      [getUser]: {
        error: this.warn
      }
    }
  }
}

repo.addEffect(Notifier)

New here: Domains and Effects can subscribe to specific action states. The effect above will listen for when getUser fails, alerting the user that something went wrong.

Altogether, this looks something like:

import Microcosm from 'microcosm'
import request from 'superagent'

let repo = new Microcosm()

function getUser(id) {
  return request(`/users/${id}`)
}

repo.addDomain('users', {
  getInitialState() {
    return []
  },
  addUser(users, record) {
    return users.concat(record)
  },
  register() {
    return {
      [getUser]: this.addUser
    }
  }
})

// Listen to failures. What happens if the AJAX request fails?
repo.addEffect({
  warn(repo, error) {
    alert(error.message)
  },
  register() {
    return {
      [getUser]: {
        error: this.warn
      }
    }
  }
})

// Push an action, a request to perform some kind of work
let action = repo.push(getUser, 2)

action.onDone(function() {
  console.log(repo.state.users) // [{ id: 2, name: "Bob" }]
})

// You could also handle errors in a domain's register method
// by hooking into `getUser.error`
action.onError(function() {
  alert('Something went terribly wrong!')
})

It's 2017, why aren't you using Redux? #

We do! As a client services company, we use whatever tool best serves our clients. In some cases, that means using Redux, particularly if it's a client preference or the existing framework for a project.

However there are a few features of Microcosm that we think are compelling:

Action State #

We've found that, when actions are treated as static events, the state around the work performed is often discarded. Networking requests are a story, not an outcome.

What if a user leaves a page before a request finishes? Or they get tired of a huge file uploading too slowly? What if they dip into a subway tunnel and lose connectivity? They might want to retry a request, cancel it, or just see what’s happening.

Microcosm makes this easier by providing a standard interface for interacting with outstanding work. For example, let's say we want to stop asking for data if a user no longer cares about the related presentation:

import React from 'react'
import { getPlanets } from '../actions/planets'

class PlanetsList extends React.Component {
  componentWillMount() {
    // We could avoid needing to pass down a "repo" prop by
    // using some options shown later
    const { repo } = this.props

    this.action = repo.push(getPlanets)
  }
  componentWillUnmount() {
    this.action.cancel()
  }
  render() {
    //... render some planets
  }
}

Assuming we give this component a Microcosm "repo" prop, and a list of planets, this component will fetch planets data, stopping whenever the component unmounts. We don't need to care if the request is represented by a Promise, Observable, error-first callback, etc.

Reducing boilerplate #

Since actions move through consistent states, we can leverage these constraints to build boilerplate reducing React components for common problems. For example, we frequently need to dispatch an action to perform some task, so Microcosm ships with an <ActionButton /> component:

import React from 'react'
import ActionButton from 'microcosm/addons/action-button'
import { deleteUser } from '../actions/user'

class DeleteUserButton extends React.Component {
  render() {
    const { userId } = this.props

    return (
      <ActionButton action={deleteUser} value={userId}>
        Delete User
      </ActionButton>
    )
  }
}

Because the lifecycle is predictable, we can expose hooks to make further improvements around that lifecycle:

import React from 'react'
import ActionButton from 'microcosm/addons/action-button'
import { deleteUser } from '../actions/user'

class DeleteUserButton extends React.Component {
  state = {
    loading: false
  }

  setLoading = () => {
    this.setState({ loading: true })
  }

  handleError = reason => {
    alert(reason)
    this.setState({ loading: false })
  }

  render() {
    const { userId } = this.props
    const { loading } = this.state

    return (
      <ActionButton action={deleteUser} value={userId} disabled={loading} onOpen={this.setLoading} onError={this.handleError}>
        Delete User
      </ActionButton>
    )
  }
}

This makes one-time, use-case specific display requirements, like error reporting, or tracking file upload progress easy. In a lot of cases, the data layer doesn't need to get involved whatsoever. This makes state management simpler - it doesn't need to account for all of the specific user experience requirements within an interface.

Optimistic updates - Taking a historical approach #

Actions are placed within a history of all outstanding work. This is maintained by a tree:

Microcosm Debugger

Taken from the Chatbot example.

Microcosm will never clean up an action that precedes incomplete work. When an action moves from open to done, or cancelled, the historical account of actions rolls back to the last state, rolling forward with the new action states. This makes optimistic updates simpler because action states are self cleaning; interstitial states are reverted automatically:

import { send } from 'actions/chat'

const Messages = {
  getInitialState() {
    return []
  },

  setPending(messages, item) {
    return messages.concat({ ...item, pending: true })
  },

  setError(messages, item) {
    return messages.concat({ ...item, error: true })
  },

  addMessage(messages, item) {
    return messages.concat(item)
  },

  register() {
    return {
      [send]: {
        open: this.setPending,
        error: this.setError,
        done: this.addMessage
      }
    }
  }
}

In this example, as chat messages are sent, we optimistically update state with the pending message. At this point, the action is in an open state. The request has not finished.

On completion, when the action moves into error or done, Microcosm recalculates state starting from the point prior to the open state update. The message stops being in a loading state because, as far as Microcosm is now concerned, the open status never occurred.

Separating responsibility with Presenters #

The Presenter addon is a special React component that can build a view model around a given Microcosm state, sending it to child "passive view" components.

When a Presenter is instantiated, it creates a fork of a Microcosm instance. A fork is a "downstream" Microcosm that gets the same state updates as the original but can add additional Domains and Effects without impacting the "upstream" Microcosm.

This sandbox allows you to break up complicated apps into smaller sections. Share state that you need everywhere, but keep context specific state isolated to a section of your application:

class EmailPreferences extends Presenter {
  setup(repo, props) {
    repo.add('settings', UserSettings)

    repo.push(getUserSettings, props.user.id)
  }

  getModel(props) {
    return {
      settings: state => state.settings
    }
  }

  render() {
    const { settings } = this.model

    return (
      <aside>
        {/* Email preferences UI omitted for brevity */}
      </aside>
    )
  }
}

In this example, we can keep a users email preferences local to this component. We could even lazy load this entire feature, state management included, using a library like react-loadable. For large applications, we've found this is essential for keeping build sizes down.

David wrote a fantastic article that goes into further detail on this subject.

What's next #

At Viget, we're excited about the future of Microcosm, and have a few areas we want to focus on in the next few months:

  • Developer tools. First class developer tools have become the baseline for JavaScript frameworks. Since Microcosm "knows" more about the state of actions, presenters, and other pieces, we're excited about opportunities to build fantastic tooling.
  • Support for Preact, Glimmer, Vue, and other frameworks. We'd love to stop calling our apps "React apps". What would it look like for the presentation layer to take on less responsibility?
  • Observables. The similarities between Actions and Observables is striking. We're curious about how we can use Observables more under the hood to provide greater interoperability with other tools.

So check it out! We're always willing to accept feedback and would love to hear about how you build apps.

Related Articles