lobx
v0.2.0
Published
lightweight reactive state management
Downloads
3,059
Readme
lobx
lobx
is a lighter alternative to a popular observable library mobx. It weights in at about half the size of mobx
while maintaining the majority of it's functionality but it also differs from how mobx
handles observables. Where as mobx
makes a deep copy of the data structure you're observing, lobx
creates a lazy proxy shell over the original data thus making it ideal for observing large objects and/or server responses. It also offers an alternative approach around asynchronous actions and introduces some performance escape hatches.
If you're new to mobx
then you can check out their excellent documentation. Some of the documentation here is taken directly from mobx
.
If you're already familiar with mobx
you can skip to the lobx vs mobx
part of the documentation.
Introduction
Anything that can be derived from the application state, should be. Automatically.
The philosophy behind lobx
is simple:
A quick example
So what does code that uses lobx
look like? That will all depend on how you want to model your state.
lobx
is not an opinionated state management library or framework. It provides the reactive primitives while giving you the freedom to model your application state however you see fit.
Here's an example of an object oriented approach using classes.
import React from "react";
import ReactDOM from "react-dom";
import { Observable } from "lobx";
import { observer } from "lobx-react";
// Model the application state.
class Timer extends Observable {
secondsPassed = 0;
increase() {
this.secondsPassed += 1;
}
reset() {
this.secondsPassed = 0;
}
// derived state
get minutesPassed() {
return Math.floor(this.secondsPassed / 60);
}
}
const myTimer = new Timer();
// Build a "user interface" that uses the observable state.
const TimerView = observer(({ timer }) => (
<button onClick={() => timer.reset()}>
Seconds passed: {timer.secondsPassed}, Minutes Passed: {timer.minutesPassed}
</button>
));
ReactDOM.render(<TimerView timer={myTimer} />, document.body);
// Update the 'Seconds passed: X' text every second.
setInterval(() => {
myTimer.increase();
}, 1000);
And here's an example without using classes, where the model is just a plain object and we define methods to mutate it.
import React from "react";
import ReactDOM from "react-dom";
import { observable } from "lobx";
import { observer } from "lobx-react";
// Model application state
const myTimer = observable({
secondsPassed: 0,
increase() {
this.secondsPassed += 1;
},
reset() {
this.secondsPassed = 0;
},
// derived state
get minutesPassed() {
return Math.floor(this.secondsPassed / 60);
},
});
// Build a "user interface" that uses the observable state.
const TimerView = observer(({ timer }) => (
<button onClick={() => timer.reset()}>
Seconds passed: {timer.secondsPassed}, Minutes Passed: {timer.minutesPassed}
</button>
));
ReactDOM.render(<TimerView timer={myTimer} />, document.body);
// Update the 'Seconds passed: X' text every second.
setInterval(() => {
timer.increase();
}, 1000);
Note: It is not required to have methods to mutate your observable objects. You can mutate them directly as long as strict actions are not enforced or use action
In Both cases the behavior is the same.
The observer
wrapper around the TimerView
React component will automatically detect that rendering
depends on the timer.secondsPassed
observable, even though this relationship is not explicitly defined. The reactivity system will take care of re-rendering the component when precisely that field is updated in the future.
The onClick
and setInterval
callbacks invoke an action that updates observable state.
Changes in the observable state are propagated precisely to all computations and side effects that depend on the changes being made.
Browser / Runtime Support
Should work in any environment that supports ES2015 and Proxies
lobx vs mobx
How lobx handles observability vs Mobx
Where as mobx
observes non-primitive values by first making a deep copy of them, lobx
is instead designed to provide an observable proxied "shell" over the original reference. There can only be one proxied "shell" over each unique reference, observing the same reference more than once will return the same observable "shell".
The differences in observability approaches between mobx
and lobx
is best demonstrated with the following code snippets:
// mobx example
import { observable } from "mobx";
const nonObservableObject = { value: { innerValue: 0 } };
const nonObservableArray = [nonObservableObject, nonObservableObject];
console.log(nonObservableArray[0] === nonObservableArray[1]); // true as they're both the same reference
const observableArray = observable(nonObservableArray);
console.log(observableArray[0] === observableArray[1]); // false , while they were the same reference in the non-observable array, mobx creates a new deep copy for each element making them unique
console.log(observableArray === observable(nonObservableArray)); // false, observing the same reference returns a new copy
observableArray.push("new value");
console.log(observableArray.length === nonObservableArray.length); // false, any mutation done to the observable copy will not be reflected in the original non-observed reference.
// lobx example
import { observable } from "lobx";
const nonObservableObject = { value: { innerValue: 0 } };
const nonObservableArray = [nonObservableObject, nonObservableObject];
console.log(nonObservableArray[0] === nonObservableArray[1]); // true as they're both the same reference
const observableArray = observable(nonObservableArray);
console.log(observableArray[0] === observableArray[1]); // true, lobx always returns the same observable for each unique references which allows us to preserve referential equality within an object
console.log(observableArray === observable(nonObservableArray)); // true, for the same reason as above
observableArray.push("new value");
console.log(observableArray.length === nonObservableArray.length); // true, since an observable in lobx is just a proxied "shell" over the source reference any mutation done to it will also be reflected in the source.
The approach taken by lobx
makes it a good choice for introducing observability to an existing code base. As you can observe existing data structures with practically no initialization over-head. The preservation of referential equality also reduces the risk of your application breaking from making an existing data structure observable. Even allowing you to observe objects with circular references.
Note: while mutating the observable reference will also affect the non-observable source, the opposite is also true. Except that any changes made directly to the non-observable source will not be visible to lobx
reactions. This is both a gotcha and a feature, as it allows for a performance escape-hatch to by-pass the overhead involved in mutating or reading the proxied observable by mutating/reading the source observable instead. lobx
then provides an api to signal to reactions that an observable has been mutated or read via forceChange
and forceObserve
.
Though it is unlikely you will ever need to do this in your application.
Further details can be found here
lobx async actions
mobx
has a separate concept for async actions called flow
. It requires developers to be familiar with generators instead of the more common async/await
paradigm for asynchronous actions. If using typescript it also requires the usage of a flowResult
wrapper function in order for typescript to infer the return type correctly. lobx
takes a simpler approach here and sticks with async/await
. All that is required is to wrap any awaited call with task
.
Examples:
mobx async example
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());
and here's the lobx
equivalent:
lobx async example
import { Observable, task } from "lobx";
class Store extends Observable {
githubProjects = [];
state = "pending";
// just a plain async function
async fetchProjects() {
this.githubProjects = [];
this.state = "pending";
try {
// notice the `task` wrapper to the right of the `await`
// this is all that's required to turn an `action` to an `async action`
const projects = await task(fetchGithubProjectsSomehow());
const filteredProjects = somePreprocessing(projects);
this.state = "done";
this.githubProjects = filteredProjects;
} catch (error) {
this.state = "error";
}
}
}
const store = new Store();
// no need for any additional wrappers here
const projects = await store.fetchProjects();
More details about async actions in lobx
can be found here
Performance escape hatches that are unique to lobx
When dealing with observable object proxies in either lobx
or mobx
there's an added overhead for every read and write operation that is performed on those objects. While this overhead is significant it rarely becomes a performance issue as in typical web applications data processing of state/domain objects is not the bottle neck. Yet there might be situations where heavy data manipulation is required and doing so on
an observable proxied object will be significantly slower then working with plain javascript objects.
lobx
offers a performance escape hatch in these situation. Since observables in lobx
are proxied shells over the original plain javascript object we can modify the object directly and then manually signal to lobx
that the object has been changed so that all reactions that depend on it can be ran. This can be achieved using the getObservableSource
and forceChange
apis.
example:
import { getObservableSource, forceChange } from "lobx";
// assuming `bigObj` is a large observable object
// get the original plain object source for this observable
const bigObjSource = getObservableSource(bigObj);
// perform expensive mutations on the plain javascript object (fast)
performExpensiveMutations(bigObjSource);
forceChange(bigObj); // manual signal to reactions that our object has changed and those reactions that depend on it need to re-run
There's also forceObserve
which is the read equivalent to forceChange
. It can be used when performing an expensive derivation within a reaction.
Other significant differences vs Mobx
WeakMap
,WeakSet
and evenDate
can be observableMap
andSet
are proxied and not their own class that rely on duck typing likemobx
. So things likeobservable(new Set()) instanceof Set
will returntrue
inlobx
while beingfalse
inmobx
- fine-grained scheduling of reactions, allowing you for example to only run reactions on
requestAnimationFrame
(also some-what possible inmobx
but only as a global configuration) - easy isolation of observable graphs if needed. (similar to
isolateGlobalState
frommobx
but with finer grained control) lobx
is missing the following features frommobx
:intercept
,trace
,spy
Is lobx a fork of Mobx?
The core of lobx
is written from scratch while the types (Array
, Object
, Map
etc) were initially brought in from mobx
and have since been heavily modified. lobx
does have a significant amount of test code from mobx
brought in to ensure that both libraries have very similar derivation behaviors. Either way the existence of lobx
would not have been possible without mobx
.
What can be made observable?
Any plain object can be made observable, as well as Array
, Map
, Set
, WeakMap
, WeakSet
and Date
.
Any non-plain object (one created with class
for instance or whose prototype is not Object.prototype
) will not be observed. Likewise with any frozen object (using Object.freeze
)
Reading from observable objects
With lobx
observability is always deep. Every object accessed through a proxied observable will itself be observable.
import { observable, isObservable } from "lobx";
const source = { inner: { values: [{ value: 0 }] }, value: 1 };
const observed = observable(source);
console.log(isObservable(observed)); //true;
console.log(isObservable(observed.inner)); // true;
console.log(isObservable(observed.inner[0])); // true
Writing to observable objects
When writing to observable objects lobx
will mutate the underlying source.
Yet it will avoid writing any observable values to the underlying source, so if an observable value A is set on a property of another observable object B, the source of B will have the source of A instead of the observable of A.
lobx
will avoid setting observable values on source objects.
import { observable, isObservable } from "lobx";
const source = { inner: { values: [{ value: 0 }] }, value: 1 };
const observed = observable(source);
const childSource = { value: 1 }; // plain object, not observed;
const childObserved = observable(childSource);
observed.inner[0] = childObserved; // set an observable object on another observable object
console.log(isObservable(observed.inner[0])); // true, we get back an observable object when accessing a property of another observable object
console.log(isObservable(source.inner[0])); // false, the source object did not get the observable set on it
console.log(source.inner[0] === childSource); // true, the source of the observable is what gets set on the source object;
Configuring observable objects
By default when making an object observable each property of that object will become observable, getters will become computed and methods and setters will be actions. This behavior can be configured. This is done with observable.configure
where the configuration is a map of object properties to one of observable
, computed
or action
.
const myObservable = observable.configure(
{
observableValue: observable,
comp: computed,
inc: action,
},
{
observableValue: 0,
nonObservableValue: 0,
get comp() {
return this.observableValue * 2;
},
inc() {
this.observableValue++;
},
}
);
Providing a custom configuration overwrite the default behavior and gives you control over which properties are observable, computed or an action.
Using lobx with React
To use lobx
with react
you will need an additional package lobx-react
. All that is required to make a react component automatically re-render when an observable changes is to wrap it in a observer
.
Usage:
import { observer } from "lobx-react".
const MyComponent = observer(props => ReactElement)
While lobx
works independently from React, they are most commonly used together.
import React from "react"
import ReactDOM from "react-dom"
import { Observable } from "lobx"
import { observer } from "mobx-react-lite"
class Timer extends Observable {
secondsPassed = 0
increaseTimer() {
this.secondsPassed += 1
}
}
const myTimer = new Timer()
// A function component wrapped with `observer` will react
// to any future change in an observable it used before.
const TimerView = observer(({ timer }) => <span>Seconds passed: {timer.secondsPassed}</span>)
ReactDOM.render(<TimerView timer={myTimer} />, document.body)
setInterval(() => {
myTimer.increaseTimer()
}, 1000)
The observer
HoC automatically subscribes React components to any observables that are used during rendering.
As a result, components will automatically re-render when relevant observables change.
It also makes sure that components don't re-render when there are no relevant changes.
So, observables that are accessible by the component, but not actually read, won't ever cause a re-render.
In practice this makes lobx
applications very well optimized out of the box and they typically don't need any additional code to prevent excessive rendering.
For observer
to work, it doesn't matter how the observables arrive in the component, only that they are read.
Reading observables deeply is fine, complex expression like todos[0].author.displayName
work out of the box.
This makes the subscription mechanism much more precise and efficient compared to other frameworks in which data dependencies have to be declared explicitly or be pre-computed (e.g. selectors).
Local and external state
There is great flexibility in how state is organized, since it doesn't matter (technically that is) which observables we read or where observables originated from.
The examples below demonstrate different patterns on how external and local observable state can be used in components wrapped with observer
.
Using external state in observer
components
Observables can be passed into components as props (as in the example above):
import { observer } from "lobx-react"
const myTimer = new Timer() // See the Timer definition above.
const TimerView = observer(({ timer }) => <span>Seconds passed: {timer.secondsPassed}</span>)
// Pass myTimer as a prop.
ReactDOM.render(<TimerView timer={myTimer} />, document.body)
Since it doesn't matter how we got the reference to an observable, we can consume observables from outer scopes directly (including from imports, etc.):
const myTimer = new Timer() // See the Timer definition above.
// No props, `myTimer` is directly consumed from the closure.
const TimerView = observer(() => <span>Seconds passed: {myTimer.secondsPassed}</span>)
ReactDOM.render(<TimerView />, document.body)
Using observables directly works very well, but since this typically introduces module state, this pattern might complicate unit testing. Instead, we recommend using React Context instead.
React Context is a great mechanism to share observables with an entire subtree:
import {observer} from 'lobx-react'
import {createContext, useContext} from "react"
const TimerContext = createContext<Timer>()
const TimerView = observer(() => {
// Grab the timer from the context.
const timer = useContext(TimerContext) // See the Timer definition above.
return (
<span>Seconds passed: {timer.secondsPassed}</span>
)
})
ReactDOM.render(
<TimerContext.Provider value={new Timer()}>
<TimerView />
</TimerContext.Provider>,
document.body
)
Note that we don't recommend ever replacing the value
of a Provider
with a different one. Using lobx
, there should be no need for that, since the observable that is shared can be updated itself.
Using local observable state in observer
components
Since observables used by observer
can come from anywhere, they can be local state as well.
Again, different options are available for us.
The simplest way to use local observable state is to store a reference to an observable class with useState
.
Note that, since we typically don't want to replace the reference, we totally ignore the updater function returned by useState
:
import { observer } from "lobx-react"
import { useState } from "react"
const TimerView = observer(() => {
const [timer] = useState(() => new Timer()) // See the Timer definition above.
return <span>Seconds passed: {timer.secondsPassed}</span>
})
ReactDOM.render(<TimerView />, document.body)
If you want to automatically update the timer like we did in the original example,
useEffect
could be used in typical React fashion:
useEffect(() => {
const handle = setInterval(() => {
timer.increaseTimer()
}, 1000)
return () => {
clearInterval(handle)
}
}, [timer])
As stated before, instead of using classes, it is possible to directly create observable objects. We can leverage observable for that:
import { observer } from "lobx-react"
import { observable } from "lobx"
import { useState } from "react"
const TimerView = observer(() => {
const [timer] = useState(() =>
observable({
secondsPassed: 0,
increaseTimer() {
this.secondsPassed++
}
})
)
return <span>Seconds passed: {timer.secondsPassed}</span>
})
ReactDOM.render(<TimerView />, document.body)
The combination const [store] = useState(() => observable({ /* something */}))
is
quite common. To make this pattern simpler the useObservable
hook is exposed from lobx-react
package, making it possible to simplify the earlier example to:
import { observer, useObservable } from "lobx-react"
import { useState } from "react"
const TimerView = observer(() => {
const timer = useObservable(() => ({
secondsPassed: 0,
increaseTimer() {
this.secondsPassed++
}
}))
return <span>Seconds passed: {timer.secondsPassed}</span>
})
ReactDOM.render(<TimerView />, document.body)
Always read observables inside observer
components
You might be wondering, when do I apply observer
? The rule of thumb is: apply observer
to all components that read observable data.
observer
only enhances the component you are decorating, not the components called by it. So usually all your components should be wrapped by observer
. Don't worry, this is not inefficient. On the contrary, more observer
components make rendering more efficient as updates become more fine-grained.
Tip: Grab values from objects as late as possible
observer
works best if you pass object references around as long as possible, and only read their properties inside the observer
based components that are going to render them into the DOM / low-level components.
In other words, observer
reacts to the fact that you 'dereference' a value from an object.
In the above example, the TimerView
component would not react to future changes if it was defined
as follows, because the .secondsPassed
is not read inside the observer
component, but outside, and is hence not tracked:
const TimerView = observer(({ secondsPassed }) => <span>Seconds passed: {secondsPassed}</span>)
React.render(<TimerView secondsPassed={myTimer.secondsPassed} />, document.body)
Note that this is a different mindset from other libraries like react-redux
, where it is a good practice to dereference early and pass primitives down, to better leverage memoization.
Don't pass observables into components that aren't observer
Components wrapped with observer
only subscribe to observables used during their own rendering of the component. So if observable objects / arrays / maps are passed to child components, those have to be wrapped with observer
as well.
This is also true for any callback based components.
If you want to pass observables to a component that isn't an observer
, either because it is a third-party component, or because you want to keep that component lobx
agnostic, you will have to use forceObserve
before passing them on.
To elaborate on the above,
take the following example observable todo
object, a TodoView
component (observer) and an imaginary GridRow
component that takes a column / value mapping, but which isn't an observer
:
class Todo extends Observable {
title = "test"
done = true
}
const TodoView = observer(({ todo }: { todo: Todo }) =>
// WRONG: GridRow won't pick up changes in todo.title / todo.done
// since it isn't an observer.
return <GridRow data={todo} />
// CORRECT: let `TodoView` detect relevant changes in `todo`,
// and pass plain data down.
return <GridRow data={{
title: todo.title,
done: todo.done
}} />
// CORRECT: using `toJS` works as well, but being explicit is typically better.
return <GridRow data={toJS(todo)} />
)
Callback components might require <Observer>
Imagine the same example, where GridRow
takes an onRender
callback instead.
Since onRender
is part of the rendering cycle of GridRow
, rather than TodoView
's render (even though that is where it syntactically appears), we have to make sure that the callback component uses an observer
component.
Or, we can create an in-line anonymous observer using <Observer />
const TodoView = observer(({ todo }: { todo: Todo }) => {
// WRONG: GridRow.onRender won't pick up changes in todo.title / todo.done
// since it isn't an observer.
return <GridRow onRender={() => <td>{todo.title}</td>} />
// CORRECT: wrap the callback rendering in Observer to be able to detect changes.
return <GridRow onRender={() => <Observer>{() => <td>{todo.title}</td>}</Observer>} />
})
Tips
When observer
needs to be combined with other decorators or higher-order-components, make sure that observer
is the innermost (first applied) decorator;
otherwise it might do nothing at all.
import { observer, useObservable } from "lobx-react"
import { useEffect } from "react"
const TimerView = observer(({ offset = 0 }) => {
const timer = useObservable(() => ({
offset, // The initial offset value
secondsPassed: 0,
increaseTimer() {
this.secondsPassed++
},
get offsetTime() {
return this.secondsPassed - this.offset // Not 'offset' from 'props'!
}
}))
useEffect(() => {
// Sync the offset from 'props' into the observable 'timer'
timer.offset = offset
}, [offset])
// Effect to set up a timer, only for demo purposes.
useEffect(() => {
const handle = setInterval(timer.increaseTimer, 1000)
return () => {
clearInterval(handle)
}
}, [])
return <span>Seconds passed: {timer.offsetTime}</span>
})
ReactDOM.render(<TimerView />, document.body)
In practice you will rarely need this pattern, since
return <span>Seconds passed: {timer.secondsPassed - offset}</span>
is a much simpler, albeit slightly less efficient solution.
useEffect
can be used to set up side effects that need to happen, and which are bound to the life-cycle of the React component.
Using useEffect
requires specifying dependencies.
With lobx
that isn't really needed, since lobx
has already a way to automatically determine the dependencies of an effect, autorun
.
Combining autorun
and coupling it to the life-cycle of the component using useEffect
is luckily straightforward:
import { observer, useObservable } from "lobx-react"
import { useState } from "react"
const TimerView = observer(() => {
const timer = useObservable(() => ({
secondsPassed: 0,
increaseTimer() {
this.secondsPassed++
}
}))
// Effect that triggers upon observable changes.
useEffect(
() =>
autorun(() => {
if (timer.secondsPassed > 60) alert("Still there. It's a minute already?!!")
}),
[]
)
// Effect to set up a timer, only for demo purposes.
useEffect(() => {
const handle = setInterval(timer.increaseTimer, 1000)
return () => {
clearInterval(handle)
}
}, [])
return <span>Seconds passed: {timer.secondsPassed}</span>
})
ReactDOM.render(<TimerView />, document.body)
Note that we return the disposer created by autorun
from our effect function.
This is important, since it makes sure the autorun
gets cleaned up once the component unmounts!
The dependency array can typically be left empty, unless a non-observable value should trigger a re-run of the autorun, in which case you will need to add it there.
To make your linter happy, you can define timer
(in the above example) as a dependency.
That is safe and has no further effect, since the reference will never actually change.
If you'd rather explicitly define which observables should trigger the effect, use reaction
instead of autorun
, beyond that the pattern remains identical.
Working with classes
While classes can't be observed directly, you can create observable classes that will return observable instances. Those classes can be then further configured to give fine grained control as to what properties are observable, which getters are computed and what methods are actions.
An observable class can be created by extending Observable
import { Observable } from "lobx";
class Todos extends Observable {
// observable
todos = [];
// observable
filter = "";
//computed
get filteredTodos() {
return this.todos.filter((todo) => todo.title.includes(this.filter));
}
//action
createTodo(title) {
this.todos.push(new Todo({ title }));
}
//action
setFilter(filter) {
this.filter = filter;
}
}
class Todo extends Observable {
// observable
title = "";
// observable
completed = false;
constructor({ title = "", completed = false } = {}) {
super();
this.title = title;
this.completed = completed;
}
//action
setTitle(title) {
this.title = title;
}
//action
setCompleted(completed) {
this.completed = completed;
}
}
By default any property of an Observable
class will be observable
, methods will be an action
and getters will be computed
.
Note: EcmaScript private fields (#field) can not be made observable.
Configuring classes
If you need more fine grained control as to which properties are observable or which methods are actions you can use decorate
.
Using the above example:
import { decorate, computed, action, observable } from "lobx";
decorate(
{
filteredTodos: computed,
todos: observable,
filter: observable,
createTodo: action,
setFilter: action,
},
Todos
);
decorate(
{
title: observable,
completed: observable,
setTitle: action,
setCompleted: action,
},
Todo
);
decorate
can only be called once on any given Observable
, it will return the passed in class. decorate
will overwrite the default configuration.
Decorator support
If preferred configuring Observable
can also be done via decorators
import { observable, computed, action, Observable } from "lobx";
class Todos extends Observable {
@observable todos = [];
@observable filter = "";
@computed get filteredTodos() {
return this.todos.filter((todo) => todo.title.includes(this.filter));
}
@action createTodo(title) {
this.todos.push(new Todo(title));
}
@action setFilter(filter) {
this.filter = filter;
}
}
Much like with decorate
the presence of a decorator on an Observable
will overwrite the default configuration on that class.
Extending Observable classes
You can also extend observable classes, when done the configuration of the base class will merge with the extending class.
Async actions
Actions on lobx
(and mobx
) serve as unit of work and will prevent reactions from being triggered while an action is in progress. Ensuring that reactions are not triggered on intermediate state mutations. When it comes to async actions an extra step is required to ensure that operations happen after the first await
are still wrapped in an action. To do this, everything to the right of an await
needs to be wrapped with in a task
import { observable, computed, action, Observable, task } from "lobx";
class Todos extends Observable {
todos = [];
filter = "";
get filteredTodos() {
return this.todos.filter((todo) => todo.title.includes(this.filter));
}
async fetchTodos() {
// we're in an action
this.todos = [];
// wrap our async call in `task`
const todoData = await task(fetchTodos());
// this will continue to execute in an action because of our use of `task()` above
todoData.forEach((todo) => this.todos.push(new Todo(todo)));
}
createTodo(title) {
this.todos.push(new Todo(title));
}
setFilter(filter) {
this.filter = filter;
}
}
Always use task
with await
, and only use task
in an action
. Using it in any other way can result in unpredictable behavior. (This is because task
will end the current action and resume it when the passed in promise is resolved)
Enforcing actions
By default lobx
does not enforce the use of actions and allows you to mutate observables directly. Use enforceActions(true)
to have lobx
throw an error if a mutation to an observable is ever done without an action. (observables that are not currently observed by a reaction are exempted for this rule).
import { autorun, observable, enforceActions, runInAction } from "lobx";
enforceActions(true); // enforce actions in lobx
const todos = observable([{ title: "write docs", completed: false }]);
autorun(() => {
console.log("all todos: ", JSON.stringify(todos));
});
try {
// will throw because we're mutating an observable that's actively being observed outside of an action
todos[0].completed = true;
} catch (e) {}
runInAction(() => {
todos[0].completed = true;
todos.push({ title: "write better docs", completed: false });
}); // will not throw as our mutations are being ran in action
Scheduling reactions
For most applications you want to enforce actions in order to ensure that reactions do not get called during intermediate state updates and only get called once all mutations for any given action have been made. This is primarily an optimization in order to avoid doing work that may need to be re-done once the next mutation is made synchronously.
While using and enforcing strict actions is the recommended way to work with lobx
it does add some complexity. In certain situations you can avoid actions all together without having to run reactions on intermediate state updates. This can be done using a scheduler.
A scheduler allows you to batch reactions and have them run sometime in the future. When exactly they will be called will depend on how you set up the scheduler. For example we can set up a scheduler to run on some setTimeout
value, on a requestAnimationFrame
or requestIdleCallback
. This approach might be a good fit for a visualization, where it would only make sense to update the visualization once a frame instead of every mutation of which can occur multiple times per frame.
import { observable, createScheduler } from "lobx";
// create a scheduler that flushes all reactions on `requestAnimationFrame`
const frameScheduler = createScheduler((fn) => requestAnimationFrame(fn));
const points = [
{ x: 0, y: 0, color: "red" },
{ x: 10, y: 10, color: "blue" },
];
frameScheduler.autorun(() => {
// an example renderer that will draw some points on a canvas given the above data structure
drawPointsOnCanvas(points);
});
// multiple mutations outside of an action
points.push({ x: 1, y: 1, color: "green" });
points[0].x++;
points[1].color = "purple";
// reaction to these mutations will not be called until `requestAnimationFrame` is triggered, and then they will only be called once
createScheduler
accepts a scheduling function that will pass in a callback. The scheduling function will be called as soon as there is a reaction. It is up to the scheduling function to execute the callback at some point either immediately or sometime in the future. lobx
will not call the scheduling function again until the callback has ben executed. Executing the callback flushes all pending reactions.
Using custom graphs
All reactions in lobx
run on a graph. Initially lobx
creates a default graph on which all observables and reactions register to. In certain rare situations you may want to isolate observables to a different graph. Observables and reactions on different graphs will not be able to interact together.
import { graph, observable, autorun } from "lobx";
// create a new graph
const g = graph();
// create an observable on our custom graph
const todos = observable([{ title: "write docs", completed: false }], {
graph: g,
});
// autorun on our custom graph
autorun(
() => {
console.log("all todos on custom graph: ", JSON.stringify(todos));
},
{ graph: g }
);
// autorun on the default graph
autorun(() => {
console.log("all todos on default graph: ", JSON.stringify(todos));
});
// the first autorun wil execute but not the second as its registered to a different graph than the observable.
todos[0].completed = true;
API and Usage
Note: lobx
is not API compatible with mobx
.
observable(obj, {equals?, graph? })
Return an observable proxy for a given object. Can be a plain object, array, map, set, date, weakmap or weakset. Resulting proxy will be deeply observed.
Options:
equals
default:(a, b) => a === b || (a !== a && b !== b)
. By default an observable is considered changed if it produces a new reference. A customequals
method can be provided if for example you need to compare by value. Ifequals
returns false then observers further down the graph will not be re-evaluated.
observable.box(value, { equals?, graph? })
Wrap single value with an observable context.
mobx
equivalent: observable.box
Options:
equals
default:(a, b) => a === b || (a !== a && b !== b)
. By default an observable is considered changed if it produces a new reference. A customequals
method can be provided if for example you need to compare by value. Ifequals
returns false then observers further down the graph will not be re-evaluated.
Methods:
get()
: returns the current valueset(newValue)
: sets a new valueequals(value): boolean
: uses the passed in comparator to determine if a value is equal to the current observable value.
observable.configure(config, value, {equals?, graph?})
Creates an observable with a specific configuration, overwriting the default behavior which assigns properties as observables
, getters as computed
and methods and setters as actions
.
observable.opts({ref?})
When decorating or using observable.configure
, observable.opts
allows for the customization of the observable field.
Options:
ref
(boolean) defaultfalse
. Ifref
is true the observable property will not itself be deeply observed.
observable
can also be used in observable.configure
and decorate
to denote an object/class property as an observable value.
computed(fn, { keepAlive = false, equals?, graph?, context? })
Derive a new value from observables
import { observable, computed } from "lobx";
const firstName = observable.box("Alice");
const lastName = observable.box("Smith");
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
console.log(fullName.get()); // Alice Smith
Options:
keepAlive
default:false
. Iftrue
will not clear out the computed value once the computed is no longer observedequals
default:(a, b) => a === b || (a !== a && b !== b)
. By default a computed is considered changed if it produces a new reference. A customequals
method can be provided if for example you need to compare by value. Ifequals
returns false then observers further down the graph will not be re-evaluated.context
whatthis
will be when the computed method executes
Methods:
get()
: derive (if needed) a value and return itequals(value): boolean
: uses the passed in comparator to determine if a value is equal to the current computed value. Might derive.isDirty(): boolean
: returnstrue
if computed needs to derive a value,false
if it has already derived and cached one.isKeepAlive(): boolean
: returnstrue
if the computed iskeepAlive
,false
otherwise.setKeepAlive(keepAlive: boolean)
: changekeepAlive
, if the computed waskeepAlive
and is no longer observed will clear out the cached value.
computed.opts({keepAlive = false, equals?})
When decorating or using observable.configure
, computed.opts
allows for the customization of the observable field.
computed
can also be used in observable.configure
and decorate
to denote an object/class getter as a derived value.
action(fn, { graph? })
An action allows you to mutate multiple observables and hold off reactions until the (outer most) action has completed. This prevents reactions of triggering on intermediate observable values.
action.opts({untracked = true, bound = false})
When decorating or using observable.configure
, computed.opts
allows for the customization of a method or setter.
untracked
is defaulted to true
but can be set to false
so that the observables accessed in a reaction within the action can still be tracked. Useful if you don't know ahead of time if the action will perform a mutation or just derive values based on parameters.
bound
is defaulted to false
but can be set to true
if you wish to have the action's context to be automatically bound to the observable.
import { observableObject, autorun, action } from "lobx";
const firstName = observable.box("Alice");
const lastName = observable.box("Smith");
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
autorun(() => console.log(fullName.get())); //Alice Smith
const changeName = action(() => {
firstName.set("Bob");
lastName.set("Jones");
});
//execute the action
changeName(); // Bob Jones
action
can also be used in observable.configure
and decorate
to denote an object/class method as an action.
Observable
A base class which will need to be extended when using observables on a class. More details here
autorun(callback, { graph? })
Perform a side effect each time from when an observable value changes. Runs immediately.
import { observable, computed, autorun } from "lobx";
const firstName = observable.box("Alice");
const lastName = observable.box("Smith");
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
const unsub = autorun(() => console.log(fullName())); //Alice Smith
firstName.set("Bob"); // Bob Smith
unsub(); // unsubscribes from future updates
reaction(tracker, callback, { graph? })
Similar to autorun
but splits the observation and effect into separate functions. Observation runs immediately whereas effect runs on change. The observation callback expects a returned value which will then be passed into the effect callback. If the value has not changed since the last run then the effect callback will not be invoked.
import { observable, computed, reaction } from "lobx";
const firstName = observable.box("Alice");
const lastName = observable.box("Smith");
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
const unsub = reaction(
() => fullName.get(),
(name) => console.log(name)
);
firstName.set("Bob"); // Bob Smith
lastName.set("Jones"); // Bob Jones
unsub(); // unsubscribes from future updates
runInAction(fn, { graph? })
Just like action
but instead of returning a function that can be later executed it will immediately execute the function passed in.
import { observableObject, autorun, runInAction } from "lobx";
const firstName = observable.box("Alice");
const lastName = observable.box("Smith");
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
autorun(() => console.log(fullName.get())); //Alice Smith
runInAction(() => {
firstName.set("Bob");
lastName.set("Jones");
}); // Bob Jones
batch(fn, {graph?})
Batches a function so that any mutation performed within does not result in an intermediate reaction update. Reactions will only be called once the batch has been completed. Similar to runInAction
but does not create an explicit action.
createScheduler(scheduler, {graph?})
Creates a scheduler which gives complete control as to when reactions will be flushed. More info can found here
Methods
listener(callback)
- Creates a scheduledlistener
reaction(track, callback)
- Creates a scheduledreaction
autorun(callback)
- Creates a scheduledautorun
decorate(configuration, Class)
Allows you to customize an Observable
class with a given configuration. Can only be called once per unique class. More info can be found here
atom({ graph? })
A atom
is a generic observable that enables the creation of custom observable types. It only has two methods; reportChanged
and reportObserved
. These methods are used to inform lobx
as to when your observable has been read or written to.
mobx
equivalent: createAtom
// example adapted from mobx
import { atom, autorun } from "lobx";
class Clock {
atom;
intervalHandler = null;
currentDateTime;
constructor() {
// creates an node to interact with the lobx core algorithm
this.atom = atom(
// first (optional) parameter: callback for when this node transitions from unobserved to observed.
() => this.startTicking(),
// second (optional) parameter: callback for when this node transitions from observed to unobserved
// note that the same node transitions multiple times between these two states
() => this.stopTicking()
);
}
getTime() {
// let lobx know this observable data source has been used
// reportObserved will return true if the node is currently being observed
// by some reaction.
// reportObserved will also trigger the onBecomeObserved event handler (startTicking) if needed
if (this.atom.reportObserved()) {
return this.currentDateTime;
} else {
// apparently getTime was called but not while a reaction is running.
// So, nobody depends on this value, hence the onBecomeObserved handler (startTicking) won't be fired
// Depending on the nature of your node
// it might behave differently in such circumstances
// (like throwing an error, returning a default value etc)
return new Date();
}
}
tick() {
this.currentDateTime = new Date();
// let lobx know that this data source has changed
this.atom.reportChanged();
}
startTicking() {
this.tick(); // initial tick
this.intervalHandler = setInterval(() => this.tick(), 1000);
}
stopTicking() {
clearInterval(this.intervalHandler);
this.intervalHandler = null;
}
}
const clock = new Clock();
const disposer = autorun(() => console.log(clock.getTime()));
// ... prints the time each second
setTimeout(() => disposer(), 10000);
// printing stops. If nobody else uses the same `clock` the clock will stop ticking as well
Methods:
reportChanged()
: signal that an atom has changedreportObserved(): boolean
- signal that an atom is observed.true
is returned if the observation occurred within a derivation.
listener(callback, { graph? })
listener
is what autorun
and reaction
are built on. It can be used to build custom reactions or as a more generic alternative to autorun
and reaction
. It accepts a callback
function and returns an object with two methods: track
and dispose
.
mobx
equivalent: Reaction
import { observable, listener } from "lobx";
const firstName = observable.box("Alice");
const lastName = observable.box("Smith");
const l = listener(() => console.log("observable changed!"));
l.track(() => firstName.get());
firstName.set("Bob"); // logs "observable changed!";
l.track(() => lastName.get()); // change tracking to lastName
firstName.set("Charlie"); // nothing logged;
lastName.set("Jones"); // logs "observable changed!";
l.dispose();
lastName.set("White"); // disposed, logs nothing;
Methods:
track(fn: Function)
: call a method and track any observables read in it. If those observables change it will trigger thecallback
method passed intolistener
dispose()
: unsubscribe from the listenerisDisposed
: (boolean) has this listener been disposed
getObservableSource(observable)
Given an observable proxy return the non-observable plain javascript source object.
untracked(fn, { graph? })
Run a function without establishing observers.
isInAction()
Returns a boolean
as to whether or not we're currently within an action
isInBatch()
Returns a boolean
as to whether or not we're currently within an action or batch
isTracking()
Returns a boolean
as to whether or not we're currently in a derivation (computed
, autorun
, reaction
, listener
)
isObserved(observable | computed | atom, { graph? })
Returns a boolean
as to whether or not an observable node is currently being observed in a derivation
isObservable(object, {graph?})
Returns a boolean
as to whether or not the passed in object is a lobx
observable
enforceActions(actionsEnforced)
Pass in true
to enforceActions
to require
task(promise)
When performing asynchronous actions everything to the right of an await needs to be wrapped in a task
so that lobx
knows to start a new action once the async operation has completed. More information can be found here
[Advanced] onReactionsComplete(callback)
Called when all the reactions have ran on the graph
[Advanced] onObservedStateChange(callback, [key], callback)
Allows you to get notified when an observable object or property on an object is being observed by a reaction or is no longer observed by reactions. The callback will be executed with a single parameter observing
which is a boolean state to indicate if that object is being observed or not.
[Advanced] graph()
A graph orchestrates all observable actions and reactions. Observables and derivations are all nodes on this graph. By default lobx
will automatically create a graph which will be used for all nodes unless otherwise provided within the options. Creating a new graph and adding nodes to it, isolates those nodes from those added to the default graph or other graphs.
mobx
equivalent: isolateGlobalState
import { graph, observable, listener } from "lobx";
const newGraph = graph();
const o = observable.box(0, { graph: newGraph });
autorun(() => o.get());
o.set(o.get() + 1); // will not react as the listener node created by autorun and the observable live on different graphs
autorun(() => o.get(), { graph: newGraph });
o.set(o.get() + 1); // listener created above will react as both the listener and observable node were created on the same graph
Methods
enforceActions(enforce)
isInAction()
isInBatch()
isObserved(node: ObservableNode)
isTracking()
runInAction(fn)
batch(fn)
onReactionsComplete(callback)
task(promise)
untracked(fn)
[Advanced] getDefaultGraph()
Returns the default graph created by lobx
.
[Advanced] observe(obj, callback)
The observe
api allows you to hook into observable events and give you the opportunity to make mutations or perform additional actions before any reaction is triggered upon a change in an observable state. Typically only used if you're building a library on top of lobx
.
The callback will be executed with one of these events, depending on the type of the observable passed in and the mutation performed.
export type AddEvent<T> = {
object: object;
type: "add";
name?: unknown;
newValue: T;
};
export type DeleteEvent<T> = {
object: object;
type: "delete";
name?: unknown;
oldValue: T;
};
export type UpdateEvent<T> = {
object: object;
type: "update";
name: unknown;
oldValue: T;
newValue: T;
};
// array only
export type UpdateArrayEvent<T> = {
object: T[];
type: "updateArray";
index: number;
oldValue: T;
newValue: T;
};
// array only
export type SpliceArrayEvent<T> = {
object: T[];
type: "spliceArray";
index: number;
added: T[];
removed: T[];
};
Thanks
To the contributors of mobx
and surrounding libraries for their amazing work.