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,043