Skip to content

danieldunderfelt/mobx-app

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

63 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mobx-app

This is my preferred state structure for mobx projects. mobx-app provides a very light (VERY LIGHT!) functional structure over mobx without hiding mobx in any way.

mobx-app was inspired by functional programming and does not use classes or inheritance. Composition all the way!

mobx-app itself is not really a library, it is a theory of how to structure applications that use mobx as their state manager. Mobx does not, as opposed to Redux, provide any structure for your state. In using mobx in React apps, I found myself re-creating a simple class-based structure (gotta love them decorators!) for each project. Juggling instances got old quick, so I sought inspiration from functional programming on how to untangle the mess. mobx-app is the (still work-in-progress!) result.

Install

With npm:

npm install --save mobx-app

mobx-app also relies on some peer dependencies that you probably already have in your project:

npm install --save lodash mobx

Overview

The core concepts of mobx-app:

  • The state is a single observable object (hello Redux!)
  • The various facets of the state is expressed in stores
  • Stores are functions that receive the state object and optionally initial data
  • Store functions return actions
  • Store functions extend the state they receive with their own state.
  • Initial data (server-side rendering, localstorage) is a first-class citizen
  • Use computed as much as possible
  • Actions (and ONLY actions) mutate state directly

Stores

Stores are simple functions with the following signature:

(state, initialData) => actions

They exist to bootstrap a slice of your state and add reactions or other subscriptions. Most importantly, they return actions. I recommend putting actions in a separate file so that they can be reused in other places.

An example of a store factory:

import { extendObservable } from 'mobx'

const Store = (state, initialData) => {

    extendObservable(state, {
        some: 'data',
        items: []
    })
    
    // Initialize actions with the state
    const actions = storeActions(state)
    
    // Run initial setup
    actions.setItems(initialData.items)

    return actions
}

Here we are using the standard extendObservable function from mobx to extend new state properties onto the global state. When we compose actions, we can feed the whole state object to the action factory. Actions do not need to be tied to a store, but any store can import any action factory and use it. The actions are then returned from the store in order to be available under the store name on the global actions object.

If you need namespaces in the state, just add your store properties under a namespace. It's that simple. No special mechanisms.

Any store setup should be performed after the state has been extended and the actions created. The initial setup procedures can then freely use any actions and state that the store makes available. This is a good opporunity to add reactions, any socket subscriptions or things like that.

The store setup is also where you can use the store actions to apply any initial data the store might receive. Of course, if the data can be imported into the store without going through actions, feel free to assign it when you extend the global state. I do however recommend that ANY data that goes into the state goes through your actions.

Actions

Actions are simple functions that (usually) mutate the state. Mutator actions are wrapped in mobx actions. As already discussed, actions are most conveniently created from a factory that receives the state. Use composition to efficiently create specific actions that build on more general ones! In fact, mobx-app ships with a handful of actions for setting values and working with collections. You know, the things you usually do in an app.

"Actions" can also be functions that do not directly mutate the state, for example fetcher functions for your api. I include these in the umbrella term actions and even define them in the same factory as other actions. They are, after all, actions that your app can perform.

An example of an action factory:

import { action } from 'mobx'

const storeActions = state => {

    const replaceItems = action((items) => {
        state.items.replace(items)
    })
    
    const addItem = action((item) => {
      state.items.push(item)
    })
    
    async function fetchItems(params) {
        const newItemsReq = await fetch('https://example.com/api/items')
        const newItems = await newItemsReq.json()
        replaceItems(newItems)
    }
    
    return {
        fetchItems,
        replaceItems,
        addItem
    }
}

In its simplest form, the actions tap into the state and perform mutations on it. You may want to pass in the name of the state property that these actions should concern, but I like to keep it simple.

createStore

You might have noticed that we haven't used a single thing you just installed with npm yet. That's the point; mobx-app is more of a concept than a library. The library mobx-app is simply a few helpers for implementing this state structure.

At the heart of mobx-app is the createStore function that creates your single state and actions. It should receive a map of store functions, loop through it, calling each function with the state and initial data as it goes. Anything returned from the store function gets assigned to an actions object under the key from the initial store map. The result is a map of actions, bearing the same keys as the initial map of stores, as well as the single state object.

An example:

import { createStore } from 'mobx-app'

const stores = {
  Store
}

const { state, actions } = createStore(stores, initialData)

Since mobx-app is unabashedly React-focused, the next step is to use Provider from mobx-react to add your state and actions to the context of your components:

import { Provider } from 'mobx-react'

const app = (
  <Provider actions={ actions } state={ state }>
    <YourApp />
  </Provider>
)

Access these with @inject from mobx-react using the app helper as a selector function. app returns a function that instructs @inject which props to pass the component. app can take a list of store names as arguments, that will result in the corresponding actions to be injected as props under the name.

Remember to still use observer!

import { observer } from 'mobx-react'
import { app } from 'mobx-app'

@inject(app('Store'))
@observer
class MyComponent extends Component {
  
  render() {
    const { Store, state } = this.props
    
    return (
      <div>
        { state.some } // prints 'data'
        <button onClick={ () => Store.addItem({ name: 'new item' }) } />
      </div>
    )
  }
}

Included actions

mobx-app ships with actions for working with collections (arrays of objects) and values.

The value actions exist so that you do not have to wrap all your actions in action, you can just create new actions for a specific value and call those actions from your higher-order action.

The collection actions wrap an array of objects and enable you to easily add, get, update and remove objects. The add actions can either simply replace all items, or add new items while checking for uniqueness. You can also specify a factory function that all new items go through automatically. Please see the api section below for more specific information, but here's an example:

import { collection, value } from 'mobx-app'

const itemFactory = itemData => { ... } // Do something with each item before adding it.

const yourActions = state => {
  const itemActions = collection(state.items, itemFactory)
  const someActions = value(state, 'some') // Remember the `some` property from the store above ;)
  
  function addItems(stuff) {
    return itemActions.addItems(stuff)
    // state.items will now contain everything from `stuff` that didn't exist before
  }
  
  function setSome(newValue) {
    return someActions.setItem(newValue)
    // Also includes `extendItem` for object values what you want to extend.
  }
  
  return {
    ...itemActions,
    setSome,
    addItems // The new addItems action will override the addItem action from `ìtemActions` for each consumer of these actions. 
  }
}

API

createStore()

Receives 1. a map of stores:

const stores = {
  ItemStore: itemStore,
  OtherStore: otherStore
}

and 2. an object representing initial data. createStore returns an object with the keys state, which is a mobx-observable object, and actions which is a plain object with the same keys as above, but containing the actions returned from the respective stores. The state is not partitioned according to the keys by default.

The store factories passed through createStore will be called with the following arguments:

store(state, initialData, name)

State is the global state, initialData is the initial data and name is the key that the store was registered under in the initial store map that you passed to createStore. Use the name for namespacing the state if you need to.


@app()

Selector function factory for mobx-react's inject.

Receives an argument list of arbitrary length of action names to add to the props of React components. It will also in all cases add the state as a prop of the component. If called with no arguments, all action names will be added to the props. If called with the special argument 'state', only the state will be added to the props.

// Props added to the component:
// { state, ItemStore }
@inject(app('ItemStore'))

// Props added to the component:
// { state, ItemStore, OtherStore }
@inject(app())

// Props added to the component:
// { state }
@inject(app('state'))

So yeah, it might be a bad idea to call your store state. The special case was added because the main job of React components is to render the current state as UI. To mutate the state is secondary and I want to give you a way to prevent littering your component props.

Also, I highly recommend to capitalize the name of the stores. When developing, you may want to pass in props of the same name as some of your stores. Normal inject rules apply, and your prop will overwrite what app wanted to inject.


collection()

The collection action factory eases your interactions with observable collections. The factory takes an observable array as its only argument and returns all collection actions.

A collection is usually an array of objects, but collection will also, in most cases, support arrays of simple values.

const itemActions = collection(itemCollection)

itemActions.setItems(items) => itemCollection

setItems replaces the array with new items.

itemActions.addItems(items, unique = 'id', processAll) => [added items]

addItems adds new items into the collection. Give a property name as a string as the unique argument to make that property value unique among all items in the collection. Pass false as the unique argument to disable the uniqueness check. The uniqueness check is on by default for the property id.

addItems also takes a processAll argument. This is a function that will be called with the whole collection, new items added, right before the old collection is replaced with it. This is an opportunity to sort the collection among other things.

itemActions.addItem(item, unique = 'id', replace = false, first = false) => item

addItem adds a single item to the collection. Unique checking is on by default for the id property.

The replace argument determines how the action behaves if an existing item is found in the collection with the same property value unique checked for. Normally this will cause addItem to return without adding anything, but by setting this to true, addItem will isntead replace the exisitng item with the new one.

The first argument decides if the new item should be added as the first element in the collection (using splice) or, the default, as the last element in the collection (using push).

itemActions.updateItem(item, idProp = 'id') => item

updateItem will use extendObservable with the new data on an existing item if found in the collection. Use the idProp argument to define how an existing item is matched.

itemActions.updateOrAdd(item, idProp = 'id', first = false) => item

This action will first determine if the item exists in the collection. If it does, updateItem will be invoked. If it doesn't, addItem will be invoked.

itemActions.removeItem(itemOrIndexOrId, idProp = 'id') => false || the removed item

removeItem will cook dinner and play the ukulele for you.

No but seriously, it removes an item from the collection. It can take either an item, an index or an id. If the first argument is a number, the item at that index will be removed. If it is a string, the item with the idProp of the same value will be removed. If it is an object, the item will be found from the collection by the idProp and removed.

Please use a higher-order function to customize this behaviour if needed.

itemActions.clear(matcherFunction = false)

Clears the collection completely if a matcher function is not passed. If a matcher is passed, each item the matcher returns true for will be removed from the collection.


value()

The value actions contain a few shortcuts for setting values, mainly so that you don't need to wrap your own actions with action. They are also quite convenient! To create value actions, pass in the state object AND the name of the value these actions should concern as a string. You may also pass an initial value, that will be assigned if you invoke the actions without arguments.

const valueActions = value(state, 'value', null)

The property name will be used as a suffix for each action returned from the factory.

An example of computed action names:

const valueActions = value(state, 'derp') => { setDerp, extendDerp }
const valueActions = value(state, 'item') => { setItem, extendItem }

This way you can make setter actions for many items in one store or action factory without them interfering with each other.

valueActions.setValue(newValue = initial) => newValue

Assigns the new value to the property.

valueActions.extendValue(newValue = initial) => extendObservable(state.value, newValue)

Extends the new value onto a property of type object.


Roadmap

This is an initial release that only serves to get this idea out, because I think it is important. For each new project I recreated a clunky state structure with classes until a lightbulb went off and I got excited for the idea of composition over inheritance and this state structure was born.

I also think the lack of structure is a silent issue with mobx for new developers, This is unfair, but comparisons to Redux are inevitably made. New developers are lost on how to structure their state, and seasoned mobx developers all have a different structure. There are many libraries on npm that bring strcuture to mobx, but most are unmaintained. That is why mobx-app is more of a theory than a library. You could easily implement it completely without even installing mobx-app! I just wanted a single source of truth to the various helpers that I have deviced in various projects.

Next up:

  • Add tests (hey, this is only a quick and dirty braindump so far!)
  • Include more actions (mobx maps up next!)
  • Listen to feedback and evolve mobx-app!