loading...

Creating a MobX store from start to finish

shilangyu profile image Marcin Wojnarowski ・4 min read

MobX brings functional reactivity to JavaScript. It operates on 3 simple concepts:

  • there is state
  • state is modified by actions
  • state is observed by reactions

Today we'll be using all 3 of them by creating what is know as a 'store'. It will store some state and modify it by providing actions.

Let's assume we are creating a Web App and we want to store client configuration data: Theme, language

Setting up

We'll be using Typescript for some type-safety and handy decorators.

import { observable, configure } from 'mobx'

configure({ enforceActions: 'always' })

export type Theme = 'dark' | 'light'
export type Language = 'en' | 'fr'

export class ConfigStore {
    @observable language!: Language
    @observable theme!: Theme
}

We import the observable decorator from mobx to annotate our state as 'reactive' and we declare the ConfigStore with the appropriate state and types. We also configured MobX to enforce state changes to be done by actions only.

Using defaults

It is always a good idea to have some defaults set. For the theme we will do a match media query, for the language we will check the navigator.

@observable language: Language = /en/i.test(window.navigator.language)
    ? 'en'
    : 'fr'
@observable theme: Theme = window.matchMedia(`(prefers-color-scheme: dark)`)
    .matches
    ? 'dark'
    : 'light'

Creating actions

Action will be modifying our state, for that we import the action decorator and create the methods

import { action, observable, configure } from 'mobx'

// --snip--
    @action
    changeTheme = (theme: Theme) => {
        this.theme = theme
    }

    @action
    changeLanguage = (language: Language) => {
        this.language = language
    }
// --snip--

This might seem like boilerplate, but this ensures we always know where the state is modified. However you might find actions like 'setDark' or 'setFrench' more fitting to you.

Reactions

Let's explore the power of reactions by setting up some caching system for our store. So far, each time the store is loaded (for example on a page refresh) we lose all of our state, this would mean a user would have to set his preferred theme on every page refresh!

Firstly we'll import autorun from mobx, it accepts a function and then runs it every time some observable in it was changed. This is great!

import { autorun, action, observable, configure } from 'mobx'

Now back to our store, we will add 2 new methods, one for saving and one for loading the state. While saving is just deriving our state, loading is modifying it, therefore it has to be marked as an action.

    private save = () =>
        window.localStorage.setItem(
            ConfigStore.name,
            JSON.stringify({
                language: this.language,
                theme: this.theme
            })
        )

We mark it as private because it wont be used outside of the store. Every class/function has a static property name, in our case it is equal to 'ConfigStore', this will be the key to localStorage where we will be storing the cache. We handpick the state we wish to be saved, in this case language and theme. Then it is passed to a JSON.stringify to turn it into a string.

Loading is much simpler and wont have to be changed when you add new state properties:

    @action
    private load = () =>
        Object.assign(this, JSON.parse(window.localStorage.getItem(ConfigStore.name) || '{}'))

We retrieve the data from the cache, if it is empty we default to an empty object and we assign the result to this.

Let's now use the previously imported reaction

    constructor() {
        this.load()
        autorun(this.save)
    }

Yes, it is that simple, now your store is fully synced with the cache.

Lets inspect the lifespan of this store.

  1. the store is initialized
  2. the defaults are set
  3. this.load() is called, it syncs the store with the cache
  4. this.save is set to be auto-ran if any of the internal observables change
  5. state is changed by an action
  6. this.save is automatically ran because state has changed
  7. cache is synced with the store!

Full store:

import { action, autorun, configure, observable } from 'mobx'

configure({ enforceActions: 'always' })

export class ConfigStore {
    @observable language: Language = /en/i.test(window.navigator.language) ? 'en' : 'fr'
    @observable theme!: Theme = window.matchMedia(`(prefers-color-scheme: dark)`).matches
        ? 'dark'
        : 'light'

    constructor() {
        this.load()
        autorun(this.save)
    }

    private save = () =>
        window.localStorage.setItem(
            ConfigStore.name,
            JSON.stringify({
                language: this.language,
                theme: this.theme
            })
        )

    @action
    private load = () =>
        Object.assign(this, JSON.parse(window.localStorage.getItem(ConfigStore.name) || '{}'))

    @action
    changeLanguage = (language: Language) => {
        this.language = language
    }

    @action
    changeTheme = (theme: Theme) => {
        this.theme = theme
    }
}

Extra: using in React

While this store is agnostic to the framework you'll be using, I'll show you how to use it with React. MobX has become one of the most popular choices as a state-management library for React (which despite its name is not reactive).

First, assume the store is in stores/ConfigStore.ts. Now create stores/index.ts:

import { createContext } from 'react'
import { ConfigStore } from './ConfigStore'

export const configStore = createContext(new ConfigStore())

This initializes a store and turns it into a context.

React hooks have made working with MobX a boilerplate-free and type-safe experience. Lets use them:

App.tsx

import { observer } from 'mobx-react-lite'
import { useContext } from 'react'
import { configStore } from 'stores'

const App = observer(() => {
    const config = useContext(configStore)

    return (
        <>
            <div style={{ backgroundColor: config.theme === 'dark' ? '#000000' : '#ffffff' }}>
                <label for="theme">Choose Theme:</label>

                <select id="theme" onChange={e => config.changeTheme(e.target.value)}>
                    <option value="dark">Dark</option>
                    <option value="light">Light</option>
                </select>
            </div>
        </>
    )
})

export default App

Discussion

pic
Editor guide