@orodio/central

0.0.2 • Public • Published

@orodio/central

Build Status

Install

yarn add @orodio/central

General terms

intent future tense

A request from the user to do something or know about something.

  • Only ever called from the view or from another intent.
  • The primary way to interact with the api.
  • The only thing that ever creates/dispatches an event.

event past tense

Something that did happen.

  • Will posibly trigger a change in the applications state.
  • Can only be created by the dispatch function.

handler current tense

Takes an event and the current state and gives us the new state.

  • Can only be triggered by an event.
  • Must return the new state.

Usage

createStore is passed an initialState and returns the following functions.

const initialState({
  counters:new Map({
    "counter_1": new $Counter({
      id: "counter_1",
      title: "foo",
      count: 0,
    })
  })
})
const { dispatch, getState, connect, handler, intent, $ } = createStore(initialState)
  • initialState must be an immutable Map from ImmutableJS.

dispatch dispatches events into the store

dispatch('counter_set', counterId, counter)
  • Should only ever be called from inside of an intent (never directly from the view)

handler registers how an event transforms the current state

handler('counter_set', (state, counterId, counter) => {
  return state.setIn(['counters', counterId], counter)
})
  • Must return the next state
  • creates a default intent with the same event name. ie: counter_set

intent registers how an event is created

intent('counter_set', (counter) => {
  return dispatch('counter_set', counter.id, counter)
})

intent('counter_get', ({ id }) => {
  return getCounterFromApi({ id })
    .then(normalizeCounter)
    .then($('counter_set'))
})
  • All async needs to happen in an intent
  • the only place you should ever call dispatch is in an intent

intent and $ how you access the intents you registerd

const counter2 = new $Counter({
  id: 'counter_2',
  title: 'bar',
  count: 10,
})

$('counter_set')(newCounter)

const counter3 = new $Counter({
  id: 'counter_3',
  title: 'baz',
  count: 15,
})

const counterSet = $('counter_set')
counterSet(counter3)
  • $/intents and connect should be the only parts of a store you call in your view.

getState gets the current state

getState() // Map({ counters:Map({ "counter_1":$Counter({ id:..., title:..., count:... }) }) })
getState(['counters', 'counter_1', 'title']) // "foo"
getState(['counters', 'counter_1', 'doesnt_exist'], 'default value') // "default_value"
  • Returns the current state (Immutable Map)
  • If if passed arguments acts like .getIn([cursor], defaultValue)

connect connects a react component to the store

const inc = $('counter_inc')

const Counter = ({ id, title, count }) =>
  <div>
    <strong>{ title }:</strong> { count }
    <button onClick={() => inc(id)}>+</button>
  </div>

const mapStateToProps = (getState, props) => {
  const { id } = props
  return {
    title: getState(['counters', id, 'title'], ""),
    count: getState(['counters', id, 'count'], 0),
  }
}

export default connect(mapStateToProps)(Counter)
  • getState in mapStateToProps is an actual getState function like the one returned from createStore
  • You want to do as little computation inside of mapStateToProps as possible

Something to keep in mind

dispatch, intents and the callback passed into handler are all variadic, for example:

  • Given a dispatch call like: dispatch("event_name", a, b, c, d, e, f)
  • It will be handled by the handler: handler("event_name", (currentState, a, b, c, d, e, f) => nextState)
  • Which creates a default intent that is equivalent to:
    intent("event_name", (a, b, c, d, e, f) =>
         dispatch('event_name', a, b, c, d, e, f))
  • Which can then be called with: $('event_name')(a, b, c, d, e, f)

In the future the following will be equivalent (using the above as an example)

This is not the case right now.

  $('event_name')(a, b, c, d, e, f)
  $`event_name`(a, b, c, d, e, f)
  $.event_name(a, b, c, d, e, f)

Example

import createStore from '@orodio/central'
import { Map, Record } from 'immutable'

// Implementation detail
const $Counter = new Record({
  id:    null,
  title: "",
  count: 0,
})

// our stores initial state
const initialState = new Map({
  counters: new Map({
    "a": new $Counter({ id:"a", title:"foo", count:"10" }),
    "b": new $Counter({ id:"b", title:"bar", count:"15" }),
  }),
})

// the creation of our store
const {
  dispatch,
  getState,
  connect,
  handler,
  intent,
  $,
} = createStore(initialState)


// register how an "inc_by" event can transform the state
handler('inc_by', (state, id, delta = 1) =>
  state.updateIn(['counters', id, 'count'], count => count + delta))

// register our intents
// our above handler already created an `inc_by` intent under the hood
// but if it didnt the following would do the same thing.
// We could also overload the 'inc_by' intent here if we wanted to, as
// the 'inc_by' handler is only actually called if we dispatch an 'inc_by' event.
// we default the intent to do this as a convenience but its important to
// remember that there isnt always a one-to-one mapping of intents and handlers
// later we can access this intent with `$('inc_by')`
// or call it with `$('inc_by')("a", 10)` which would 'increment counter "a" by 10'
intent('inc_by')

// this intent is calling another intent
// it can be accessed by `$('inc')`
// and be called with `$('inc')("a")` which would 'increment counter "a" by 1'
intent('inc', (id) => $('inc_by')(id, 1))

// this is an intent dispatching a totally different event
// it could just as easily have been done the same way as our 'inc' intent above
// it can be accessed by `$('dec')`
// and be called with `$('dec')("a")` which would 'increment counter "a" by -1'
intent('dec', (id) => dispatch('inc', id, -1))

// if we had async stuff (like hitting an api) we would do it in an intent.
handler('counters_set', (state, counters=new Map()) => {
  return state.setIn(['counters'], counters)
})
intent('counters_get', () => {
  return getCountersFromApi() // conveniently returns the data structure our store uses :P
    .then(counters => dispatch('counters_set', counters))
})

handler('counter_set', (state, id, counter) => {
  return state.setIn(['counters', id], counter)
})
intent('counter_get', ({ id }) => {
  return getCounterFromApi({ id })
    .then(counter => new $Counter(counter))
    .then(counter => dispatch('counter_set', counter.id, counter))
})

// we will leverage the fact that our `counters_get` and `counter_get` intents return
// a promise later in our components to show a loading state

//
// In our Components we could do some things like this
//

const inc = id => () =>
  $('inc')(id)

const dec = id => () =>
  $('dec')(id)

// what does it look like?
export const Counter = ({
  id      = null,
  title   = '',
  count   = 0,
  total   = 0,
  loading = false,
  dec     = dec, // lets us mock dec for testing
  inc     = inc, // lets us mock inc for testing
}) => {
  if (loading) return <div>Loading...</div>
  return <div>
    <strong>{ title }:</strong>
    <span>{ count }/{ total }</span>
    <button onClick={dec(id)}>-</button>
    <button onClick={inc(id)}>+</button>
  </div>
}

// what is the current state of our component?
// do we need any additional data from the server?
export class XhrCounter extends React.Component {
  state = {
    loading: true
  }

  componentDidMount () {
    this.mount = true
    $('counter_get')(this.props)
      .then(() => this.mount && this.setState({ loading:false }))
  }

  componentWillUnmount () {
    this.mount = false
  }

  render () {
    const { loading } = this.state
    return <Counter {...this.props}/>
  }
}

export const mapStateToProps = (state, { id }) => ({
  title: state(['counters', id, 'title'], ''),
  count: state(['counters', id, 'count'], 0),
  total: state()
          .getIn(['counters'], new Map())
          .reduce((total, counter) => total + counter.count, 0),
})

export const mapPropsToKey = props =>
  props.id

// connect returns a component that can be used as a covariant functor in a map function.
// how do we get the data from our store?
export const ConnectedCounter =
  connect(mapStateToProps)(XhrCounter, mapPropsToKey) // mapPropsToKey is completely optional

export default ConnectedCounter

//
// Another Component
//

// what does it look like?
export const Counters = ({ counters = [], loading = false }) => {
  if (loading) return <div>Loading...</div>

  return <div>
    <h1>Counters</h1>
    <div>
      { !counters.length
          ? "No Counters"
          : counters.map(ConnectedCounter)
      }
    </div>
  </div>
}

// what is the current state of our component?
// do we need any additional data from the server?
export class XhrCounters extends React.Component({
  state = {
    loading: true,
  }

  componentDidMount () {
    this.mount = true
    $('counters_get')()
      .then(() => this.mount && this.setState({ loading:false }))
  }

  componentWillUnmount () {
    this.mount = false
  }

  render () {
    const { loading } = this.state
    const { counters=[] } = this.props

    return <Counters
      counters={ counters }
      loading={ loading }
    />
  }
})

// how do we get the data from our store?
export const ConnectedCounters = connect(state => ({
  counters: state(['counters'], new Map())
              .map(counter => ({ id:counter.id }))
              .toArray(),
}))(XhrCounters)

export default ConnectedCounters


// Render it! :tada:
ReactDOM.render(<ConnectedCounters/>, domNodeToRenderIn)

Readme

Keywords

none

Package Sidebar

Install

npm i @orodio/central

Weekly Downloads

0

Version

0.0.2

License

MIT

Last publish

Collaborators

  • orodio