mobx-utils v6.1.0
MobX-utils
Utility functions and common patterns for MobX
This package provides utility functions and common MobX patterns build on top of MobX. It is encouraged to take a peek under the hood and read the sources of these utilities. Feel free to open a PR with your own utilities. For large new features, please open an issue first.
Installation & Usage
NPM: npm install mobx-utils --save
CDN: https://unpkg.com/mobx-utils/mobx-utils.umd.js
import {function_name} from 'mobx-utils'
API
Table of Contents
- fromPromise
- isPromiseBasedObservable
- moveItem
- lazyObservable
- fromResource
- toStream
- StreamListener
- ViewModel
- createViewModel
- keepAlive
- keepAlive
- queueProcessor
- chunkProcessor
- resetNowInternalState
- now
- expr
- createTransformer
- deepObserve
- ObservableGroupMap
- ObservableMap
- defineProperty
- defineProperty
- defineProperty
- defineProperty
- defineProperty
- computedFn
- DeepMapEntry
- DeepMap
fromPromise
fromPromise
takes a Promise, extends it with 2 observable properties that track
the status of the promise and returns it. The returned object has the following observable properties:
value
: either the initial value, the value the Promise resolved to, or the value the Promise was rejected with. use.state
if you need to be able to tell the difference.state
: one of"pending"
,"fulfilled"
or"rejected"
And the following methods:
case({fulfilled, rejected, pending})
: maps over the result using the provided handlers, or returnsundefined
if a handler isn't available for the current promise state.
The returned object implements PromiseLike<TValue>
, so you can chain additional Promise
handlers using then
. You may also use it with await
in async
functions.
Note that the status strings are available as constants:
mobxUtils.PENDING
, mobxUtils.REJECTED
, mobxUtil.FULFILLED
fromPromise takes an optional second argument, a previously created fromPromise
based observable.
This is useful to replace one promise based observable with another, without going back to an intermediate
"pending" promise state while fetching data. For example:
Parameters
origPromise
The promise which will be observedoldPromise
The previously observed promise
Examples
@observer
class SearchResults extends React.Component {
@observable.ref searchResults
componentDidUpdate(nextProps) {
if (nextProps.query !== this.props.query)
this.searchResults = fromPromise(
window.fetch("/search?q=" + nextProps.query),
// by passing, we won't render a pending state if we had a successful search query before
// rather, we will keep showing the previous search results, until the new promise resolves (or rejects)
this.searchResults
)
}
render() {
return this.searchResults.case({
pending: (staleValue) => {
return staleValue || "searching" // <- value might set to previous results while the promise is still pending
},
fulfilled: (value) => {
return value // the fresh results
},
rejected: (error) => {
return "Oops: " + error
}
})
}
}
Observable promises can be created immediately in a certain state using
`fromPromise.reject(reason)` or `fromPromise.resolve(value?)`.
The main advantage of `fromPromise.resolve(value)` over `fromPromise(Promise.resolve(value))` is that the first _synchronously_ starts in the desired state.
It is possible to directly create a promise using a resolve, reject function:
`fromPromise((resolve, reject) => setTimeout(() => resolve(true), 1000))`
const fetchResult = fromPromise(fetch("http://someurl"))
// combine with when..
when(
() => fetchResult.state !== "pending",
() => {
console.log("Got ", fetchResult.value)
}
)
// or a mobx-react component..
const myComponent = observer(({ fetchResult }) => {
switch(fetchResult.state) {
case "pending": return <div>Loading...</div>
case "rejected": return <div>Ooops... {fetchResult.value}</div>
case "fulfilled": return <div>Gotcha: {fetchResult.value}</div>
}
})
// or using the case method instead of switch:
const myComponent = observer(({ fetchResult }) =>
fetchResult.case({
pending: () => <div>Loading...</div>,
rejected: error => <div>Ooops.. {error}</div>,
fulfilled: value => <div>Gotcha: {value}</div>,
}))
// chain additional handler(s) to the resolve/reject:
fetchResult.then(
(result) => doSomeTransformation(result),
(rejectReason) => console.error('fetchResult was rejected, reason: ' + rejectReason)
).then(
(transformedResult) => console.log('transformed fetchResult: ' + transformedResult)
)
Returns any origPromise with added properties and methods described above.
isPromiseBasedObservable
Returns true if the provided value is a promise-based observable.
Parameters
value
any
Returns boolean
moveItem
Moves an item from one position to another, checking that the indexes given are within bounds.
Parameters
Examples
const source = observable([1, 2, 3])
moveItem(source, 0, 1)
console.log(source.map(x => x)) // [2, 1, 3]
Returns ObservableArray<T>
lazyObservable
lazyObservable
creates an observable around a fetch
method that will not be invoked
until the observable is needed the first time.
The fetch method receives a sink
callback which can be used to replace the
current value of the lazyObservable. It is allowed to call sink
multiple times
to keep the lazyObservable up to date with some external resource.
Note that it is the current()
call itself which is being tracked by MobX,
so make sure that you don't dereference to early.
Parameters
fetch
initialValue
T optional initialValue that will be returned fromcurrent
as long as thesink
has not been called at least once (optional, defaultundefined
)
Examples
const userProfile = lazyObservable(
sink => fetch("/myprofile").then(profile => sink(profile))
)
// use the userProfile in a React component:
const Profile = observer(({ userProfile }) =>
userProfile.current() === undefined
? <div>Loading user profile...</div>
: <div>{userProfile.current().displayName}</div>
)
// triggers refresh the userProfile
userProfile.refresh()
fromResource
fromResource
creates an observable whose current state can be inspected using .current()
,
and which can be kept in sync with some external datasource that can be subscribed to.
The created observable will only subscribe to the datasource if it is in use somewhere,
(un)subscribing when needed. To enable fromResource
to do that two callbacks need to be provided,
one to subscribe, and one to unsubscribe. The subscribe callback itself will receive a sink
callback, which can be used
to update the current state of the observable, allowing observes to react.
Whatever is passed to sink
will be returned by current()
. The values passed to the sink will not be converted to
observables automatically, but feel free to do so.
It is the current()
call itself which is being tracked,
so make sure that you don't dereference to early.
For inspiration, an example integration with the apollo-client on github,
or the implementation of mobxUtils.now
The following example code creates an observable that connects to a dbUserRecord
,
which comes from an imaginary database and notifies when it has changed.
Parameters
subscriber
unsubscriber
IDisposer (optional, defaultNOOP
)initialValue
T the data that will be returned byget()
until thesink
has emitted its first data (optional, defaultundefined
)
Examples
function createObservableUser(dbUserRecord) {
let currentSubscription;
return fromResource(
(sink) => {
// sink the current state
sink(dbUserRecord.fields)
// subscribe to the record, invoke the sink callback whenever new data arrives
currentSubscription = dbUserRecord.onUpdated(() => {
sink(dbUserRecord.fields)
})
},
() => {
// the user observable is not in use at the moment, unsubscribe (for now)
dbUserRecord.unsubscribe(currentSubscription)
}
)
}
// usage:
const myUserObservable = createObservableUser(myDatabaseConnector.query("name = 'Michel'"))
// use the observable in autorun
autorun(() => {
// printed everytime the database updates its records
console.log(myUserObservable.current().displayName)
})
// ... or a component
const userComponent = observer(({ user }) =>
<div>{user.current().displayName}</div>
)
toStream
Converts an expression to an observable stream (a.k.a. TC 39 Observable / RxJS observable). The provided expression is tracked by mobx as long as there are subscribers, automatically emitting when new values become available. The expressions respect (trans)actions.
Parameters
expression
fireImmediately
boolean (by default false)
Examples
const user = observable({
firstName: "C.S",
lastName: "Lewis"
})
Rx.Observable
.from(mobxUtils.toStream(() => user.firstname + user.lastName))
.scan(nameChanges => nameChanges + 1, 0)
.subscribe(nameChanges => console.log("Changed name ", nameChanges, "times"))
Returns IObservableStream<T>
StreamListener
ViewModel
createViewModel
createViewModel
takes an object with observable properties (model)
and wraps a viewmodel around it. The viewmodel proxies all enumerable properties of the original model with the following behavior:
- as long as no new value has been assigned to the viewmodel property, the original property will be returned.
- any future change in the model will be visible in the viewmodel as well unless the viewmodel property was dirty at the time of the attempted change.
- once a new value has been assigned to a property of the viewmodel, that value will be returned during a read of that property in the future. However, the original model remain untouched until
submit()
is called.
The viewmodel exposes the following additional methods, besides all the enumerable properties of the model:
submit()
: copies all the values of the viewmodel to the model and resets the statereset()
: resets the state of the viewmodel, abandoning all local modificationsresetProperty(propName)
: resets the specified property of the viewmodelisDirty
: observable property indicating if the viewModel contains any modificationsisPropertyDirty(propName)
: returns true if the specified property is dirtychangedValues
: returns a key / value map with the properties that have been changed in the model so farmodel
: The original model object for which this viewModel was created
You may use observable arrays, maps and objects with createViewModel
but keep in mind to assign fresh instances of those to the viewmodel's properties, otherwise you would end up modifying the properties of the original model.
Note that if you read a non-dirty property, viewmodel only proxies the read to the model. You therefore need to assign a fresh instance not only the first time you make the assignment but also after calling reset()
or submit()
.
Parameters
model
T
Examples
class Todo {
@observable title = "Test"
}
const model = new Todo()
const viewModel = createViewModel(model);
autorun(() => console.log(viewModel.model.title, ",", viewModel.title))
// prints "Test, Test"
model.title = "Get coffee"
// prints "Get coffee, Get coffee", viewModel just proxies to model
viewModel.title = "Get tea"
// prints "Get coffee, Get tea", viewModel's title is now dirty, and the local value will be printed
viewModel.submit()
// prints "Get tea, Get tea", changes submitted from the viewModel to the model, viewModel is proxying again
viewModel.title = "Get cookie"
// prints "Get tea, Get cookie" // viewModel has diverged again
viewModel.reset()
// prints "Get tea, Get tea", changes of the viewModel have been abandoned
keepAlive
MobX normally suspends any computed value that is not in use by any reaction,
and lazily re-evaluates the expression if needed outside a reaction while not in use.
keepAlive
marks a computed value as always in use, meaning that it will always fresh, but never disposed automatically.
Parameters
_1
_2
target
Object an object that has a computed property, created by@computed
orextendObservable
property
string the name of the property to keep alive
Examples
const obj = observable({
number: 3,
doubler: function() { return this.number * 2 }
})
const stop = keepAlive(obj, "doubler")
Returns IDisposer stops this keep alive so that the computed value goes back to normal behavior
keepAlive
Parameters
_1
_2
computedValue
IComputedValue<any> created using thecomputed
function
Examples
const number = observable(3)
const doubler = computed(() => number.get() * 2)
const stop = keepAlive(doubler)
// doubler will now stay in sync reactively even when there are no further observers
stop()
// normal behavior, doubler results will be recomputed if not observed but needed, but lazily
Returns IDisposer stops this keep alive so that the computed value goes back to normal behavior
queueProcessor
queueProcessor
takes an observable array, observes it and calls processor
once for each item added to the observable array, optionally debouncing the action
Parameters
observableArray
Array<T> observable array instance to trackprocessor
debounce
number optional debounce time in ms. With debounce 0 the processor will run synchronously (optional, default0
)
Examples
const pendingNotifications = observable([])
const stop = queueProcessor(pendingNotifications, msg => {
// show Desktop notification
new Notification(msg);
})
// usage:
pendingNotifications.push("test!")
Returns IDisposer stops the processor
chunkProcessor
chunkProcessor
takes an observable array, observes it and calls processor
once for a chunk of items added to the observable array, optionally deboucing the action.
The maximum chunk size can be limited by number.
This allows both, splitting larger into smaller chunks or (when debounced) combining smaller
chunks and/or single items into reasonable chunks of work.
Parameters
observableArray
Array<T> observable array instance to trackprocessor
debounce
number optional debounce time in ms. With debounce 0 the processor will run synchronously (optional, default0
)maxChunkSize
number optionally do not call on full array but smaller chunks. With 0 it will process the full array. (optional, default0
)
Examples
const trackedActions = observable([])
const stop = chunkProcessor(trackedActions, chunkOfMax10Items => {
sendTrackedActionsToServer(chunkOfMax10Items);
}, 100, 10)
// usage:
trackedActions.push("scrolled")
trackedActions.push("hoveredButton")
// when both pushes happen within 100ms, there will be only one call to server
Returns IDisposer stops the processor
resetNowInternalState
Disposes of all the internal Observables created by invocations of now()
.
The use case for this is to ensure that unit tests can run independent of each other. You should not call this in regular application code.
Examples
afterEach(() => {
utils.resetNowInternalState()
})
now
Returns the current date time as epoch number. The date time is read from an observable which is updated automatically after the given interval. So basically it treats time as an observable.
The function takes an interval as parameter, which indicates how often now()
will return a new value.
If no interval is given, it will update each second. If "frame" is specified, it will update each time a
requestAnimationFrame
is available.
Multiple clocks with the same interval will automatically be synchronized.
Countdown example: https://jsfiddle.net/mweststrate/na0qdmkw/
Parameters
interval
(number |"frame"
) interval in milliseconds about how often the interval should update (optional, default1000
)
Examples
const start = Date.now()
autorun(() => {
console.log("Seconds elapsed: ", (mobxUtils.now() - start) / 1000)
})
expr
expr
can be used to create temporary computed values inside computed values.
Nesting computed values is useful to create cheap computations in order to prevent expensive computations from needing to run.
In the following example the expression prevents that a component is rerender each time the selection changes;
instead it will only rerenders when the current todo is (de)selected.
expr(func)
is an alias for computed(func).get()
.
Please note that the function given to expr
is evaluated twice in the scenario that the overall expression value changes.
It is evaluated the first time when any observables it depends on change.
It is evaluated a second time when a change in its value triggers the outer computed or reaction to evaluate, which recreates and reevaluates the expression.
In the following example, the expression prevents the TodoView
component from being re-rendered if the selection changes elsewhere.
Instead, the component will only re-render when the relevant todo is (de)selected, which happens much less frequently.
Parameters
expr
Examples
const Todo = observer((props) => {
const todo = props.todo
const isSelected = mobxUtils.expr(() => props.viewState.selection === todo)
const TodoView = observer(({ todo, editorState }) => {
const isSelected = mobxUtils.expr(() => editorState.selection === todo)
return <div className={isSelected ? "todo todo-selected" : "todo"}>{todo.title}</div>
})
createTransformer
Creates a function that maps an object to a view. The mapping is memoized.
See the transformer section for more details.
Parameters
transformer
arg2
onCleanup
deepObserve
Given an object, deeply observes the given object.
It is like observe
from mobx, but applied recursively, including all future children.
Note that the given object cannot ever contain cycles and should be a tree.
As benefit: path and root will be provided in the callback, so the signature of the listener is (change, path, root) => void
The returned disposer can be invoked to clean up the listener
deepObserve cannot be used on computed values.
Parameters
target
listener
Examples
const disposer = deepObserve(target, (change, path) => {
console.dir(change)
})
ObservableGroupMap
Reactively sorts a base observable array into multiple observable arrays based on the value of a
groupBy: (item: T) => G
function.
This observes the individual computed groupBy values and only updates the source and dest arrays
when there is an actual change, so this is far more efficient than, for example
base.filter(i => groupBy(i) === 'we')
. Call #dispose() to stop tracking.
No guarantees are made about the order of items in the grouped arrays.
The resulting map of arrays is read-only. clear(), set(), delete() are not supported and modifying the group arrays will lead to undefined behavior.
NB: ObservableGroupMap relies on Symbol
s. If you are targeting a platform which doesn't
support these natively, you will need to provide a polyfill.
Parameters
base
array The array to sort into groups.groupBy
function The function used for grouping.options
Object with properties:name
: Debug name of this ObservableGroupMap.keyToName
: Function to create the debug names of the observable group arrays.
Examples
const slices = observable([
{ day: "mo", hours: 12 },
{ day: "tu", hours: 2 },
])
const slicesByDay = new ObservableGroupMap(slices, (slice) => slice.day)
autorun(() => console.log(
slicesByDay.get("mo")?.length ?? 0,
slicesByDay.get("we"))) // outputs 1, undefined
slices[0].day = "we" // outputs 0, [{ day: "we", hours: 12 }]
ObservableMap
defineProperty
Base observable array which is being sorted into groups.
defineProperty
The ObservableGroupMap needs to track some state per-item. This is the name/symbol of the property used to attach the state.
defineProperty
The function used to group the items.
defineProperty
This function is used to generate the mobx debug names of the observable group arrays.
defineProperty
Disposes all observers created during construction and removes state added to base array items.
computedFn
computedFn takes a function with an arbitrary amount of arguments, and memoizes the output of the function based on the arguments passed in.
computedFn(fn) returns a function with the very same signature. There is no limit on the amount of arguments that is accepted. However, the amount of arguments must be constant and default arguments are not supported.
By default the output of a function call will only be memoized as long as the output is being observed.
The function passes into computedFn
should be pure, not be an action and only be relying on
observables.
Setting keepAlive
to true
will cause the output to be forcefully cached forever.
Note that this might introduce memory leaks!
Parameters
fn
keepAliveOrOptions
Examples
const store = observable({
a: 1,
b: 2,
c: 3,
m: computedFn(function(x) {
return this.a * this.b * x
})
})
const d = autorun(() => {
// store.m(3) will be cached as long as this autorun is running
console.log(store.m(3) * store.c)
})
DeepMapEntry
DeepMap
Details
createTransformer in detail
With createTransformer
it is very easy to transform a complete data graph into another data graph.
Transformation functions can be composed so that you can build a tree using lots of small transformations.
The resulting data graph will never be stale, it will be kept in sync with the source by applying small patches to the result graph.
This makes it very easy to achieve powerful patterns similar to sideways data loading, map-reduce, tracking state history using immutable data structures etc.
createTransformer
turns a function (that should transform value A
into another value B
) into a reactive and memoizing function.
In other words, if the transformation
function computes B
given a specific A
, the same B
will be returned for all other future invocations of the transformation with the same A
.
However, if A
changes, or any derivation accessed in the transformer function body gets invalidated, the transformation will be re-applied so that B
is updated accordingly.
And last but not least, if nobody is using the transformation of a specific A anymore, its entry will be removed from the memoization table.
The optional onCleanup
function can be used to get a notification when a transformation of an object is no longer needed.
This can be used to dispose resources attached to the result object if needed.
Always use transformations inside a reaction like observer
or autorun
.
Transformations will, like any other computed value, fall back to lazy evaluation if not observed by something, which sort of defeats their purpose.
Parameters
- `transformation: (value: A) => B
onCleanup?: (result: B, value?: A) => void)
createTransformer<A, B>(transformation: (value: A) => B, onCleanup?: (result: B, value?: A) => void): (value: A) => B
Examples
This all might still be a bit vague, so here are two examples that explain this whole idea of transforming one data structure into another by using small, reactive functions:
Tracking mutable state using immutable, shared data structures.
This example is taken from the Reactive2015 conference demo:
/*
The store that holds our domain: boxes and arrows
*/
const store = observable({
boxes: [],
arrows: [],
selection: null,
})
/**
Serialize store to json upon each change and push it onto the states list
*/
const states = []
autorun(() => {
states.push(serializeState(store))
})
const serializeState = createTransformer((store) => ({
boxes: store.boxes.map(serializeBox),
arrows: store.arrows.map(serializeArrow),
selection: store.selection ? store.selection.id : null,
}))
const serializeBox = createTransformer((box) => ({ ...box }))
const serializeArrow = createTransformer((arrow) => ({
id: arrow.id,
to: arrow.to.id,
from: arrow.from.id,
}))
In this example the state is serialized by composing three different transformation functions.
The autorunner triggers the serialization of the store
object, which in turn serializes all boxes and arrows.
Let's take closer look at the life of an imaginary example box#3.
- The first time box#3 is passed by
map
toserializeBox
, the serializeBox transformation is executed and an entry containing box#3 and its serialized representation is added to the internal memoization table ofserializeBox
. - Imagine that another box is added to the
store.boxes
list. This would cause theserializeState
function to re-compute, resulting in a complete remapping of all the boxes. However, all the invocations ofserializeBox
will now return their old values from the memoization tables since their transformation functions didn't (need to) run again. - Secondly, if somebody changes a property of box#3 this will cause the application of the
serializeBox
to box#3 to re-compute, just like any other reactive function in MobX. Since the transformation will now produce a new Json object based on box#3, all observers of that specific transformation will be forced to run again as well. That's theserializeState
transformation in this case.serializeState
will now produce a new value in turn and map all the boxes again. But except for box#3, all other boxes will be returned from the memoization table. - Finally, if box#3 is removed from
store.boxes
,serializeState
will compute again. But since it will no longer be using the application ofserializeBox
to box#3, that reactive function will go back to non-reactive mode. This signals the memoization table that the entry can be removed so that it is ready for GC.
So effectively we have achieved state tracking using immutable, shared datas structures here.
All boxes and arrows are mapped and reduced into single state tree.
Each change will result in a new entry in the states
array, but the different entries will share almost all of their box and arrow representations.
Transforming a datagraph into another reactive data graph
Instead of returning plain values from a transformation function, it is also possible to return observable objects. This can be used to transform an observable data graph into a another observable data graph, which can be used to transform... you get the idea.
Here is a small example that encodes a reactive file explorer that will update its representation upon each change. Data graphs that are built this way will in general react a lot faster and will consist of much more straight-forward code, compared to derived data graph that are updated using your own code. See the performance tests for some examples.
Unlike the previous example, the transformFolder
will only run once as long as a folder remains visible;
the DisplayFolder
objects track the associated Folder
objects themselves.
In the following example all mutations to the state
graph will be processed automatically.
Some examples:
- Changing the name of a folder will update its own
path
property and thepath
property of all its descendants. - Collapsing a folder will remove all descendant
DisplayFolders
from the tree. - Expanding a folder will restore them again.
- Setting a search filter will remove all nodes that do not match the filter, unless they have a descendant that matches the filter.
- Etc.
import {extendObservable, observable, createTransformer, autorun} from "mobx"
function Folder(parent, name) {
this.parent = parent;
extendObservable(this, {
name: name,
children: observable.shallow([]),
});
}
function DisplayFolder(folder, state) {
this.state = state;
this.folder = folder;
extendObservable(this, {
collapsed: false,
get name() {
return this.folder.name;
},
get isVisible() {
return !this.state.filter || this.name.indexOf(this.state.filter) !== -1 || this.children.some(child => child.isVisible);
},
get children() {
if (this.collapsed)
return [];
return this.folder.children.map(transformFolder).filter(function(child) {
return child.isVisible;
})
},
get path() {
return this.folder.parent === null ? this.name : transformFolder(this.folder.parent).path + "/" + this.name;
})
});
}
var state = observable({
root: new Folder(null, "root"),
filter: null,
displayRoot: null
});
var transformFolder = createTransformer(function (folder) {
return new DisplayFolder(folder, state);
});
// returns list of strings per folder
var stringTransformer = createTransformer(function (displayFolder) {
var path = displayFolder.path;
return path + "\n" +
displayFolder.children.filter(function(child) {
return child.isVisible;
}).map(stringTransformer).join('');
});
function createFolders(parent, recursion) {
if (recursion === 0)
return;
for (var i = 0; i < 3; i++) {
var folder = new Folder(parent, i + '');
parent.children.push(folder);
createFolders(folder, recursion - 1);
}
}
createFolders(state.root, 2); // 3^2
autorun(function() {
state.displayRoot = transformFolder(state.root);
state.text = stringTransformer(state.displayRoot)
console.log(state.text)
});
state.root.name = 'wow'; // change folder name
state.displayRoot.children[1].collapsed = true; // collapse folder
state.filter = "2"; // search
state.filter = null; // unsearch
3 months ago
1 year ago
1 year ago
2 years ago
3 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago