DEV Community

Margarita Krutikova
Margarita Krutikova

Posted on • Updated on

How to: mobx-state-tree + react + typescript

This is a walkthrough on how to get a full setup with mobx-state-tree and react in a CRA app with typescript. This guide doesn't focus too much on the theory or how things work under the hood and mostly includes practical examples (code!) on how to make things work.

I have been mostly using redux in all of my work and side projects, and eventually got curios about the other side of the state management world with mobx and decided to jump right into mobx-state-tree.

Trying to make mobx-state-tree work in react with typescript appeared to be quite a struggle. Especially making everything properly typed (no cheating with any!) in Typescript was a challenge, so when eventually everything fell in place I thought I would share my setup in order to (hopefully) make someone else's life easier :)

The application I build is a simple poll maker that allows to create a new poll, publish it, view and delete published polls. The source code with a cute little demo is available on my github.

Here are the quick links to jump to directly if you have a particular problem that is covered:

Setup stores in mobx-state-tree

I started developing my app with designing stores of the domain area in mobx-state-tree and was immediately faced with the following "how-to"s:

  • how to create a base model and use composition to extend it with properties and functionality in different stores,
  • how to create a store with a nested list of items representing another model and perform CRUD operations on it,
  • how to create a root store composing all the other domain stores,
  • how to communicate between stores.

I figured those might be common problems when designing stores for any domain area, so I will go through them in more detail and show my solutions.

In my poll-maker app there is going to be a base model PollBase, a store responsible for creating a new poll PollDraft, a model for a published poll PublishedPoll and a store for published polls PublishedPolls.

Create a base model

Before we start, install the necessary dependencies:

yarn add mobx mobx-state-tree

Now let's create a base model for the domain object poll, which will have a poll question and a list of choices, and abase model for choice with a string property and id:

import { types } from "mobx-state-tree"

const PollChoiceBase = types.model("PollChoiceBase", {
  id: types.identifier,
  value: types.optional(types.string, "")
})

const PollBase = types.model("PollBase", {
  question: "",
  choices: types.optional(types.array(PollChoiceBase), [])
})

Use composition to create domain stores

A poll that is being edited (let's call it a draft poll) and not yet published will have the same properties as PollBase, but also actions to edit those properties. Similar, a choice of a draft poll will have the same shape as PollChoiceBase with an action to update it:

const PollDraftChoice = PollChoiceBase.actions(self => ({
  setChoice(choice: string) {
    self.value = choice
  }))

const PollDraft = types
  .compose(PollBase,
    types.model({
      choices: types.optional(types.array(PollDraftChoice), [])
    })
  )
  .actions(self => ({
    setQuestion(question: string) {
      self.question = question
    }
}))

A published poll can no longer be edited, so it won't have editing actions but it needs an extra property id to be able to find it or create an external link to it:

const PublishedPoll = types.compose(
  PollBase,
  types.model({
    id: types.identifier
  })
)

CRUD on models in a nested list

A draft poll has a list of choices, that can be added, edited and removed. Currently we have an action to update a choice (setChoice), but no actions to remove an existing choice or add a new one.

Here adding is rather trivial, but removal is a bit tricky. We want to be able to use choice.remove() somewhere in a react component, but actions can only modify the model they belong to or their children, so a choice can't simply remove itself and can only be removed by its parent PollDraft since it "owns" the list of choices. This means PollDraftChoice model will need a remove action which will delegate its removal to PollDraft, which we can retrieve via getParent helper from mobx-state-tree.

Here is the code (I use shortid to generate unique ids):

import { destroy, getParent, Instance, cast } from "mobx-state-tree"

// Instance is a typescript helper that extracts the type of the model instance
type PollDraftChoiceModel = Instance<typeof PollDraftChoice>
type PollDraftModel = Instance<typeof PollDraft>

const PollDraftChoice = PollChoiceBase.actions(self => ({
  ...
  remove() {
    const pollDraftParent = getParent<PollDraftModel>(self, 2)
    pollDraftParent.removeChoice(cast(self))
  }
}))

const PollDraft = types.compose(...)
  .actions(self => ({
    ...
    addChoice(choice: string) {
      self.choices.push({ id: shortid(), value: choice })
    },
    removeChoice(choiceToRemove: PollDraftChoiceModel) {
      destroy(choiceToRemove)
    }
}))

Here is what is happening inside PollDraftChoice:

  • getParent<PollDraftModel>(self, 2) means fetch parent 2 levels up - one until you reach items property and one more until you reach PollDraft itself, and assume that the returned parent is of type PollDraftModel.
  • pollDraftParent.removeChoice(cast(self)) uses cast helper to tell typescript that self is indeed of type PollDraftChoiceModel. Why is it necessary? The problem is that self here is of type what was before views and actions are applied, which means at that point self is actually not of type PollDraftChoiceModel, so pollDraftParent.removeChoice(self) won't compile in TS.

Convert between models

Let's create our second domain store to keep track of published polls:

import { types, Instance, getSnapshot } from "mobx-state-tree"

type PublishedPollModel = Instance<typeof PublishedPoll>
type PollDraftModel = Instance<typeof PollDraft>

export const PublishedPolls = types
  .model({
    polls: types.optional(types.array(PublishedPoll), [])
  })
  .actions(self => ({
    publishDraft(pollDraft: SnapshotIn<PollDraftModel>) {
      const pollToPublish = { ...pollDraft, id: shortid() }
      self.polls.push(pollToPublish)
    }
  }))

Here publishDraft takes in a snapshot of a poll draft. Snapshot in mobx-state-tree is a plain object stripped from all type information and actions and can be automatically converted to models.

So why does publishDraft need to take in a snapshot and not just PollDraftModel? That's because an instance of PollDraftModel can't be converted to a published poll since it will have extra actions that aren't compatible with PublishedPollModel, and will cause a runtime exception. So, by specifying SnapshotIn<PollDraftModel> we explicitly say that we want the raw data that exists on PollDraftModel.

Next problem is that publishDraft action has to be called somewhere from outside, either from the PollDraft store or from some kind of RootStore. Let's see how we can make that happen and establish some communication between the two stores.

Root store

Let's create a root store to combine all stores used in the app: PollDraft and PublishedPolls:

type RootStoreModel = Instance<typeof RootStore>

const RootStore = types.model("RootStore", {
  pollDraft: PollDraft,
  publishedPolls: PublishedPolls
})

Communicate between stores

One way of communicating between stores, is to use getRoot from mobx-state-tree to fetch the root store and from there get the necessary store, or use getParent to traverse the tree. This works fine for tightly coupled stores (like PollDraft and PollDraftChoice), but won't scale if used in more decoupled stores.

One way to enable store communication is to make use of getEnv function that can inject environment specific data when creating a state tree (from mobx-state-tree docs). So we can just inject a newly created store into the whole state tree. One caveat here is that the environment can't be passed directly into one of the child stores and needs to be passed into the root store, otherwise you get this error:

Error: [mobx-state-tree] A state tree cannot be made part of another state tree 
as long as their environments are different.

Let's create a function called createStore, similar to redux's configureStore, that would create all individual stores, create the environment and assemble them all together in one root store. The environment will have only one property of PublishedPolls store since it needs to be accessed from PollDraft when publishing a poll draft:

type RootStoreEnv = {
  publishedPolls: PublishedPollsModel
}

const createStore = (): RootStoreModel => {
  const publishedPolls = PublishedPolls.create()
  const pollDraft = PollDraft.create()

  const env: RootStoreEnv = { publishedPolls }

  return RootStore.create({ pollDraft, publishedPolls }, env)
}

Now, PolLDraft store can define a publish action and call publishDraft on publishedPolls:

import { types, getEnv, getSnapshot } from "mobx-state-tree"

const PollDraft = types
  .compose(...)
  .actions(self => ({
    ...
    publish() {
      const snapshot = getSnapshot(self)

      const env = getEnv<RootStoreEnv>(self)
      env.publishedPolls.publishDraft(snapshot)
    }
  }))

Connect to redux devtools

We will use connectReduxDevtools middleware from the package mst-middlewares that will connect the state tree to the redux devtools (more info and configuration options available in the docs). In order to setup the connection we will use a monitoring tool remotedev. Install the packages first:

yarn add --dev remotedev mst-middlewares

and add the following code after the store creation:

import { createStore } from "../stores/createStore"
import { connectReduxDevtools } from "mst-middlewares"

const rootStore = createStore()

connectReduxDevtools(require("remotedev"), rootStore)

Connect react to mobx

The part I struggled most with is how to connect react to mobx and start using stores in my components. The idea here is that react components need to become "reactive" and start tracking observables from the stores.

Why NOT mobx-react

The most common way to achieve this is by using mobx-react which provides observer and inject functions, where observer is wrapped around components to make them react to changes and re-render and inject just injects stores into components. However, I wouldn't recommend using this library because:

  • when using observer, the component loses the ability to use hooks because it gets converted to a class, more on this here. And the docs recommend in the best practices to use observer around as many components as possible, which means hooks can't be used almost anywhere,
  • inject function is quite compilcated and doesn't work well with typescript (see github issue), requiring all stores to be marked as optional and then using ! to indicate that they actually exist.

mobx-react-lite to the rescue

Luckily there is another library, mobx-react-lite, which is built with hooks and provides observer wrapper. One thing worth mentioning, observer doesn't support classes, but there is a dedicated component Observer that can be wrapped around parts of jsx in render in class components.

It is easy to get confused with this library since it provides a lot of hooks like useObservable, useComputed etc. that are going to be deprecated according to the docs. Instead here is a recommended way, that we are going to follow:

  • use react context provider to pass down the store(s),
  • access the store using useContext hook with a selector, alternatively inject the necessary stores with a custom useInject hook based on the useContext hook,
  • wrap components with observer from mobx-react-lite to subscribe to changes.

So let's install the library:

yarn add mobx-react-lite

Context provider to pass store

First, let's create context StoreContext, that will later receive the root store as its value, and export provider and a custom hook for accessing the context value:

const StoreContext = createContext<RootStoreModel>({} as RootStoreModel)

export const useStore = () => useContext(StoreContext)
export const StoreProvider = StoreContext.Provider

And then create the root store with createStore and send it into StoreProvider which we wrap around App:

import { StoreProvider } from "./StoreProvider"
import { createStore } from "../stores/createStore"

const rootStore = createStore()

const Root: React.FunctionComponent<{}> = () => (
  <StoreProvider value={rootStore}>
    <App />
  </StoreProvider>
)

Custom hook to inject stores

It is possible to use the useStore hook directly to access the root store and get the necessary data from it, like this:

const { pollDraft } = useStore()

I also implemented a useInject hook that takes in a mapping function and returns a mapped object, similar to how it is done in redux with mapStateToProps. This hook is somewhat close to the idea of custom inject with a mapper function, but with hooks. So if you have a more complicated app with lots of things in your store, you might want to get only the things you want and not care about the rest.

In its simplest form, useInject hook might look like this:

export type MapStore<T> = (store: RootStoreModel) => T

const useInject = <T>(mapStore: MapStore<T>) => {
  const store = useStore()
  return mapStore(store)
}

The PollDraft component would then use useInject to access pollDraft store from the root store:

import { observer } from "mobx-react-lite"
import { RootStoreModel } from "../stores/RootStore"
import useInject from "../hooks/useInject"

const mapStore = (rootStore: RootStoreModel) => ({ pollDraft: rootStore.pollDraft })

const PollDraft: React.FunctionComponent<{}> = observer(() => {
  const { pollDraft } = useInject(mapStore)

  return (
    <div>
      <h1>Create a new poll</h1>
      <input
        value={pollDraft.question}
        onChange={e => pollDraft.setQuestion(e.target.value)}
      />
      <button onClick={pollDraft.publish}>Publish</button>
    </div>
  )
})

This is especially useful if mapStore function is more complicated and involves combining data and actions from several stores.


At this point I felt like I covered the basics and created a setup that I could continue building upon or use it as a boilerplate for projects with a similar stack. The source code can be found on my github.

I hope this walkthrough was useful and you found something that helped you in your projects. Would love to hear your feedback on what you think was helpful or share your own experience with mobx-state-tree and react in typescript!

Top comments (10)

Collapse
 
havef profile image
HaveF

Hey Margarita, thanks for your post. Learned a lot.

Btw, I still don't understand what you said, useInject is better than useStore when using complicated store. Use useStore we still can get what we want.

const store = useStore()
const poll..... = store.pollDraft.......
Enter fullscreen mode Exit fullscreen mode
Collapse
 
margaretkrutikova profile image
Margarita Krutikova • Edited

Hey!
Great to hear it was helpful :)

When I implemented the useInject hook, I was thinking about having something similar to customizing inject with a mapper function, but with hooks. So if you have a more complicated app with lots of things in your store, or if you need to combine data from several stores, you might want to just extract all the data your component needs in a mapper function and then not care about how you get that data from the stores in your component.

For example,

const mapStore = ({ publishedPolls, participants }: RootStoreModel) => ({
 polls: publishedPolls.polls,
 totalNumberOfParticipants: participants.totalNumber
})

// in the component
const { polls, totalNumberOfParticipants } = useInject(mapStore)
...

Then you abstract away some details of your store implementation from your component's code. But I agree that it doesn't seem like a big problem to just use useStore, and since I am really used to redux and its mapStateToProps, I was trying to make it more comfortable for myself to develop with mst by using familiar concepts.

There is even a discussion about creating a similar useInject hook in this github issue.

However, when I tried the example above, I noticed an error in my implementation of useInject, which makes it confuse the return type from the mapper function because of this: (mapStore || defaultMapStore)(store), which is supposed to allow omitting mapStore parameter in the hook. On my way to fixing this, thanks a lot for making me notice the error! :)

Collapse
 
havef profile image
HaveF

Thanks for your detailed explanation!

useInject is a bit complicate to me, so I'm sticking to use useStore :-)

And another thing I found when I try your great example is enhanced the useStore with types:

export const useStore = () => useContext(StoreContext)

Enter fullscreen mode Exit fullscreen mode

to

export const useStore: () => TRootStoreModel = () => useContext(StoreContext) as TRootStoreModel

Enter fullscreen mode Exit fullscreen mode

Then it will be more convenience to use useStore with ts autocomplete feature!

Collapse
 
timnhe profile image
timnhe

Hey Margarita, thanks so much for this amazing article, I'm seeing that my knowledge on mst is a little rusty and this lecture helps me to be updated on it

Looking forward for more publications like this, for example, how do you organize remote side effects?

Collapse
 
margaretkrutikova profile image
Margarita Krutikova

Hey! Thank you for your feedback, glad that the article helped you :)

I haven't had time to look into side effects with mst yet, but it is definitely on my todo list ;)

Collapse
 
devozs profile image
Oz Shemesh

thank you for this great article.
The same approach can be used for react native as well?

Collapse
 
margaretkrutikova profile image
Margarita Krutikova

Thanks for the feedback!
I am not sure about react-native to be honest, haven't worked with it, but I think it should be the same, since it is till react :)

Collapse
 
rdewolff profile image
Rom

It is actually the same :)

Collapse
 
bfeeley profile image
Brian Feeley • Edited

Hi Margarita! Thanks for the post. Super helpful. Question .. how would you go about mocking the provider for tests and Storybook integration?

Some comments may only be visible to logged-in visitors. Sign in to view all comments.