![Microcosm](http://code.viget.com/microcosm/assets/microcosm.svg)
![npm](https://img.shields.io/npm/dm/microcosm.svg?maxAge=2592000)
Microcosm is a state management tool for React (and similar libraries). Keep track of user actions, cancel requests, and perform optimistic updates with ease.
At a glance
import Microcosm, { get, set } from 'microcosm'
let repo = new Microcosm()
function getUser (id) {
return fetch(`/users/#{id}`).then(response => response.json())
}
repo.addDomain('counter', {
getInitialState () {
return {}
},
addUser (users, record) {
return set(users, record.id, record)
},
register () {
return {
[getUser.done]: this.addUser
}
}
})
let action = repo.push(getUser, 2)
action.onDone(function () {
let user = get(repo.state, ['users', '2'])
console.log(user)
})
action.onError(function () {
alert("Something went terribly wrong!")
})
Why?
Other Flux implementations treat actions as static events; the result of calling a dispatch method or resolving some sort of data structure like a Promise.
But what if a user gets tired of waiting for a file to upload, or switches pages before a GET request finishes? 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.
The burden of this state often falls on data stores (Domains, in Microcosm) or a home-grown solution for tracking outstanding requests and binding them to related action data. Presentation layer requirements leak into the data layer, making it harder to write tests, reuse code, and accommodate unexpected changes.
How Microcosm is different
Microcosm actions are first-class citizens. An action can move from an open
to error
state if a request fails. Requests that are aborted may move into a cancelled
state. As they change, actions resolve within a greater history of every other action.
This means that applications can make a lot of assumptions about user actions:
- Actions resolve in a consistent, predictable order
- Action types are automatically generated
- Actions maintain the same public API, no matter what asynchronous pattern is utilized (or not)
This reduces a lot of boilerplate, however it also makes it easier for the presentation layer to handle use-case specific display requirements, like displaying an error, performing an optimistic update, or tracking file upload progress.
Get started
npm install --save microcosm
Check out our quickstart guide.
Documentation
Comprehensive documentation can be found in the docs section of this repo.
Overview
Microcosm is an evolution of Flux
that makes it easy to manage complicated async workflows and unique
data modeling requirements of complicated UIs.
Actions take center stage
Microcosm organizes itself around a history of user actions. As those actions move through a set lifecycle,
Microcosm reconciles them in the order they were created.
Invoking push()
appends to that history, and returns an Action
object to represent it:
function getPlanet (id) {
return fetch('/planets/' + id).then(response => response.json())
}
let action = repo.push(getPlanet, 'venus')
action.onDone(function (planet) {
console.log(planet.id)
})
Domains: Stateless Stores
A Domain is a collection of side-effect free operations for manipulating data. As actions update, Microcosm
uses domains to determine how state should change. Old state comes in, new state comes out:
const PlanetsDomain = {
getInitialState () {
return []
},
addPlanet (planets, record) {
return planets.concat(record)
},
register() {
return {
[getPlanet]: this.addPlanet
}
}
}
repo.addDomain('planets', PlanetsDomain)
By implementing a register method, domains can subscribe to actions. Each action
is assigned a unique string identifier. Action type constants are generated automatically.
Pending, failed, and cancelled requests
Microcosm makes it easy to handle pending, loading, cancelled,
completed, and failed requests:
const PlanetsDomain = {
register() {
return {
[getPlanet.open] : this.setPending,
[getPlanet.done] : this.addPlanet,
[getPlanet.error] : this.setError,
[getPlanet.loading] : this.setProgress,
[getPlanet.cancelled] : this.setCancelled
}
}
}
open
, loading
, done
, error
and cancelled
are action
states. In our action creator, we can unlock a deeper level of control
by returning a function:
import request from 'superagent'
function getPlanet (id) {
return function (action) {
action.open(id)
let request = request('/planets/' + id)
request.end(function (error, response) {
if (error) {
action.reject(error)
} else {
action.resolve(response.body)
}
})
action.onCancel(request.abort)
}
}
First, the action becomes open
. This state is useful when waiting
for something to happen, such as loading. When the request finishes,
if it fails, we reject the action, otherwise we resolve it.
Microcosm actions are cancellable. Invoking action.cancel()
triggers a
cancellation event:
let action = repo.push(getPlanet, 'Pluto')
action.cancel()
When action.cancel()
is called, the action will move into a
cancelled
state. If a domain doesn't handle a given state no data
operation will occur.
Visit the API documentation for actions to
read more.
A historical account of everything that has happened
Whenever an action creator is pushed into a Microcosm, it creates an
action to represent it. This gets placed into a tree of all actions
that have occurred.
For performance, completed actions are archived and purged from
memory, however passing the maxHistory
option into Microcosm allows
for a compelling debugging story, For example, the time-travelling
Microcosm debugger:
let forever = new Microcosm({ maxHistory: Infinity })
Taken from the Chatbot example.
Optimistic updates
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:
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 {
[action.open] : this.setPending,
[action.error] : this.setError,
[action.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, it never occured.
Forks: Global state, local concerns
Global state management reduces the complexity of change propagation
tremendously. However it can make application features such as
pagination, sorting, and filtering cumbersome.
How do we maintain the current page we are on while keeping in sync
with the total pool of known records?
To accommodate this use case, there is Microcosm::fork
:
const UsersDomain = {
getInitialState() {
return []
},
addUsers(users, next) {
return users.concat(next)
},
register() {
return {
[getUsers]: this.addUsers
}
}
})
const PaginatedUsersDomain {
getInitialState() {
return []
},
addUsers(users, next) {
let page = next.map(user => user.id)
return users.filter(user => page.contains(user.id))
},
register() {
return {
[getUsers]: this.addUsers
}
}
})
let roster = new Microcosm()
let pagination = parent.fork()
roster.addDomain('users', UsersDomain)
pagination.addDomain('users', PaginatedUsersDomain)
roster.push(getUsers, { page: 1 })
roster.push(getUsers, { page: 2 })
console.log(roster.state.users.length)
console.log(pagination.state.users.length)
fork
returns a new Microcosm, however it shares the same action
history. Additionally, it inherits state updates from its
parent. In this example, we've added special version of the roster
repo that only keeps track of the current page.
As getUsers()
is called, the roster
will add the new users to the
total pool of records. Forks dispatch sequentially, so the child
pagination
repo is able to filter the data set down to only what it
needs.
Networks of Microcosms with Presenters
Fork is an important component of
the Presenter
addon. Presenter is a
special React component that can build a view model around a given
Microcosm state, sending it to child "passive view" components.
All Microcosms sent into a Presenter are forked, granting them a sandbox
for data operations specific to a particular part of an application:
class PaginatedUsers extends Presenter {
setup (repo, { page }) {
repo.add('users', PaginatedUsersDomain)
repo.push(getUsers, page)
}
getModel () {
return {
page: state => state.users
}
}
render () {
const { page } = this.model
return <UsersTable users={page} />
}
}
const repo = new Microcosm()
repo.addDomain('users', UsersDomain)
ReactDOM.render(<PaginatedUsers repo={repo} page="1" />, el)
Inspiration
Visit code.viget.com to see more projects from Viget.
12.0.0
merge
helper skips over nully values. For example, merge(null, {})
will
start with the second argument- Renamed
Presenter::model
to Presenter::getModel
. - Renamed
Presenter::register
to Presenter::intercept
- Added
Presenter::ready
, which fires after ::setup
- Added a
model
property to Presenters. This behaves similarly to props
or
state
, and is available after setup
executes Presenter::render
is now the primary rendering method for PresentersPresenter::view
always gets called with React.createElement
- Removed deprecated
Action::send
- Added nested action registrations in domains. See the Domains component of
the upgrading section later.
Microcosm:toJSON
only serializes domains that implement ::serialize
Microcosm::reset
only operate on keys managed by the specific Microcosm.
reset
effects the entire tree of forks.Microcosm::patch
only operate on keys managed by the specific Microcosm.
patch
effects the entire tree of forks.- Removed
Domain::commit
, which consistently added needless complexity to our
applications. - All instances of
intent
have been replaced with action
. They are the
exact same thing under the hood, and it is a common source of confusion. - Renamed
IntentButton
to ActionButton
. Import from microcosm/addons/action-button
- Renamed
Form
to ActionForm
Import from microcosm/addons/action-form
- Renamed
withIntent
to withSend
. Import from microcosm/addons/with-send
- Added
update
data utility, which calls set
on the result of a function that
is passed the result of get
.
Upgrading
Microcosm
deserialize
, serialize
, reset
, and patch
only operate on keys managed
by a particular Microcosm. Verify that, where you are using these methods, your
application is not relying on them to inject arbitrary application state.
These methods now return the merged result of calling all the way up the
hierarchy of Microcosm forks. In practice, this means that Microcosms only have
to deal with the keys for domains they were assigned, which is more in line with
the behavior we expect from forks.
Actions
With the exception of removing send
, which was replaced with update
,
actions have not changed. If you have removed all deprecated action.send
calls after upgrading to 11.6.0, there should be no further change required.
Domains
No more commit
Domains no longer support commit()
, and subsequently shouldCommit()
. We
found, while useful for serializing libraries such as ImmutableJS, that it our
usage of commit
turned into a convenience method for always writing state in a
specific way. This created an awkwardness with serializing data, and could be
a source of performance problems as they continually write new object references
from things like filter
or slice
.
So we removed it. We recommend moving this sort of behavior to getModel
in
the Presenter add-on.
Nested action registrations
Domains may now nest action statuses as an object:
class Domain {
register() {
return {
[action]: {
open: this.setLoading,
error: this.setError,
done: this.setDone
}
}
}
}
Presenters
getModel
is the new model
We frequently found ourselves wanting to access the latest model inside of our
presenter. What if we wanted to fetch extra data from records pulled out of a
model, or render differently if the record was missing?
Presenters now have a model
property, which can be accessed after setup
has
completed:
class MyPresenter extends Presenter {
getModel() {
return { count: state => state.count }
}
render() {
return (
<ActionButton action={step} value={1}>
{this.model.count}
</ActionButton>
)
}
}
ready
setup
can not have access to this.model
because repo specific setup
behavior might cause the model to be recalculated excessively. So we've added a
ready
method. Both ready
and update
have access to the last calculated
model, which makes them ideal for performing some work based on it:
class MyPresenter extends Presenter {
getModel (props) {
return {
user: data => data.users.find(u => u.id === props.id)
}
}
ready (repo, props)
if (this.model.user == null) {
repo.push(this.fetchUser, props.id)
}
}
}
You can still do this sort of fetching inside of setup
, there just won't be a
model to access. Not much of a change from 11.6.0
, where this.model
was not
available.
render
is the new view
We (Nate) got this wrong. By not using render, too much distance was created
between the underlying React Component behavior and the "special treatment"
received by view
.
render
now works just like React.Component::render
, as it should be. Still,
we haven't gotten rid of view
, which is useful in a couple of places, like as
a getter to switch over some piece of model state:
class MyPresenter extends Presenter {
getModel(props) {
return {
user: data => data.users.find(u => u.id === props.id)
}
}
get view() {
return this.model.user ? MyUserView : My404View
}
}
view
is always invoked with React.createElement
. render
is always called
in the context of the Presenter. It is a plain-old React render method (for
great justice).
intercept
is the new register
Presenter::register
was a confusing name for the what it did.
Presenter::register
allows you to catch messages sent from child view
components. Catch is a reserved word, so we've renamed it intercept
.
In the future, Presenter::register
might behave more like an Effect, which is
what several users have mistaken it for.