Microcosm

Presenter

  1. Overview
  2. Track changes and compute values
  3. Receiving Actions
  4. API

Overview

Presenter is a specialized React component that creates a boundary between “smart” and “dumb” components. This improves testing and keeps business logic in a consistent place (instead of spread across bunches of components).

Use Presenters to track changes to a Microcosm, push actions, and manage application flow.

Track changes and compute values

Presenter extends from React.Component and can be used exactly the same way. By implementing a getModel method, Presenters declare what information they need from an instance of Microcosm:

import React from 'react'
import DOM from 'react-dom'
import Presenter from 'microcosm/addons/presenter'
import Microcosm from 'microcosm'

const repo = new Microcosm()

repo.patch({ planets: [ 'Mercury', 'Venus', 'Earth' ]})

class PlanetsPresenter extends Presenter {

  getModel (props, state) {
    return {
      planets: data => data.planets
    }
  }

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

    return <p>{planets.join(', ')}</p>
  }

}

DOM.render(<PlanetsPresenter repo={ repo } />)

// <p>Mercury, Venus, Earth</p>

Presenters accept a repo property; an instance of Microcosm. Here, PlanetsPresenter extracts a list of planets its given Microcosm and stores it within this.model.

Presenters track their Microcosm instance for changes, keeping this.model in sync.

Receiving Actions

Though explicit, passing event callbacks down through a deep component hierarchy can be cumbersome and brittle. Presenters expose a method on context that enable child components to declare actions receivable by Presenters.

The ActionForm add-on can be used to broadcast actions to Presenters:

import React from 'react'
import DOM from 'react-dom'
import Presenter from 'microcosm/addons/presenter'
import ActionForm from 'microcosm/addons/action-form'
import Microcosm from 'microcosm'

const repo = new Microcosm()

const increaseCount = n => n

repo.addDomain('count', {
  getInitialState() {
    return 0
  },
  increase(count, amount) {
    return count + amount
  },
  intercept() {
    return {
      [increaseCount] : this.increase
    }
  }
})

function StepperForm ({ count }) {
  return (
    <ActionForm action="increment">
      <input type="hidden" name="amount" value="1" />
      <p>The current count is { count }</p>
      <button>+ 1</button>
    </ActionForm>
  )
}

class CountPresenter extends Presenter {
  getModel () {
    return {
      count: data => data.count
    }
  }

  intercept () {
    return {
      "increment": this.increaseCount
    }
  }

  increaseCount (repo, { amount }) {
    return repo.push(increaseCount, amount)
  }

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

    return <StepperForm count={count} />
  }
}

DOM.render(<CountPresenter repo={ repo } />, document.getElementById('container'))

Whenever the form is submitted, an increaseCount action will bubble up to the associated Presenter including the serialized parameters of the form. Since this Presenter’s intercept method includes increaseCount, it will invoke the method with the associated parameters.

If a Presenter does not intercept an action, it will bubble up to any parent Presenters. If no Presenter intercepts the action, it will dispatch the action to the repo.

function StepperForm ({ count }) {
  return (
    <Form action={ increaseCount }>
      <input type="hidden" name="amount" value="1" />
      <p>The current count is { count }</p>
      <button>+ 1</button>
    </Form>
  )
}

API

setup(repo, props, state)

Called when a presenter is created, useful any prep work. setup runs before the first getModel invocation.

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

class PlanetsList extends Presenter {
  setup (repo, props, state) {
    // Important: this.model is not defined yet!
    repo.push(getPlanets)
  }
  // ...
}

ready(repo, props, state)

Called after the presenter has run setup and executed the first getModel. This hook is useful for fetching initial data and other start tasks that need access to the model data.

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

class PlanetsList extends Presenter {
  getModel () {
    return {
      planets: state => state.planets
    }
  }
  ready (repo, props, state) {
    if (this.model.planets.length <=0) {
      repo.push(getPlanets)
    }
  }
  // ...
}

update(repo, nextProps, nextState)

Called when a presenter gets new props. This is useful for secondary data fetching and other work that must happen when a Presenter receives new information.

import { getPlanet } from '../actions/planets'

class Planet extends Presenter {
  getModel (props) {
    const { planetId } = props

    return {
      planet: state => state.planets.find(planet => planet.id === planetId)
    }
  }
  update (repo, nextProps, nextState) {
    if (nextProps.planetId !== this.props.planetId)
      repo.push(getPlanet, nextProps.planetId)
    }
  }
  // ...
}

In order for this hook to be useful, we ensure that update is executed only after the latest model has been calculated.

NOTE: update is not called every time model changes! It only gets called when the props sent to Presenter change or when state changes within the Presenter. If this is what you need, see modelWillUpdate.

modelWillUpdate(repo, nextModel, changeset)

Called right before a presenter’s model changes. The third argument, changeset, provides the subset of the model that changed. This is useful for determining when to fetch new data:

import { getPlanet } from '../actions/planets'
import { find } from 'lodash'

class Planet extends Presenter {
  getModel(props) {
    let { id } = props.location.query

    return {
      id: id,
      planet: state => find(state.planets, { id })
    }
  }
  modelWillUpdate(repo, nextModel, changeset) {
    if ('id' in changeset)
      repo.push(getPlanet, changeset.id)
    }
  }
  // ...
}

teardown(repo, props, state)

Runs when the presenter unmounts. Useful for tearing down subscriptions and other setup behavior.

class Example extends Presenter {
  setup () {
    this.socket = new WebSocket('ws://localhost:3000')
  }
  teardown () {
    this.socket.close()
  }
}

getModel(props, state)

Builds a view model for the current props and state. This must return an object of key/value pairs.

class PlanetPresenter extends Presenter {
  getModel (props, state) {
    return {
      planet : data => data.planets.find(p => p.id === props.planetId)
    }
  }
  // ...
}

getModel assigns a model property to the presenter, similarly to props or state. It is recalculated whenever the Presenter’s props or state changes, and functions returned from model keys are invoked every time the repo changes.

view

If a Presenter has a view property, it creates the associated component instead of calling render. The view component is given the latest model data:

function Message ({ message }) {
  return <p>{message}</p>
}

class Greeter extends Presenter {
  view = Message

  getModel ({ greet })
    return {
      message: "Hello, " + greet
    }
  }
}

Views may also be assigned as a getter:

class ShowPlanet extends Presenter {
  getModel (props) {
    return {
      planet: state => state.planets.find(p => p.id === props.id)
    }
  }
  get view {
    return this.model.planet ? PlanetView : MissingView
  }
}

Views are passed the send method on a Presenter. This provides the exact same behavior as withSend:

function Button ({ send }) {
  return <button onClick={() => send('test')}>Click me!</button>
}

class Example extends Presenter {
  view = Button

  intercept () {
    return {
      'test': () => alert("This is a test!")
    }
  }
}

intercept()

Catch an action emitted from a child view, using an add-on ActionForm, ActionButton, or withSend. These add-ons are designed to improve the ergonomics of presenter/view communication. Data down, actions up.

import ActionForm from 'microcosm/addons/action-form'

class HelloWorldPresenter extends Presenter {
  intercept () {
    return {
      'greet': this.greet
    }
  }
  greet (repo, data) {
    alert("hello world!")
  }
  render () {
    return (
      <ActionForm action="greet">
        <button>Greet</button>
      </ActionForm>
    )
  }
}

getRepo(repo, props)

Runs before assigning a repo to a Presenter. This method is given the parent repo, either passed in via props or context. By default, it returns a fork of that repo, or a new Microcosm if no repo is provided.

This provides an opportunity to customize the repo behavior for a particular Presenter. For example, to circumvent the default Presenter forking behavior:

class NoFork extends Presenter {
  getRepo (repo) {
    return repo
  }
}

send(action, ...params)

Bubble an action up through the presenter tree. If no parent presenter responds to the action within their intercept() method, then dispatch it to the root Microcosm repo.

This works exactly like the send property passed into a component that is wrapped in the withSend higher order component.

function AlertButton ({ message, send }) {
  return (
    <button onClick={() => send('alert', message)}>
      Click Me
    </button>
  )
}

class Example extends Presenter {
  intercept () {
    return {
      alert: message => alert(message)
    }
  }

  render () {
    return <AlertButton message="Hey!" send={this.send} />
  }
}