Skip to content

Latest commit

Β 

History

History
475 lines (351 loc) Β· 14.2 KB

actions.md

File metadata and controls

475 lines (351 loc) Β· 14.2 KB
title sidebar_label hide_title
Updating state using actions
Actions
true
<script async type="text/javascript" src="//cdn.carbonads.com/carbon.js?serve=CEBD4KQ7&placement=mobxjsorg" id="_carbonads_js"></script>

Updating state using actions

Usage:

  • action (annotation)
  • action(fn)
  • action(name, fn)

All applications have actions. An action is any piece of code that modifies the state. In principle, actions always happen in response to an event. For example, a button was clicked, some input changed, a websocket message arrived, etc.

MobX requires that you declare your actions, although makeAutoObservable can automate much of this job. Actions help you structure your code better and offer the following performance benefits:

  1. They are run inside transactions. No observers will be updated until the outer-most action has finished, guaranteeing that intermediate or incomplete values produced during an action are not visible to the rest of the application until the action has completed.

  2. By default, it is not allowed to change the state outside of actions. This helps to clearly identify in your code base where the state updates happen.

The action annotation should only be used on functions that intend to modify the state. Functions that derive information (performing lookups or filtering data) should not be marked as actions, to allow MobX to track their invocations. action annotated members will be non-enumerable.

Examples

import { makeObservable, observable, action } from "mobx"

class Doubler {
    value = 0

    constructor(value) {
        makeObservable(this, {
            value: observable,
            increment: action
        })
    }

    increment() {
        // Intermediate states will not become visible to observers.
        this.value++
        this.value++
    }
}
import { makeAutoObservable } from "mobx"

class Doubler {
    value = 0

    constructor(value) {
        makeAutoObservable(this)
    }

    increment() {
        this.value++
        this.value++
    }
}
import { makeObservable, observable, action } from "mobx"

class Doubler {
    value = 0

    constructor(value) {
        makeObservable(this, {
            value: observable,
            increment: action.bound
        })
    }

    increment() {
        this.value++
        this.value++
    }
}

const doubler = new Doubler()

// Calling increment this way is safe as it is already bound.
setInterval(doubler.increment, 1000)
import { observable, action } from "mobx"

const state = observable({ value: 0 })

const increment = action(state => {
    state.value++
    state.value++
})

increment(state)
import { observable, runInAction } from "mobx"

const state = observable({ value: 0 })

runInAction(() => {
    state.value++
    state.value++
})

Wrapping functions using action

To leverage the transactional nature of MobX as much as possible, actions should be passed as far outward as possible. It is good to mark a class method as an action if it modifies the state. It is even better to mark event handlers as actions, as it is the outer-most transaction that counts. A single unmarked event handler that calls two actions subsequently would still generate two transactions.

To help create action based event handlers, action is not only an annotation, but also a higher order function. It can be called with a function as an argument, and in that case it will return an action wrapped function with the same signature.

For example in React, an onClick handler can be wrapped as below.

const ResetButton = ({ formState }) => (
    <button
        onClick={action(e => {
            formState.resetPendingUploads()
            formState.resetValues()
            e.stopPropagation()
        })}
    >
        Reset form
    </button>
)

For debugging purposes, we recommend to either name the wrapped function, or pass a name as the first argument to action.

**Note:** actions are untracked

Another feature of actions is that they are untracked. When an action is called from inside a side effect or a computed value (very rare!), observables read by the action won't be counted towards the dependencies of the derivation

makeAutoObservable, extendObservable and observable use a special flavour of action called autoAction, that will determine at runtime if the function is a derivation or action.

action.bound

Usage:

  • action.bound (annotation)

The action.bound annotation can be used to automatically bind a method to the correct instance, so that this is always correctly bound inside the function.

**Tip:** use `makeAutoObservable(o, {}, { autoBind: true })` to bind all actions and flows automatically
import { makeAutoObservable } from "mobx"

class Doubler {
    value = 0

    constructor(value) {
        makeAutoObservable(this, {}, { autoBind: true })
    }

    increment() {
        this.value++
        this.value++
    }

    *flow() {
        const response = yield fetch("http://example.com/value")
        this.value = yield response.json()
    }
}

runInAction

Usage:

  • runInAction(fn)

Use this utility to create a temporarily action that is immediately invoked. Can be useful in asynchronous processes. Check out the above code block for an example.

Actions and inheritance

Only actions defined on prototype can be overriden by subclass:

class Parent {
    // on instance
    arrowAction = () => {}

    // on prototype
    action() {}
    boundAction() {}

    constructor() {
        makeObservable(this, {
            arrowAction: action
            action: action,
            boundAction: action.bound,
        })
    }
}
class Child extends Parent {
    // THROWS: TypeError: Cannot redefine property: arrowAction
    arrowAction = () => {}

    // OK
    action() {}
    boundAction() {}

    constructor() {
        super()
        makeObservable(this, {
            arrowAction: override,
            action: override,
            boundAction: override,
        })
    }
}

To bind a single action to this, action.bound can be used instead of arrow functions.
See subclassing for more information.

Asynchronous actions

In essence, asynchronous processes don't need any special treatment in MobX, as all reactions will update automatically regardless of the moment in time they are caused. And since observable objects are mutable, it is generally safe to keep references to them for the duration of an action. However, every step (tick) that updates observables in an asynchronous process should be marked as action. This can be achieved in multiple ways by leveraging the above APIs, as shown below.

For example, when handling promises, the handlers that update state should be wrapped using action or be actions, as shown below.

Promise resolution handlers are handled in-line, but run after the original action finished, so they need to be wrapped by action:

import { action, makeAutoObservable } from "mobx"

class Store {
    githubProjects = []
    state = "pending" // "pending", "done" or "error"

    constructor() {
        makeAutoObservable(this)
    }

    fetchProjects() {
        this.githubProjects = []
        this.state = "pending"
        fetchGithubProjectsSomehow().then(
            action("fetchSuccess", projects => {
                const filteredProjects = somePreprocessing(projects)
                this.githubProjects = filteredProjects
                this.state = "done"
            }),
            action("fetchError", error => {
                this.state = "error"
            })
        )
    }
}

If the promise handlers are class fields, they will automatically be wrapped in action by makeAutoObservable:

import { makeAutoObservable } from "mobx"

class Store {
    githubProjects = []
    state = "pending" // "pending", "done" or "error"

    constructor() {
        makeAutoObservable(this)
    }

    fetchProjects() {
        this.githubProjects = []
        this.state = "pending"
        fetchGithubProjectsSomehow().then(this.projectsFetchSuccess, this.projectsFetchFailure)
    }

    projectsFetchSuccess = projects => {
        const filteredProjects = somePreprocessing(projects)
        this.githubProjects = filteredProjects
        this.state = "done"
    }

    projectsFetchFailure = error => {
        this.state = "error"
    }
}

Any steps after await aren't in the same tick, so they require action wrapping. Here, we can leverage runInAction:

import { runInAction, makeAutoObservable } from "mobx"

class Store {
    githubProjects = []
    state = "pending" // "pending", "done" or "error"

    constructor() {
        makeAutoObservable(this)
    }

    async fetchProjects() {
        this.githubProjects = []
        this.state = "pending"
        try {
            const projects = await fetchGithubProjectsSomehow()
            const filteredProjects = somePreprocessing(projects)
            runInAction(() => {
                this.githubProjects = filteredProjects
                this.state = "done"
            })
        } catch (e) {
            runInAction(() => {
                this.state = "error"
            })
        }
    }
}
import { flow, makeAutoObservable, flowResult } from "mobx"

class Store {
    githubProjects = []
    state = "pending"

    constructor() {
        makeAutoObservable(this, {
            fetchProjects: flow
        })
    }

    // Note the star, this a generator function!
    *fetchProjects() {
        this.githubProjects = []
        this.state = "pending"
        try {
            // Yield instead of await.
            const projects = yield fetchGithubProjectsSomehow()
            const filteredProjects = somePreprocessing(projects)
            this.state = "done"
            this.githubProjects = filteredProjects
        } catch (error) {
            this.state = "error"
        }
    }
}

const store = new Store()
const projects = await flowResult(store.fetchProjects())

Using flow instead of async / await {πŸš€}

Usage:

  • flow (annotation)
  • flow(function* (args) { })

The flow wrapper is an optional alternative to async / await that makes it easier to work with MobX actions. flow takes a generator function as its only input. Inside the generator, you can chain promises by yielding them (instead of await somePromise you write yield somePromise). The flow mechanism will then make sure the generator either continues or throws when a yielded promise resolves.

So flow is an alternative to async / await that doesn't need any further action wrapping. It can be applied as follows:

  1. Wrap flow around your asynchronous function.
  2. Instead of async use function *.
  3. Instead of await use yield.

The flow + generator function example above shows what this looks like in practice.

Note that the flowResult function is only needed when using TypeScript. Since decorating a method with flow, it will wrap the returned generator in a promise. However, TypeScript isn't aware of that transformation, so flowResult will make sure that TypeScript is aware of that type change.

makeAutoObservable and friends will automatically infer generators to be flows. flow annotated members will be non-enumerable.

{πŸš€} **Note:** using flow on object fields `flow`, like `action`, can be used to wrap functions directly. The above example could also have been written as follows:
import { flow } from "mobx"

class Store {
    githubProjects = []
    state = "pending"

    fetchProjects = flow(function* (this: Store) {
        this.githubProjects = []
        this.state = "pending"
        try {
            // yield instead of await.
            const projects = yield fetchGithubProjectsSomehow()
            const filteredProjects = somePreprocessing(projects)
            this.state = "done"
            this.githubProjects = filteredProjects
        } catch (error) {
            this.state = "error"
        }
    })
}

const store = new Store()
const projects = await store.fetchProjects()

The upside is that we don't need flowResult anymore, the downside is that this needs to be typed to make sure its type is inferred correctly.

flow.bound

Usage:

  • flow.bound (annotation)

The flow.bound annotation can be used to automatically bind a method to the correct instance, so that this is always correctly bound inside the function. Similary to actions, flows can be bound by default using autoBind option.

Cancelling flows {πŸš€}

Another neat benefit of flows is that they are cancellable. The return value of flow is a promise that resolves with the value that is returned from the generator function in the end. The returned promise has an additional cancel() method that will interrupt the running generator and cancel it. Any try / finally clauses will still be run.

Disabling mandatory actions {πŸš€}

By default, MobX 6 and later require that you use actions to make changes to the state. However, you can configure MobX to disable this behavior. Check out the enforceActions section. For example, this can be quite useful in unit test setup, where the warnings don't always have much value.