Skip to main content

Root Stores

Overview

Usually an application has one or more store objects which are meant to represent the actual current state of such application. These objects are usually known as "root stores".

In the case of mobx-keystone, root stores are tree nodes (model instances or arrays / plain objects turned into tree nodes with toTreeNode) from where the rest of the application state will be stored in a tree-like structure. While it is not strictly necessary to mark these instances as root stores, doing so opens up some benefits:

  • Root stores allow the usage of the onAttachedToRootStore(rootStore) hook inside models.

You can think of a tree node marked as a root store as the "live tree" of your application state, meaning that any nodes attached to a root store are actually part of your running application, rather than a transient instance that might or might not end up as part of your actual application state.

Registering/unregistering a model instance as a root store is as simple as:

// given `myTodoList` is a model instance of a todo list ...
registerRootStore(myTodoList)

// unregistering
unregisterRootStore(myTodoList)

If you need to check whether a node is currently registered as a root store, use isRootStore(node).

onAttachedToRootStore

By registering the instance above the first thing that will happen is that the onAttachedToRootStore hook will be invoked for the todo list model as well as any submodels that it might contain. Additionally, any models that eventually get added to the tree will invoke such hook too. This life-cycle hook also supports optionally returning a disposer function, which will execute when the model instance has just left the root store tree or when the root store itself is unregistered.

This hook is a great place to actually register effects (e.g. MobX reaction, when, etc.), and its disposer is a great place to dispose of them.

Practical example

As a practical example, say that you have some kind of application user preferences that have to be saved to / loaded from local storage, but we also want to use the same model in a form to edit them.

First we need to define a model for the user preferences, as well as its desired side effects when it is part of the actual application state (when it is part of a root store tree) ...

type Theme = "light" | "dark"

@model("myApp/UserPreferences")
class UserPreferences extends Model({ theme: prop<Theme>().withSetter() }) {
// once we are part of the root store ...
onAttachedToRootStore() {
// every time the snapshot of the configuration changes
const reactionDisposer = reaction(
() => getSnapshot(this),
(sn) => {
// save the config to local storage
localStorage.setItem("myPreferences", JSON.stringify(sn))
},
{
// also run the reaction the first time
fireImmediately: true,
}
)

// when the model is no longer part of the root store stop saving
return () => {
reactionDisposer()
}
}
}

... we also need to model the root store of our application ...

@model("myApp/RootStore")
class RootStore extends Model({
userPreferences: prop<UserPreferences>().withSetter(),
}) {}

... then we will need some code to initialize our application, loading the preferences already stored in local storage ...

const myPreferencesRaw = localStorage.getItem("myPreferences")

// this will create a `UserPreferences` model instance, but won't save any changes yet
// since it is not yet part of a root store
// this means we can manipulate it without fear of overwriting the
// config in local storage
const myPreferences = myPreferencesRaw
? fromSnapshot(UserPreferences, JSON.parse(myPreferencesRaw))
: new UserPreferences({ theme: "light" })

... and finally creating the root store itself with the initial data ...

const myRootStore = new RootStore({ userPreferences: myPreferences })

// after this next function is called, `myPreferences` will become part of a root store
// and therefore start saving now and on changes
registerRootStore(myRootStore)
// the preferences get saved ...

Now we would like to have a form which will be able to edit a copy of the current user preferences ...

// we make a clone of the current preferences
const formPreferences = clone(myRootStore.userPreferences)
// since the clone is outside the root store it WON'T be auto-saved

// the form eventually makes changes ...
formPreferences.setTheme("dark")
// but that's ok, it is not auto-saved since it is not part of a root store,
// therefore living "outside" the actual application state

... but it should be saved once the save button is clicked:

myRootStore.setUserPreferences(formPreferences)

After that last line, the old preferences object (myPreferences) becomes detached from the root store tree, so it stops saving changes when its disposer runs. At the same time, the new preferences object (formPreferences) becomes part of the root store tree, runs the hook, and starts saving future changes.

This is why onAttachedToRootStore is such a good place to manage side effects.

Sharing contextual data

Although contexts are usually preferred for this case (see the contexts section), root stores can also hold global runtime environment data that should be shared across the application but does not need to be serialized. For example:

In reactive code, prefer getRootStore over getRoot when you need the application root store. Detached nodes can make getRoot(node) resolve to node itself.

@model("myApp/RootStore")
class RootStore extends Model({
currentUserId: prop("user-1"),
}) {
env!: {
environment: "development" | "staging" | "production"
releaseChannel: "internal" | "public"
enableDebugTools: boolean
}
}

const rootStore = new RootStore({})
rootStore.env = {
environment: "development",
releaseChannel: "internal",
enableDebugTools: true,
}
registerRootStore(rootStore)

// then on another model
@model("myApp/Profile")
class Profile extends Model({}) {
get shouldShowInternalFeatures() {
const rootStore = getRootStore<RootStore>(this)
return rootStore?.env.releaseChannel === "internal"
}

onAttachedToRootStore(rootStore) {
if (rootStore.env.environment !== "production" && rootStore.env.enableDebugTools) {
console.log("debug tools enabled?", rootStore.env.enableDebugTools)
}
}
}

getRootStore(node) is also usually preferable to manually walking to the root and then checking it yourself, since it directly returns the registered root store or undefined.