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 reachitemsproperty and one more until you reachPollDraftitself, and assume that the returned parent is of typePollDraftModel. -
pollDraftParent.removeChoice(cast(self))usescasthelper to tell typescript thatselfis indeed of typePollDraftChoiceModel. Why is it necessary? The problem is thatselfhere is of type what was before views and actions are applied, which means at that pointselfis actually not of typePollDraftChoiceModel, sopollDraftParent.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 useobserveraround as many components as possible, which means hooks can't be used almost anywhere, -
injectfunction 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 contextprovider to pass down the store(s), - access the store using
useContexthook with a selector, alternatively inject the necessary stores with a customuseInjecthook based on theuseContexthook, - wrap components with
observerfrommobx-react-liteto 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)
Hey Margarita, thanks for your post. Learned a lot.
Btw, I still don't understand what you said,
useInjectis better thanuseStorewhen using complicated store. UseuseStorewe still can get what we want.Hey!
Great to hear it was helpful :)
When I implemented the
useInjecthook, 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,
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 toreduxand itsmapStateToProps, I was trying to make it more comfortable for myself to develop withmstby using familiar concepts.There is even a discussion about creating a similar
useInjecthook 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 omittingmapStoreparameter in the hook. On my way to fixing this, thanks a lot for making me notice the error! :)Thanks for your detailed explanation!
useInjectis a bit complicate to me, so I'm sticking to useuseStore:-)And another thing I found when I try your great example is enhanced the
useStorewith types:to
Then it will be more convenience to use
useStorewith ts autocomplete feature!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?
Hey! Thank you for your feedback, glad that the article helped you :)
I haven't had time to look into side effects with
mstyet, but it is definitely on my todo list ;)thank you for this great article.
The same approach can be used for react native as well?
Thanks for the feedback!
I am not sure about
react-nativeto be honest, haven't worked with it, but I think it should be the same, since it is tillreact:)It is actually the same :)
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.