Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/api/stores.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ var Planets = {
}
```

### `setup()`

Setup runs right after a store is added to a Microcosm, but before it runs
getInitialState. This is useful for one-time setup instructions.

### `serialize(state)`

Allows a store to transform data before it leaves the system. It gives
Expand Down Expand Up @@ -127,3 +132,44 @@ const Planets = {

repo.push(Actions.add, { name: 'earth' }) // this will add Earth
```

### `commit(next)`

How should a store actually write to `repo.state`? This is useful for serializing a complex data structure, such as a `Map`, into form easier for public consumption:

```javascript
import Immutable from 'immutable'

const Planets = {
getInitialState() {
return Immutable.Map()
},

commit(next) {
return Array.from(next.values())
}
}
```

### `shouldCommit(next, last)`

Based on the next and last state, should `commit` be called? Useful for
custom change management behavior.

```javascript
import Immutable from 'immutable'

const Planets = {
getInitialState() {
return Immutable.Map()
},

shouldCommit(next, last) {
return Immutable.is(next, last)
}

commit(next) {
return Array.from(next.values())
}
}
```
95 changes: 95 additions & 0 deletions docs/recipes/immutable-js.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# ImmutableJS Integration

1. [Immutable Everywhere](#immutable-everywhere)
2. [Immutable in, Vanilla out](#immutable-in-vanilla-out)

## Immutable Everywhere

The most basic integration method is to simply use ImmutableJS:

```javascript
import Immutable from 'immutable'
import actions from 'actions'

const Store = {
getInitialState() {
return Immutable.Map()
},

add(state, record) {
return state.set(record.id, record)
},

remove(state, id) {
return state.remove(id)
},

register() {
return {
[actions.create]: this.add,
[actions.destroy]: this.remove
}
}
}
```

## Immutable in, Vanilla out

We've found it can be much simpler to expose vanilla JavaScript data to our
presentation layer. Unfortunately, this tends to mitigates much of the benefit
of immutable data. It also can impose penalties serializing between the two formats.

It is worth walking through the phases of state a Microcosm works through in order
to better understand how Microcosm accommodates this use case:

1. **archive** - For performance, Microcosm purges old actions and writes their
final result to a cache.
2. **staging** - State before a making change. This is a preparatory state allowing
stores the ability to transform data one last time before assigning it publicly.
3. **state** - Publicaly available state. This is what is exposed via `repo.state`,

Essentially, Microcosm can maintain ImmutableJS data structures internally, exposing
plain JavaScript for public consumption. There are two key methods responsible for this:

1. **commit** - A middleware function that dictates how a Store assigns to `repo.state`.
2. **shouldCommit** - A predicate function that controls invocation of `commit`.

In practice, this results in a small adjustment to the store described earlier:

```javascript
import Immutable from 'immutable'
import actions from 'actions'

const Store = {
getInitialState() {
return Immutable.Map()
},

shouldCommit(next, previous) {
return Immutable.is(next, previous) === false
},

commit(state) {
return Array.from(state.values())
},

add(state, record) {
return state.set(record.id, record)
},

remove(state, id) {
return state.remove(id)
},

register() {
return {
[actions.create]: this.add,
[actions.destroy]: this.remove
}
}
}
```

Here we've added a `shouldCommit` that utilizes the `Immutable.is` equality check.
Additionally, `commit` describes how `Immutable` should convert into a regular form.
In this case, it will convert into an Array.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"eslint-plugin-react": "6.2.0",
"express": "4.14.0",
"happypack": "2.2.1",
"immutable": "3.8.1",
"jsdom": "9.5.0",
"json-loader": "0.5.4",
"microcosm-debugger": "vigetlabs/microcosm-debugger",
Expand Down
2 changes: 1 addition & 1 deletion src/getStoreHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ function format (string) {
function getHandler (key, store, type) {
let handler = store[type]

if (handler === undefined && store.register) {
if (handler === undefined) {
const registrations = store.register()

if (process.env.NODE_ENV !== 'production') {
Expand Down
83 changes: 62 additions & 21 deletions src/microcosm.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import Emitter from './emitter'
import MetaStore from './stores/meta'
import Tree from './tree'
import Store from './store'
import lifecycle from './lifecycle'
import getStoreHandlers from './getStoreHandlers'
import merge from './merge'
import shallowEqual from './shallow-equal'
import update from './update'

/**
Expand All @@ -19,22 +21,34 @@ export default class Microcosm extends Emitter {
/**
* @param {{maxHistory: Number}} options - Instantiation options.
*/
constructor ({ maxHistory = -Infinity } = {}) {
constructor ({ maxHistory = -Infinity, pure = false } = {}) {
super()

this.history = new Tree()
this.pure = pure
this.maxHistory = maxHistory
this.stores = []

// cache store registry methods for efficiency
this.registry = {}

// cache represents the result of dispatching all complete
// actions.
this.cache = {}

// state represents the result of dispatching all outstanding
// actions over cache
/**
* State is captured in three phases. Each stage solves a different problem:
*
* 1. archive A cache of completed actions. Since actions will never change,
* writing them to an archive allows the actions to be disposed,
* improving dispatch efficiency.
* 2. staged The "private" state, before writing for public consumption.
* Store may not operate on primitive (like ImmutableJS), this
* allows a store to work with complex data types while still
* exposing a primitive public state
* 3. state Public state. The `willCommit` lifecycle method allows a
* store to transform private state before it changes. This
* is useful for turning something like Immutable.Map() or
* a linked-list into a primitive object or array.
*/
this.archive = {}
this.staged = {}
this.state = {}

// Standard store reduction behaviors
Expand Down Expand Up @@ -64,20 +78,20 @@ export default class Microcosm extends Emitter {
}

/**
* Microcosm maintains a cache of "merged" actions. When an action completes,
* Microcosm maintains an archive of "merged" actions. When an action completes,
* it checks the maxHistory property to decide if it should prune the action
* and dispatch it into the cache.
* and dispatch it into the archive.
*
* @private
* @param {Action} action target action to "clean"
* @param {Number} size the depth of the tree
* @return {Boolean} Was the action merged into the state cache?
* @return {Boolean} Was the action merged into the archive?
*/
clean (action, size) {
const shouldMerge = size > this.maxHistory && action.is('disposable')

if (shouldMerge) {
this.cache = this.dispatch(this.cache, action)
this.archive = this.dispatch(this.archive, action)
}

return shouldMerge
Expand Down Expand Up @@ -105,7 +119,10 @@ export default class Microcosm extends Emitter {
for (var i = 0, len = handlers.length; i < len; i++) {
const { key, store, handler } = handlers[i]

state = update.set(state, key, handler.call(store, update.get(state, key), payload))
const last = update.get(state, key)
const next = handler.call(store, last, payload)

state = update.set(state, key, store.stage(last, next))
}

return state
Expand All @@ -121,13 +138,30 @@ export default class Microcosm extends Emitter {
rollforward () {
this.history.prune(this.clean, this)

this.state = this.history.reduce(this.dispatch, this.cache, this)
const staged = this.history.reduce(this.dispatch, this.archive, this)

const next = this.stores.reduce((memo, store) => {
return this.commit(memo, store[0], store[1])
}, merge({}, staged))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this. This is inefficiency (though not much). I don't like shallow copying the staged changes, and I don't like the anonymous function call. I also don't like the [key, value] tuple.

Something to muse on...


this._emit('change', this.state)
this.staged = staged

if (!this.pure || shallowEqual(this.state, next) === false) {
this.state = next
this._emit('change', next)
}

return this
}

commit(staged, key, store) {
const last = update.get(this.staged, key)
const next = update.get(staged, key)
const value = store.shouldCommit(next, last) ? store.commit(next) : update.get(this.state, key)

return update.set(staged, key, value)
}

/**
* Append an action to a microcosm's history. In a production
* repo, this is typically reserved for testing. `append`
Expand Down Expand Up @@ -185,11 +219,18 @@ export default class Microcosm extends Emitter {
key = null
}

let store = null

if (typeof config === 'function') {
config = { register: config }
store = new config()
} else {
store = merge(new Store(), config)
}

this.stores = this.stores.concat([[ key, config ]])
this.stores = this.stores.concat([[ key, store ]])

// Setup is called once, whenever to store is added to the microcosm
store.setup()

this.rebase()

Expand Down Expand Up @@ -253,18 +294,18 @@ export default class Microcosm extends Emitter {
}

/**
* Recalculate initial state by back-filling the cache object with
* the result of getInitialState(). This is used when a store is
* added to Microcosm to ensure the initial state of the store is
* respected. Emits a "change" event.
* Recalculate initial state by back-filling the archive with the
* result of getInitialState(). This is used when a store is added
* to Microcosm to ensure the initial state of the store is respected
* Emits a "change" event.
*
* @private
* @return {Microcosm} self
*/
rebase () {
this.registry = {}

this.cache = merge(this.getInitialState(), this.cache)
this.archive = merge(this.getInitialState(), this.archive)

this.rollforward()

Expand Down
Loading