Michał Matyas
@nerdblogpl
function that tracks observables and reruns if any of them change
function that changes
observable value
value derived
from observable
class Store {
firstName = ''
lastName = ''
fullName() {
return `${this.firstName} ${this.lastName}`
}
setName(firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
}
export default decorate(Store, {
firstName: observable,
lastName: observable,
fullName: computed,
setName: action
})
const store = new Store
const App = () => (
<Provider store={store}>
<ProfilePage/>
</Provider>
)
const ProfilePage = () => (
<Profile/>
)
class Profile extends React.Component {
form = { firstName: "", lastName: "" }
update(key, value) {
this.form[key] = value
}
onSave = () => {
this.props.store.setName(this.firstName, this.lastName)
this.form = { firstName: "", lastName: "" }
}
render() {
const { firstName, lastName, props: { store } } = this
return (
<div>
<p>Hello, {store.fullName}.</p>
<Input value={firstName}
onChange={value => this.update('firstName', value)}/>
<Input value={lastName}
onChange={e => this.update('lastName', e.target.value)}/>
<button onClick={this.onSave}>
</div>
)
}
}
export default inject('store')(
decorate(observer(Profile), {
form: observable,
update: action
})
)
It simply attaches itself to getters and setters and then tracks them being called in a wrapped function (reaction)
class Store {
users = []
currentUserId = null
get user() { // computed
if(this.currentUserId) {
return this.users.find(user => user.id === this.currentUserId)
}
}
}
decorate(Store, {
users: observable, currentUserId: observable, user: computed
})
import { now } from 'mobx-utils'
class Clock extends React.Component {
render() {
return (
<div>
Right now it's {new Date(now()).toTimeString()}
</div>
)
}
}
class AlertStore {
alerts = []
constructor() {
observe(this, 'alerts', () => {
this.alerts.forEach(text => {
Alert.alert(text) // 👋 React Native
})
this.alert.clear()
}
}
}
decorate(AlertStore, {
alerts: observable
})
import { queueProcessor } from 'mobx-utils'
class AlertStore {
alerts = []
constructor() {
queueProcessor(this.alerts, () => {
Alert.alert(text) // 👋 React Native
})
}
}
decorate(AlertStore, {
alerts: observable
})
import { fromPromise } from 'mobx-utils'
class User {
@observable user
componentDidMount() {
this.user = fromPromise(fetch("/api/currentUser"))
}
render() {
return fetchResult.case({
pending: () => <div>Loading...</div>,
rejected: error => <div>Ooops.. {error}</div>,
fulfilled: value => <div>Gotcha: {value}</div>,
})
}
}
The tree is built using model types. The model type can contain:
properties (observables) - strongly typed, can use any type
actions
views (computed values)
volatiles (observables that are not typed and cannot be serialized)
const Event = types
.model('Event', {
id: types.identifier,
startTime: dateTimeType, // custom type! returns luxon DateTime object
duration: types.number,
period: types.maybe(types.enumeration([
'once', 'fortnight', 'month', 'threeweekly', '', 'day', 'week', 'finite'
])),
provider: types.maybe(types.reference(Provider)),
changeRequests: types.array(ChangeRequest),
cancelled: types.boolean,
chargeForCancellation: types.boolean
})
.views(self => ({
get pendingChangeRequest() {
return self.changeRequests.find(cr => cr.providerResponse === null)
},
get endTime() {
return self.startTime.plus({ seconds: self.duration })
},
get inProgress() {
const now = DateTime.local()
return self.startTime <= now && now <= self.endTime
}
}))
const ProvidersStore = types
.model('ProvidersStore', {
collection: types.map(Provider)
})
.actions(self => ({
put(data) {
const { customerRepeatPrice, ...rest } = data
// we can normalize data in one place!
const provider = {
...rest,
id: String(data.id)
}
if (customerRepeatPrice) {
provider.customerRepeatPrice = Number(customerRepeatPrice)
}
return self.collection.put(provider)
}
}))
const store = klass => types.optional(klass, () => klass.create())
const AppStore = types
.model('AppStore', {
loading: types.optional(types.boolean, false),
sidebar: types.optional(types.boolean, false),
session: store(SessionStore),
chat: store(ChatStore),
bookings: store(BookingsStore),
events: store(EventsStore),
providers: store(ProvidersStore),
addresses: store(AddressesStore),
requests: store(RequestsStore),
messages: store(MessagesStore),
candidates: store(CandidatesStore),
pages: types.optional(
types.model({
dashboard: store(DashboardPageStore),
events: store(EventsPageStore),
event: store(EventPageStore),
messages: store(MessagesPageStore),
request: store(RequestPageStore),
conversation: store(ConversationPageStore),
booking: store(BookingPageStore)
}), {}
)
})
Generator functions wrapped in flow() function.
Have all the benefits of regular actions while also supporting asynchronous flows like API calls.
You can think of their syntax as await/async where instead of async you use the keyword yield.
const ProvidersStore = types
.model('ProvidersStore', {
collection: types.map(Provider)
})
.actions(self => ({
// nothing scary here!
fetch: flow(function*(id) {
const { data: { provider } } = yield fetchProvider(id)
self.collection.put(provider)
})
}))
Model can be identified as unique per entire tree using identifiers.
Identifiers can then be referenced using reference / safeReference.
This allows us to store the data in one source of truth and then reference particular instances from within the entire app.
const Candidate = types.model('Candidate', {
id: types.identifier,
firstname: types.string,
price: types.integer
})
const CandidateStore = types.model('CandidateStore', {
collection: types.map(Candidate)
}).actions(self => ({
fetch: flow(function*(id) {
if(!self.collection.has(id)) {
const data = yield getCandidateFromApi(id)
self.collection.put(data)
}
return self.collection.get(id)
})
})
const CandidatePageStore = types.model(
'CandidatePageStore', {
currentCandidate: types.reference(Candidate)
}).actions(self => ({
load: flow(function*(id) {
const appStore = getRoot(self)
self.currentCandidate = yield appStore.candidates.fetch(id)
})
})
const AppStore = types.model('AppStore', {
candidates: types.optional(
CandidateStore,
() => CandidateStore.create
),
candidatesPage: types.optional(
CandidatePageStore,
() => CandidatePageStore.create
)
})
const Page = types.model('Page', {
// you can keep an array of simple types
ids: types.array(types.integer),
// it's also possible to keep an array of references
// this pattern is very useful if you separate object stores and page stores
candidates: types.array(types.reference(Candidate))
})
Arrays can be used to store an array of other strongly typed properties.
It can be also used to store arrays of references.
Maps can be used to store key => value observable Maps.
You operate on them like on JS maps (get, set, has, entries etc.)
They also have useful helper method put(Model) which saves the model in the same key as it's identifier.
const ProvidersStore = types
.model('ProvidersStore', {
collection: types.map(Provider)
})
.actions(self => ({
put(data) {
const { customerRepeatPrice, ...rest } = data
// we can normalize data in one place!
const provider = {
...rest,
id: String(data.id)
}
if (customerRepeatPrice) {
provider.customerRepeatPrice = Number(customerRepeatPrice)
}
return self.collection.put(provider)
}
}))
optional utility type can be used to apply a default value to the property when creating a new instance on a Model.
const Page = types.model('Page', {
// we can pass simple values like string, number, array, etc
loading: types.optional(types.boolean, false),
// we can also provide a function that will be evaluated during initialization
createdAt: types.optional(types.Date, () => new Date()),
// this can be useful when creating stores
eventStore: types.optional(EventStore, () => EventStore.create()),
// we use this pattern so much we wrote a function for it:
// const store = klass => types.optional(klass, () => klass.create())
})
maybe type can be used to allow the property to be undefined. maybeNull does the same but with null.
const Page = types.model('Page', {
// this will allow undefined value
firstName: types.maybe(types.string),
// this will allow null as value
selectedId: types.maybeNull(types.integer)
})
enumeration allows creating a set of values that can be passed to the property
const User = types.model('User', {
accountType: types.enumeration(['customer', 'provider', 'admin'])
})
frozen can be used for values that will never change. It can be used for storing nested JSON structures that cannot be (or cannot be easily) strongly typed
const Request = types.model('Request', {
bodyJson: types.frozen
})
late can be used to define a type that will be resolved during initialization.
This should be used only to avoid having circular dependencies in code!
// depending on the loading order, one of those during code initialization will receive
// an empty object instead of the imported file
// Event.js
import User from './User.js'
const Event = types.model('Event', {
owner: types.late(() => User)
})
// User.js
import Event from './Event.js'
const User = types.model('User', {
events: types.array(Event)
})
custom can be used to create a custom type to store a complex object or create an instance of it based on given value from the snapshot.
import { types } from 'mobx-state-tree'
import { DateTime } from 'luxon'
export const dateTimeType = types.custom({
name: 'DateTime',
fromSnapshot(value) {
return DateTime.fromISO(value)
},
toSnapshot(value) { // must be able to serialize to JSON
return value.toISO()
},
isTargetType(value) {
return value instanceof DateTime
},
getValidationMessage(snapshot) {
return DateTime.fromISO(snapshot).invalidReason || ''
}
})
Every function used on types.model (like props, views, actions etc.) creates a new class. You can create subtypes by extending the base model and store more data in them.
const DetailedEvent = Event.named('DetailedEvent').props({
address: types.maybe(types.reference(Address)),
booking: types.maybe(types.reference(Booking)),
customerTotal: types.integer
})
When using references you may want to create a union type. It will create a type that will infer the correct type from given input data and return proper instance.
const EventOrDetailedEvent = types.union(
Event,
DetailedEvent
)
const EventStore = types.model('EventStore', {
// you can now put both Event and DetailedEvent in this map!
// remember: identifiers of all models inside union type must always have unique identifiers
events: types.map(EventOrDetailedEvent)
})
Union types can be provided a dispatcher. It will be called to automatically infer the correct type when creating a new instance of a Model.
const EventOrDetailedEvent = types.union(
{
dispatcher: snapshot => (snapshot.booking || snapshot.address ? DetailedEvent : Event)
},
Event,
DetailedEvent
)
// this will return Event
const event = EventOrDetailedEvent.create({})
// this will return DetailedEvent
const event = EventOrDetailedEvent.create({
booking: Booking.create({
frequency: 'once'
})
})
Michał Matyas
@nerdblogpl