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
- Use Redux if you're paid by the hour
- Find your minimal state
- Derive whatever is possible
- Think as you're building for any UI environment
- 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