A MobX-State-Tree shortcut for setter actions

MobX-State-Tree property setters are kind of annoying. Here’s a nice shortcut to create them automatically.

Jamon Holmgren
Red Shift

--

If you’ve used MobX-State-Tree (as we generally like to do at Infinite Red), you’ll know that you can’t assign to properties outside of an action.

import { types } from "mobx-state-tree"const UserModel = types.model("User", {
name: types.string,
age: types.number
})
const user = UserModel.create({ name: "Jamon", age: 40 })
user.name = "Joe" // error! must be in an action

The reason for this is to ensure that assignments are “batched” properly before you update your UI. It also makes sure that changes generate actions for onAction listeners properly.

It’s also problematic in async actions, as you can’t set properties directly in those either:

import { types } from "mobx-state-tree"const UserModel = types.model("User", {
name: types.string,
age: types.number
})
.actions(self => ({
async fetchData() {
const data = await getData()
self.name = data.name // NOPE! Not in an action anymore
},
}))
const user = UserModel.create({ name: "Jamon", age: 40 })
user.setName("Joe") // all good!

Generally speaking, the way we solve that is by making a setter action for each property:

import { types } from "mobx-state-tree"const UserModel = types.model("User", {
name: types.string,
age: types.number
})
.actions(self => ({
setName(newName: string) {
self.name = newName
},
setAge(newAge: number) {
self.age = newAge
}
}))
const user = UserModel.create({ name: "Jamon", age: 40 })
user.setName("Joe") // all good!

This is a bit tedious and feels like it shouldn’t be necessary. Not only that, but every time you add a new property, you have to add another setter action.

While you can turn off the action protection entirely, there’s a safer way to do this!

import { types} from "mobx-state-tree"const UserModel = types.model("User", {
name: types.string,
age: types.number
})
.actions(self => ({
setProp(field, newValue) {
self[field] = newValue;
}
}))
const user = UserModel.create({ name: "Jamon", age: 40 })
user.setProp("name", "Joe") // all good!
// but typescript thinks this is fine? uh-oh
user.setProp("age", "shouldn't work")

This works pretty well, but what about TypeScript protections? After all, we have different types for age and name.

Just use this:

import { types, SnapshotIn } from "mobx-state-tree"const UserModel = types.model("User", {
name: types.string,
age: types.number
})
.actions(self => ({
setProp<
K extends keyof SnapshotIn<typeof self>,
V extends SnapshotIn<typeof self>[K]
>
(field: K, newValue: V) {
self[field] = newValue;
}
}))
const user = UserModel.create({ name: "Jamon", age: 40 })
user.setProp("name", "Joe") // all good!
// typescript will error, like it's supposed to
user.setProp("age", "shouldn't work")

Try this out, and if you think it’s worthwhile enough, let me know on Twitter. Maybe we could include something like it in MobX-State-Tree core at some point!

Note: Timo Zöller published a very similar article about this a couple years ago, but I only found it after writing my version.

Note about Ignite: this helper will be included by default in Ignite v8 (code-named Maverick).

--

--

Co-founder & CTO @infinite_red. Lutheran, husband, dad to 4, React Native Radio podcast host, Twitch streamer, hockey goalie. Talking shop!