Organizing Cerebral Code

Post by Saul Shanabrook

Problem

I really am enjoying using Cerebral, with Baobab and React. However, my project was getting a bit big, so when I added a new feature I would have to reach out into these separate parts of my code base to add the different parts of that feature.
Like if I added a new page, I would definitely need some new react components, which would go in the ./components/ directory. Those would need to change the state somehow, so I would need to add a signal in ./signals.js, which required adding actions to ./actions/ and then probably modifying my default state in controller.js. It is nice that all these things are separate, but the problem was that I had all the different parts of my app just mixed together in those locations.

Solution

So my inclination, from some Django experience, was to separate out the different parts of my app into "modules". (They would be called "apps" in Django. Basically just seperate folders with homogenous layouts).

Inside each were a bunch of React components, each in their own file in the ./<module>/components subfolder. For example, I have a systems module and within that a ./systems/components/Systems.js, a ./systems/components/System.js, and a ./systems/components/System.css.

Then I moved the signals that are called in those components to ./<module>/signals.js. The corresponding actions I put in ./<module>/actions.js. In each signals file I exported an object of signal name -> actions, like this:

import {outputSynced} from 'sync/actions'  
import * as a from './actions'

export default {  
  clickedAddNewCue: [a.addNewCue, [outputSynced]],
  clickedRemoveCue: [a.removeCue, [outputSynced]],
  changedCueName: [a.setCueName, [outputSynced]],

  liveCueChanged: [a.setLiveCue, [outputSynced]],
  clickedToggleExpandCue: [a.invertExpandCue],
  clickedGoButton: [a.advanceCue, [outputSynced]]
}

Then, in the root ./signals.js file I could just import them all and register them all with my controller

import app from 'app/signals'  
import cues from 'cues/signals'  
// import common from 'common/signals'
// import live from 'live/signals'
import patch from 'patch/signals'  
import sync from 'sync/signals'  
import systems from 'systems/signals'

const modules = [  
  app,
  cues,
  // common,
  // live,
  patch,
  sync,
  systems
]

export default function registerSignals (controller) {  
  for (var signals of modules) {
    for (var signalName in signals) {
      var actions = signals[signalName]
      controller.signal(signalName, ...actions)
    }
  }
}

Also I split up the initial state into ./<module>/state.js files. Each one just exported a bit of the state:

// ./patch/state.js
import _ from 'lodash'

import {newPatchItem} from './utils'

export default {  
  local: {
    '$allTags': [
      ['synced', 'patch'],
      patch => _.uniq(_.flatten(_.values(patch)))
    ],
    newPatchItem: newPatchItem(),
    '$newPatchItemValid': [
      ['local', 'newPatchItem'],
      patchItem => patchItem && patchItem.address !== null && patchItem.tags.length > 0
    ]
  }
}

Then in my root ./state.js file I just deep merged them all together:

import {merge} from 'lodash'

import app from 'app/state'  
import cues from 'cues/state'  
// import common from 'common/state'
// import live from 'live/state'
import patch from 'patch/state'  
import sync from 'sync/state'  
import systems from 'systems/state'

const modules = [  
  app,
  cues,
  // common,
  // live,
  patch,
  sync,
  systems
]
export default merge({}, ...modules)  

You will also notice I have a ./<module>/utils.js function that holds any helper functions I might need, like creating objects or validating something.

I also have a "common" module where I put reusable components that aren't tied to the state and utilities, like my modified Cerebral component wrapper.

Next all that is left is to import the ./state.js and ./signals.js file into my ./controller.js.

import Controller from 'cerebral'  
import Model from 'cerebral-baobab'  
import signals from './signals'  
import schema from './schema'

const baobabOptions = {  
  asynchronous: false
}

const model = Model(state, baobabOptions)  
const services = {}  
var controller = Controller(model, services)  
signals(controller)  
export default controller  

If you wanna look, this is the commit where I reorganized my files. I actually saved 30 lines of code! This is likely from consolidating my actions.

Overall, my project is easier to work on, now that all the separate parts are seperated more. For example, this is my old signals.js file:

import addNewSystem from './actions/addNewSystem.js'  
import moveSystem from './actions/moveSystem.js'  
import outputSynced from './actions/outputSynced.js'  
import setSynced from './actions/setSynced.js'  
import setSystemLevel from './actions/setSystemLevel.js'  
import setSystemQuery from './actions/setSystemQuery.js'  
import removeSystem from './actions/removeSystem.js'  
import addNewPatchItem from './actions/addNewPatchItem.js'  
import setNewPatchItemAddress from './actions/setNewPatchItemAddress.js'  
import setNewPatchItemTags from './actions/setNewPatchItemTags.js'  
import setPatchItemAddress from './actions/setPatchItemAddress.js'  
import setPatchItemTags from './actions/setPatchItemTags.js'  
import setPage from './actions/setPage.js'  
import createNewSystem from './actions/createNewSystem.js'  
import addNewCue from './actions/addNewCue.js'  
import removeCue from './actions/removeCue.js'  
import setCueName from './actions/setCueName.js'  
import setLiveCue from './actions/setLiveCue.js'  
import invertExpandCue from './actions/invertExpandCue.js'  
import advanceCue from './actions/advanceCue.js'

export default controller => {  
  controller.signal('gotWebsocketMessage', setSynced)

  controller.signal('draggedSystem', moveSystem, [outputSynced])

  controller.signal('levelChanged', setSystemLevel, {
    inSynced: [[outputSynced]],
    inLocal: []
  })
  controller.signal('queryChanged', setSystemQuery, {
    inSynced: [[outputSynced]],
    inLocal: []
  })

  controller.signal('needNewSystem', createNewSystem)

  controller.signal('clickedAddNewSystem', addNewSystem, createNewSystem, [outputSynced])
  controller.signal('clickedDeleteSystem', removeSystem, [outputSynced])

  controller.signal('clickedAddNewPatchItem', addNewPatchItem, [outputSynced])
  controller.signal('changedNewPatchItemAddress', setNewPatchItemAddress)
  controller.signal('changedNewPatchItemTags', setNewPatchItemTags)
  controller.signal('changedPatchItemAddress', setPatchItemAddress, [outputSynced])
  controller.signal('changedPatchItemTags', setPatchItemTags, [outputSynced])

  controller.signal('clickedNav', setPage)

  controller.signal('clickedAddNewCue', addNewCue, [outputSynced])
  controller.signal('clickedRemoveCue', removeCue, [outputSynced])
  controller.signal('changedCueName', setCueName, [outputSynced])

  controller.signal('liveCueChanged', setLiveCue, [outputSynced])
  controller.signal('clickedToggleExpandCue', invertExpandCue)

  controller.signal('clickedGoButton', advanceCue, [outputSynced])

}

You can see how messy that is. If you are just working on cues, for example, you have to mentally ignore all the other parts. Now it is nicely split up into five different files.

Auto loading

The one thing that still bothers me is having to manually import each module, in the ./state.js and ./signals.js files. It would be nice to just try to import all files in subdirectories with the right name. I tried to get autoloading working, both by using a dynamic call to require() and by using ES6's dynamic module loader (System.import()), but both didn't end up working in webpack.

Hope is around the corner, though. It look like that in Webpack 2 we will be able to use System.import. Which mean we can wrap that in a try ... except and just try to import all the, say state.js files from subdirectories.

TLDR

I went from this:

.
├── Storage.js
├── actions
│   ├── addNewCue.js
│   ├── addNewPatchItem.js
│   ├── addNewSystem.js
│   ├── advanceCue.js
│   ├── createNewSystem.js
│   ├── getControllerSystem.js
│   ├── invertExpandCue.js
│   ├── moveSystem.js
│   ├── outputSynced.js
│   ├── removeCue.js
│   ├── removeSystem.js
│   ├── setCueName.js
│   ├── setLiveCue.js
│   ├── setNewPatchItemAddress.js
│   ├── setNewPatchItemTags.js
│   ├── setPage.js
│   ├── setPatchItemAddress.js
│   ├── setPatchItemTags.js
│   ├── setSynced.js
│   ├── setSystemLevel.js
│   └── setSystemQuery.js
├── app
├── common
│   └── components
├── common.css
├── components
│   ├── Address.css
│   ├── Address.js
│   ├── Cue.css
│   ├── Cue.jsx
│   ├── CueName.css
│   ├── CueName.jsx
│   ├── CueSystems.jsx
│   ├── GoButton.js
│   ├── Level.css
│   ├── Level.js
│   ├── Query.js
│   ├── README.md
│   ├── System.css
│   ├── System.js
│   ├── Systems.css
│   ├── Systems.js
│   └── Tags.js
├── containers
│   ├── App.css
│   ├── App.jsx
│   ├── App.less
│   ├── Cues.jsx
│   ├── Grandmaster.jsx
│   ├── Live.css
│   ├── Live.jsx
│   ├── LiveCue.jsx
│   ├── LiveList.jsx
│   ├── Patch.jsx
│   └── README.md
├── controller.js
├── cues
├── elements
│   ├── Add.js
│   ├── AutoFillInput.css
│   ├── AutoFillInput.js
│   ├── AutoFillInputReactSelect.css
│   ├── AutoFillInputReactSelect.js
│   ├── AutoFillInputReactSelect.less
│   ├── PercentInput.css
│   ├── PercentInput.js
│   ├── README.md
│   └── Remove.js
├── index.html
├── live
├── main.jsx
├── mockWebSocket.js
├── patch
├── schema.js
├── signals.js
├── sync
├── systems
│   └── components
└── utils.js

13 directories, 68 files  

to this:

├── app
│   ├── actions.js
│   ├── components
│   │   ├── App.css
│   │   ├── App.jsx
│   │   └── App.less
│   ├── signals.js
│   └── state.js
├── common
│   ├── components
│   │   ├── Add.js
│   │   ├── AutoFillInput.css
│   │   ├── AutoFillInput.js
│   │   ├── AutoFillInputReactSelect.css
│   │   ├── AutoFillInputReactSelect.js
│   │   ├── AutoFillInputReactSelect.less
│   │   ├── PercentInput.css
│   │   ├── PercentInput.js
│   │   └── Remove.js
│   ├── hiddenInput.css
│   └── utils.js
├── controller.js
├── cues
│   ├── actions.js
│   ├── components
│   │   ├── Cue.css
│   │   ├── Cue.jsx
│   │   ├── CueName.css
│   │   ├── CueName.jsx
│   │   ├── CueSystems.jsx
│   │   ├── Cues.jsx
│   │   ├── GoButton.js
│   │   └── LiveCue.jsx
│   ├── signals.js
│   ├── state.js
│   └── utils.js
├── index.html
├── live
│   └── components
│       ├── Grandmaster.jsx
│       ├── Live.css
│       ├── Live.jsx
│       └── LiveList.jsx
├── main.jsx
├── modules.js
├── patch
│   ├── actions.js
│   ├── components
│   │   ├── Address.css
│   │   ├── Address.js
│   │   ├── Patch.jsx
│   │   └── Tags.js
│   ├── signals.js
│   ├── state.js
│   └── utils.js
├── schema.js
├── signals.js
├── state.js
├── sync
│   ├── Storage.js
│   ├── actions.js
│   ├── mockWebSocket.js
│   ├── signals.js
│   └── state.js
└── systems
    ├── actions.js
    ├── components
    │   ├── Level.css
    │   ├── Level.js
    │   ├── Query.js
    │   ├── System.css
    │   ├── System.js
    │   ├── Systems.css
    │   └── Systems.js
    ├── signals.js
    ├── state.js
    └── utils.js

13 directories, 64 files