This is a walkthrough on how to get started with mobx-state-tree
and connect it to react
components in a CRA
app with typescript
.
The application I built is a simple poll maker that allows to create a new poll, publish it, view and delete published polls. Here is a little demo:
The full article is published on dev.to.
yarn
npm run start
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
.
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 a 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), [])
})
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 allowing to edit those properties. Similar, a choice of the 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 by its id:
const PublishedPoll = types.compose(
PollBase,
types.model({
id: types.identifier
})
)
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 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.
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
.
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
})
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.
Here is the code:
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)
}
}))
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)
The most common way to connect mobx
to reaect
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.
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.
The library 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.
First thing, install the library:
yarn add mobx-react-lite
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>
)
It is possible to use the useStore
hook direclty 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
. In its simplest form, useInject
hook might look like this:
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 compilcated and envolves combining data and actions from several stores.