Reactive programming in mobx
Wait, what do you mean by "observable"?
Michał Matyas
@nerdblogpl
What is mobx?
observable
ACTION
computed
reaction / autorun
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
})
)
using observable instead of setstate
- Allows you to use local state in derived values and reactions
- Gives you easier API since setters are synchronous and predictable
- Makes keeping and changing local data (for ex. for forms) super easy
mobx is not magic
It simply attaches itself to getters and setters and then tracks them being called in a wrapped function (reaction)
view is just reflection of your data
mobx ensures there is never
an inconsistency between them
what is possible with reactive programming
you can use derivations to deduplicate the data
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
})
ALL DATA that changes in time is potentially an observable
like time itself
import { now } from 'mobx-utils'
class Clock extends React.Component {
render() {
return (
<div>
Right now it's {new Date(now()).toTimeString()}
</div>
)
}
}
like data streams
class AlertStore {
alerts = []
constructor() {
observe(this, 'alerts', () => {
this.alerts.forEach(text => {
Alert.alert(text) // 👋 React Native
})
this.alert.clear()
}
}
}
decorate(AlertStore, {
alerts: observable
})
like data streams
import { queueProcessor } from 'mobx-utils'
class AlertStore {
alerts = []
constructor() {
queueProcessor(this.alerts, () => {
Alert.alert(text) // 👋 React Native
})
}
}
decorate(AlertStore, {
alerts: observable
})
like promises
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>,
})
}
}
mobx-state-tree
mobx with structure
mobx-state-tree
is a strongly, dynAmically typed tree
types in mST
- string
- number
- integer
- boolean
- Date
- custom
Primitive
complex
- array
- map
- model
UTILITY
- optional
- maybe / maybeNull
- enumeration
- literal
- union
- refinement
- compose
- null / undefined
- late
- frozen
- reference / safeReference
- identifier / identifierNull
model type
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)
model type
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
}
}))
model type
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)
}
}))
model type
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)
}), {}
)
})
Asynchronous actions (flows)
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.
Asynchronous actions (flows)
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)
})
}))
iDENTIFIERS AND REFERENCES
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.
identifiers and references
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
)
})
COMPLEX TYPES - arrays
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.
complex types - Maps
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)
}
}))
complex types - Maps
UTILITY TYPES - optional
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)
})
UTILITY TYPES - maybe
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'])
})
UTILITY TYPES - enumeration
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
})
UTILITY 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)
})
UTILITY TYPES - late
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 || ''
}
})
UTILITY TYPES - custom
DO NOT USE MAYBE / MAYBENULL TO AVOID FILLING PROPERTIES YOU "DON'T FEEL LIKE FETCHING" OR "DON'T NEED RIGHT NOW"
Creating subtypes
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
})
composing subtypes
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)
})
composing subtypes
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'
})
})
but wait, there's more!
snapshots!
lifecycle hooks!
patches!
middlewares!
action tracking!
documentation:
Michał Matyas
@nerdblogpl
Reactive Programming in MobX: Wait, what do you mean by "observable"?
By Michał Matyas
Reactive Programming in MobX: Wait, what do you mean by "observable"?
- 1,025