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 reachitems
property and one more until you reachPollDraft
itself, and assume that the returned parent is of typePollDraftModel
. -
pollDraftParent.removeChoice(cast(self))
usescast
helper to tell typescript thatself
is indeed of typePollDraftChoiceModel
. Why is it necessary? The problem is thatself
here is of type what was before views and actions are applied, which means at that pointself
is 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 useobserver
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 customuseInject
hook based on theuseContext
hook, - wrap components with
observer
frommobx-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)
Hey Margarita, thanks for your post. Learned a lot.
Btw, I still don't understand what you said,
useInject
is better thanuseStore
when using complicated store. UseuseStore
we still can get what we want.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,
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 toredux
and itsmapStateToProps
, I was trying to make it more comfortable for myself to develop withmst
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 omittingmapStore
parameter in the hook. On my way to fixing this, thanks a lot for making me notice the error! :)Thanks for your detailed explanation!
useInject
is 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
useStore
with types:to
Then it will be more convenience to use
useStore
with 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
mst
yet, 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-native
to 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.