Real-World MobX Project Architecture

Ego Slide

@mattiamanzati

How I fell in love with Python

How I cheated my Python wife with JavaScript

Redux

Raise your hand if you used in production...

MobX

Raise your hand if you used in production...

React.createContext

Do you want to experiment with just

I hope I'll change your idea!

What does an application needs?

STORE ITS STATE

MODIFY ITS STATE

VIEW ITS STATE

AGGREGATED

PERFORM
SIDE EFFECTS

...JavaScript?

{
name: "work",
done: false
}
onClick = () =>{
todo.done = true
}
get totalCount(){
return this.todos.length
}

???

MobX

  • Reactive library
  • No concept of stream
  • Uses Proxies (ES6+) or Atoms (ES5-)

Observables

  • Store your application state
  • State may change over time
  • Observable instance may be the same

Actions

  • Change observables state value

  • Can be triggered from UI or side effects

Computeds

  • Derived state data
  • Automatically updated synchronusly
  • Always up to date with current observable state

Reactions

  • Trigger functions when a condition changes

  • UI is a reaction of the store state

A successful pattern in history!

Excel is Reactive!

CELLS

USER INTERACTION

FORMULAS

SCREEN UPDATES

Let's start!

A classic example: TODO Lists

class App extends React.Component {
  // observable values
  currentText = "";
  todos = [];

  // actions
  addTodo = () => {
    this.todos.push({ name: this.currentText, done: false });
    this.currentText = "";
  };
  toggleTodo = todo => {
    todo.done = !todo.done;
  };
  setCurrentText = text => {
    this.currentText = text;
  };

  // computed
  get pendingCount() {
    return this.todos.filter(todo => !todo.done).length;
  }

  // ...
}
class App extends React.Component {
  // ...
  // render observer
  render() {
    return (
      <div className="App">
        <h1>TODOs</h1>
        <input
          type="text"
          value={this.currentText}
          onChange={e => this.setCurrentText(e.target.value)}
        />
        <button onClick={this.addTodo}>Add</button>
        <ul>
          {this.todos.map(item => (
            <li onClick={() => this.toggleTodo(item)}>
              {item.name} {item.done ? <i>DONE!</i> : null}
            </li>
          ))}
        </ul>
        <p>There are {this.pendingCount} pending todos.</p>
      </div>
    );
  }
}
class App extends React.Component {
  // ...
}

const MyApp = observer(App);

decorate(App, {
  currentText: observable,
  todos: observable,
  addTodo: action,
  toggleTodo: action,
  setCurrentText: action,
  pendingCount: computed
});

const rootElement = document.getElementById("root");
ReactDOM.render(<MyApp />, rootElement);

Few notes:

Component state managed by MobX or not?

class App extends React.Component {
  @observable 
  currentText = ""

  // ...
}

export default observer(App)
class App extends React.Component {
  state = {
    currentText: ""
  }

  // ...
}

Few notes:

Component state managed by MobX or not?

  • Better optimized than setState
  • Simpler API than setState
  • Easier to refactor to a separate store
class App extends React.Component {
  @observable 
  currentText = ""

  // ...
}

export default observer(App)

Few notes:

Decorators are optional

class Store {
  @observable
  todos = []
}
class Store {
  todos = []
}

decorate(Store, {
  todos: observable
});

Few notes:

Classes are optional

class Store {
  @observable
  todos = []
}

const store = new Store()
const store = observable({
   todos: []
})

MobX is unopinionated

  • How do I X?

  • Should I MVC?

  • Should I MVVM?

  • Should I MVP?

  • Should I Flux?

MobX is a

reactive data library

It works!

Is it testable?

Mix BL and UI?

Does it scales?

Don't mix Business Logic & UI

  • What if we want a React Native app later?
  • What if UI framework changes?
  • What if we need SSR?

Don't mix Business Logic & UI

class Store {
  // observable values
  currentText = "";
  todos = [];

  // actions
  addTodo = () => {
    this.todos.push({ name: this.currentText, done: false });
    this.currentText = "";
  };
  toggleTodo = todo => { todo.done = !todo.done; };
  setCurrentText = text => { this.currentText = text; };

  // computed
  get pendingCount() {
    return this.todos.filter(todo => !todo.done).length;
  }
}

Extracting the Store

class Store {
   // ...
}

class App extends React.Component {
   // ...
}


const MyApp = observer(Store);
const store = new Store();
const rootElement = document.getElementById("root");
ReactDOM.render(<MyApp store={store} />, rootElement);

Don't mix Business Logic & UI

Wiring the Store as prop

import { observer, Provider, inject } 
  from "mobx-react";

// ...
const MyApp = inject("store")(observer(App));

const store = new Store();
const rootElement = document.getElementById("root");
ReactDOM.render(
    <Provider store={store}>
        <MyApp />
    </Provider>, rootElement);

Don't mix Business Logic & UI

Use Provider & Inject

  • No need to pass down manually your stores
  • Allows to change store implementation in your tests
  • Only one point of store injections into the views

Real-World Architecture

Current State Recap

VIEW

STORE

Renders the view and

calls actions on the store

Holds and modify domain state

Holds and modify application state

Implements business logic

Fetch from API

Implements view actions

Aggregates data for the view

Way too much things!

Domain State

WTF is dat?

  • Is the domain of your app
    • in our example, of Todo
  • Describes the application entities and relations
  • Usually it is persistent and stored somewhere
  • Not tight with the UI
  • Highly reusable

Domain State

first implementation

class Todo {
  name = "";
  done = false;
}
decorate(Todo, {
  name: observable,
  done: observable
});

class Store {
  // ...
  addTodo = () => {
    const todo = new Todo();
    todo.name = this.currentText;
    this.todos.push(todo);
    this.currentText = "";
  };
}

Domain State

domain actions

// domain todo model
class Todo {
  name = "";
  done = false;

  toggle(){
     this.done = !this.done
  }
}

decorate(Todo, {
  // ...
  toggle: action
});

Observables should be changed trough actions! So it's a good idea to define dumb actions on our domain model!

Domain State

let's be more real!

// domain todo model
class Todo {
  name = "";
  done = false;
  user_id = 0; // UHM... is this ok?
  // ...
}

// domain user model
class User {
  id = 0
  name = ""
}

Domain State

tree or graph?

// domain todo model
class Todo {
  name = "";
  done = false;
  user_id = 0;
  // ...
}
// domain user model
class User {
  id = 0
  name = ""
}
// domain todo model
class Todo {
  name = "";
  done = false;
  user = new User(0, "");
  // ...
}
// domain user model
class User {
  id = 0
  name = ""
}
  • Normalized
  • Object tree
  • Easily serialize
  • Difficult access
  • Denormalized
  • Object graph
  • Harder serialization
  • Easy access

Domain State

good news everyone!

Domain State

lets do both!

// domain todo model
class Todo {
  name = ""
  done = false
  user_id = 0

  get user(){
     return store.getUserById(this.user_id) // <- WTF
  }
  set user(value){
    this.user_id = value.id
  }
}

decorate(Todo, {
  //...
  user_id: observable,
  user: computed
})

Multiple Store Communication

available patters

  • Singleton instance & require/import
  • Dependency injection framework
  • Root store pattern

Multiple Store Communication

root store pattern

class RootStore {
  todoStore = null;
  fetch = null;
  apiKey = "";

  constructor(fetch, apiKey){
    this.fetch = fetch
    this.apiKey = apiKey
    this.todoStore = new Store(this)
  }
}

class Todo {
  store = null;
  constructor(store){ this.store = store }
}
class Store {
  rootStore = null;
  constructor(rootStore){ this.rootStore = rootStore }
}

Multiple Store Communication

root store pattern

  • Central point for each store to communicate
  • Strongly typed
  • Works as dependency root
  • Can host environment specific variables
  • Very easily testable

Multiple Store Communication

back to our problem

// domain todo model
class Todo {
  name = ""
  done = false
  user_id = 0

  store = null;
  constructor(store){ this.store = store; }

  get user(){
     return this.store.rootStore
         .userStore.getUserById(this.user_id)
  }
  set user(value){
    this.user_id = value.id
  }
}

Multiple Store Communication

back to our problem

import { observer, Provider, 
  inject } from "mobx-react";

// ...

const MyApp = inject("store")(observer(App));

const store = new RootStore(window.fetch);
const rootElement = document.getElementById("root");
ReactDOM.render(
    <Provider store={store}>
        <MyApp />
    </Provider>, rootElement);

Multiple Store Communication

back to our problem

test("it should restore data from API", async t => {
  const fakeFetch = () => Promise.resolve({ 
    data: [{id: 1, name: "Mattia"}]
  })
  const store = new RootStore(fakeFetch)
  await store.userStore.fetchAll()
  t.equal(store.userStore.users[0].name, "Mattia")
})

Real-World Architecture

Current State Recap

VIEW

STORE

Renders the view and

calls actions on the store

Holds domain state

Create and modify domain state

Holds and modify application state

Implements business logic

Fetch from API

Aggregates data for the view

DOMAIN MODEL

Domain Model Serialization

turning JSON into observables

  • Our state is an object tree
  • Unfortunately it's not a plain JS object
  • Most APIs provide JSON as intermediate language
  • We need to provide conversions to serialize or deserialize our state

Domain Model Serialization

deserialization

class Todo {
  // ...

  constructor(store, data){
    this.store = store
    if(data) Object.assign(this, data)
  }
}

Domain Model Serialization

serialization

class Todo {
  // ...
  get toJSON(){
    return {
      name: this.name,
      done: this.done
    }
  }
}

// ...
decorate(Todo, {
  toJSON: computed
})

JSON is just another view of the domain model,
so we can just derive it

derive

Domain Model Serialization

packages

class User {
    @serializable(identifier()) id = 0;
    @serializable name = '';
}
class Todo {
    @serializable name = '';
    @serializable done = false;
    @serializable(object(User)) user = null;
}
// You can now deserialize and serialize!
const todo = deserialize(Todo, {
    name: 'Hello world',
    done: true,
    user: { id: 1, name: 'Mattia' }
});
const todoJSON = serialize(todo)

with serializr you get that with tagging your domain models

Domain Model Serialization

the deserialization problem

class TodoStore {
  todos = []

  fromCache(){
    const cachedData = localStorage.getItem("todos")
      || "[]"
    this.todos = JSON.parse(cachedData)
      .map(data => new Todo(this, data)
  }

  getById = id => this.todos
    .find(item => item.id === id)
}

decorate(TodoStore, {
  fromCache: action
})

deserializing is memory intensive!

MobX hidden feature

_interceptReads

import {_interceptReads} from "mobx"

const todos = observable.map()

_interceptReads(todos, value => value + "! LOL")

todos.set(1, "Mattia")
console.log(todos.get("1")) // => Mattia! LOL
  • undocumented (yet there since 2017) mobx feature
  • allows to transform mobx values while reading
  • available for objects, arrays, maps and boxed values

MobX hidden feature

_interceptReads to the rescue!

class TodoStore {
  todos = []
  _cache = {}

  constructor(rootStore){
    this.rootStore = rootStore
    // ...
    _interceptReads(this.todos, this.unboxTodo)
  }

  unboxTodo = data => {
    if(this._cache[data.id]){
      return this._cache[data.id]
    }
    this._cache[data.id] = new Todo(this, data)
    return this._cache[data.id]
  }
}

MobX Performance

better performant domain stores

  • Use ES6 Maps and lookup by ID when possible
  • Store JSON in an observable map
  • Use _interceptReads to perform lazy deserialization
  • Implement ID -> name without deserialization

Real-World Architecture

Current State Recap

VIEW

REPOSITORY

Renders the view and

calls actions on the store

Holds domain state

Create and modify domain state

Fetch from API

Holds and modify application state

Implements business logic

Aggregates data for the view

STORE

DOMAIN MODEL

class Overview {
  rootStore = null
  currentText = ""

  constructor(rootStore){ this.rootStore = rootStore }

  get todos(){
    // perform sorting and pagining here
    return this.rootStore.todoStore.todos
  }

  handleAddClicked(){
    this.rootStore.todoStore.addTodo(this.currentText)
    this.currentText = ""
  }
}

decorate(Router, {
  todos: computed,
  currentText: observable,
  handleAddClicked: action
})

Services & Presenters

routing

class Router {
  uri = "/"
  page = null

  // ...
  constructor(rootStore){
    reaction(() => this.uri, uri => this.parseUri(uri))
  }

  openOverview(){
    this.page = { name: "overview", data: { project_id: 1 }}
  }
}

decorate(Router, {
  uri: observable,
  page: observable
})

Where load that data?

Services & Presenters

view presenter

class Router {
  uri = "/"
  page = null

  constructor(rootStore){
    this.page = this.loadHome()
    reaction(() => this.uri, uri => this.parseUri(uri))
  }

  openOverview(){
    this.page = fromPromise(
      this.overviewStore.fetchData()
        .then(data => 
          Promise.resolve({ name: "overview", data })
        )
    )
  }
}

Services & Presenters

fromPromise

class Router {
  uri = "/"
  page = null

  constructor(rootStore){
    this.page = this.loadHome()
    reaction(() => this.uri, uri => this.parseUri(uri))
  }

  openOverview(){
    this.page = fromPromise(
      this.overviewStore.fetchData()
        .then(data => 
          Promise.resolve({ name: "overview", data })
        )
    )
  }
}

MobX Async Tips

reactive finite state machines

<div>
  { this.page.case({
    pending:   () => "loading",
    rejected:  (e) => "error: " + e,
    fulfilled: ({name, data}) => {
      switch(name){
        case "home": return <HomePage data={data} />;
        case "overview": return <Overview data={data} />;
      }
    }
  })}
</div>

Real-World Architecture

Current State Recap

VIEW

REPOSITORY

Renders the view and

calls actions on the presenter

Holds domain state

Create and modify domain state

Fetch from API

Holds and modify application state

Implements business logic

SERVICE

DOMAIN MODEL

PRESENTER

Aggregates data for the view

Call actions on the services

Today's Recap

  1. Use Redux if you're paid by the hour
  2. Find your minimal state
  3. Derive whatever is possible
  4. Think as you're building for any UI environment
  5. Always experiment and have fun!

Waaaait...

does'nt Volto use Redux?

MobX + Redux = MobX-State-Tree

an opinionated immutable & mutable solution

Data Shape

predefined data shapes for domain state

const Todo = types.model({
    name: types.string,
    done: types.boolean
}).actions(
  self => ({
  	toggle: () => self.done = !self.done
  })
)

Automatic de/serialization

based on the data shape of the domain model

const work = Todo.create()
work.toggle()

const serializedData = getSnapshot(work)
// serializedData = {name: "Work!", done: false}

applySnapshot(work, { name: "Work!", done: true })
// work.done = true

Built-in time travelling

based on the data serialization

import {onSnapshot, applySnapshot} from "mobx-state-tree"

const appStates = []
onSnapshot(work, newSnapshot => 
    appStates.push(newSnapshot)
)

function travelAt(index){
    applySnapshot(store, appStates[index])
}

Redux compatible

any MST store is also a Redux store

import {asReduxStore} 
    from 'mobx-state-tree/middleware/redux'

onAction(store, msg => console.log(msg)) // => msg stream
applyAction(store, { type: "toggle", payload: []}) // => apply an msg
const reduxStore = asReduxStore(store) // => MST store as redux store

References

Thanks for your time!

Questions?

 

@MattiaManzati - slides.com/mattiamanzati

Real-World MobX Project Architecture @ PloneConf

By mattiamanzati

Real-World MobX Project Architecture @ PloneConf

  • 1,025