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
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ start. Beyond that, check out [the example apps](../examples).
1. [Microcosm](api/microcosm.md)
2. [Domains](api/domains.md)
3. [Actions](api/actions.md)
4. [Effects](api/effects.md)

## Addons

Expand Down
79 changes: 79 additions & 0 deletions docs/api/effects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Effects

1. [Overview](#overview)
2. [Subscribing to different action states](#subscribing-to-different-action-states)
3. [API](#api)

## Overview

Effects are one-time handlers that are invoked once, after the action
moves into a different state. They are very similar to Domains,
however their purposes is to handle side-effects. Domains are not the
appropriate place to handle this sort of behavior, as they may
dispatch the same handler several times during the reconciliation of
asynchronous actions.

```javascript
// If an effect implements a setup method, it will receive options as
// the second argument
repo.addEffect(Effect, options)
```

## Subscribing to different action states

Just like Domains, effects can provide a `register` method to dictate
what actions they listen to:

```javascript
const Effect = {

handler (repo, payload) {
// side-effect here
},

register() {
return {
[action] : this.handler
}
}
}
```

`action` referenced directly, like `[action]: callback`, refer to the
`done` state.

## API

### `setup(repo, options)`

Setup runs right after an effect is added to a Microcosm. It receives
that repo and any options passed as the second argument.

### `teardown(repo)`

Runs whenever `Microcosm::teardown` is invoked. Useful for cleaning up
work done in `setup()`.

### `register()`

Returns an object mapping actions to methods on the effect. This is the
communication point between a effect and the rest of the system.

```javascript
import { addPlanet } from '../actions/planets'

class Planets {
//...
register () {
return {
[addPlanet]: this.alert
}
}

alert (repo, planet) {
alert('A planet was added! ' + planet.name)
}
}

repo.push(Actions.add, { name: 'earth' }) // this will add Earth
```
2 changes: 1 addition & 1 deletion src/action.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export default class Action extends Emitter {
*/
reconcile() {
if (this.history) {
this.history.reconcile()
this.history.reconcile(this)
}

return this
Expand Down
55 changes: 55 additions & 0 deletions src/effects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* A cluster of effects.
*/

import merge from './merge'

const EMPTY = {}

export default class Effects {

constructor(repo) {
this.repo = repo
this.effects = []
}

trigger (action) {
for (var i = 0; i < this.effects.length; i++) {
let effect = this.effects[i]
let handlers = effect.register ? effect.register() : EMPTY

if (handlers[action.type]) {
handlers[action.type](this.repo, action.payload)
}
}
}

add (config, options) {
let effect = null

if (typeof config === 'function') {
effect = new config(this.repo, options)
} else {
effect = merge({ repo: this.repo }, config)
}

this.effects.push(effect)

if (typeof effect.setup === 'function') {
effect.setup(this.repo, options)
}

return this
}

teardown () {
for (var i = 0; i < this.effects.length; i++) {
let effect = this.effects[i]

if (typeof effect.teardown === 'function') {
effect.teardown(this.repo)
}
}
}

}
7 changes: 6 additions & 1 deletion src/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export default class History {
return this.size <= 0 || this.repos.length <= 0
}

reconcile () {
reconcile (action) {
if (this.isDormant()) {
return false
}
Expand All @@ -118,6 +118,11 @@ export default class History {
this.rollforward()
this.archive()
this.invoke('release')

// Play effects after a release so that they can reference the new state
if (action) {
this.invoke('effect', action)
}
}

rollforward () {
Expand Down
26 changes: 26 additions & 0 deletions src/microcosm.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Emitter from './emitter'
import History from './history'
import MetaDomain from './domains/meta'
import Realm from './realm'
import Effects from './effects'
import lifecycle from './lifecycle'
import merge from './merge'
import shallowEqual from './shallow-equal'
Expand All @@ -25,6 +26,7 @@ export default class Microcosm extends Emitter {

this.history = history || new History(maxHistory)
this.realm = new Realm(this)
this.effects = new Effects(this)

this.pure = pure
this.parent = parent
Expand Down Expand Up @@ -76,6 +78,9 @@ export default class Microcosm extends Emitter {
// Teardown all domains
this.realm.teardown(this)

// Teardown all effects
this.effects.teardown(this)

// Remove all listeners
this.off()

Expand Down Expand Up @@ -273,6 +278,27 @@ export default class Microcosm extends Emitter {
return this.addDomain.apply(this, arguments)
}

/**
* An effect is a one-time handler that fires whenever an action changes. Callbacks
* will only ever fire once, and can not modify state.
*
* @param {Object} config - Configuration for the effect
* @param {Object} options - Options to pass to the effect
* @return {Microcosm} self
*/
addEffect (effect, options) {
this.effects.add(effect, options)

return this
}

/**
* Trigger an effect
*/
effect (action) {
this.effects.trigger(action)
}

/**
* Push an action to reset the state of the instance. This state is folded
* on to the result of `getInitialState()`.
Expand Down
95 changes: 95 additions & 0 deletions test/effects.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import Microcosm from '../src/microcosm'

test('invokes an effect when an action completes', function () {
const repo = new Microcosm()
const test = n => n

const Effect = {
handler: jest.fn(),
register() {
return {
[test] : this.handler
}
}
}

repo.addEffect(Effect)

repo.push(test, true)

expect(Effect.handler).toHaveBeenCalledWith(repo, true)
})

test('an effect is only called once - at reconciliation', function () {
const repo = new Microcosm()
const test = n => n

const Effect = {
handler: jest.fn(),
register() {
return {
[test] : this.handler
}
}
}

repo.addEffect(Effect)

const one = repo.append(test)
const two = repo.append(test)

two.resolve()
one.resolve()

expect(Effect.handler).toHaveBeenCalledTimes(2)
})

test('an effect may be a class', function () {
const repo = new Microcosm()
const test = n => n
const spy = jest.fn()

class Effect {
handler = spy

register() {
return {
[test] : this.handler
}
}
}

repo.addEffect(Effect)

repo.push(test, true)

expect(spy).toHaveBeenCalledWith(repo, true)
})

test('an effect is setup with options', function () {
const repo = new Microcosm()
const spy = jest.fn()

class Effect {
setup = spy
}

repo.addEffect(Effect, { test: true })

expect(spy).toHaveBeenCalledWith(repo, { test: true })
})

test('an effect is torn down with the repo', function () {
const repo = new Microcosm()
const spy = jest.fn()

class Effect {
teardown = spy
}

repo.addEffect(Effect)

repo.teardown()

expect(spy).toHaveBeenCalledWith(repo)
})