Front-end developers often refer to transparent reactivity — at the core of MobX, Vue or React Easy State — as magic, but there is nothing magical about it. It is based on a very simple idea, which can be demonstrated with the following snippet.
import React from 'react'
import { view, store } from 'react-easy-state'
const notepad = store({
author: 'Mr. Note Maker',
notes: []
})
const NotesApp = view(() =>
notepad.notes.map(note => <Note note={note} />)
)
const Note = view(({ note }) =>
<p>{note.text} by {notepad.author}</p>
)
You can perfectly define when you expect NotesApp
and Note
to re-render: when a new note is added or removed and when the author or a note’s text is modified. Luckily this conclusion was not driven by complex human intuition, but simple programmable if-else logic.
If a part of a state store — which is used inside a component’s render— mutates re-render the component to reflect the new state.
Your brain is creating the following ternary relations about properties of objects — used inside render methods.
object | property | component |
---|---|---|
appStore | notes | NotesApp |
notes array | length | NotesApp |
note object | text | Note |
appStore | author | Note |
When a property of an object is modified you subconsciously collect all of the components which belong to that (object, property)
pair. Let’s turn this process into code!
The rest of the article assumes you have a basic understanding of ES6 Proxies and React Easy State. If you don’t know what I am talking about, a quick look at the MDN Proxy docs and the React Easy State repo is enough to go on.
Making a Reactive Core
In order to construct the (object, property, component)
relations, we have to know which objects and properties do NotesApp
and Note
use during their renders. A developer can tell this by a glance at the code, but a library can not.
We also need to know when a property of an object is mutated, to collect the related components from the saved relations and render them.
Both of these can be solved with ES6 Proxies.
import { saveRelation, renderCompsThatUse } from './reactiveWiring'
export function store (obj) {
return new Proxy(obj, traps)
}
const traps = {
get (obj, key) {
saveRelation(obj, key, currentlyRenderingComp)
return Reflect.get(obj, key)
},
set (obj, key, value) {
renderCompsThatUse(obj, key)
return Reflect.set(obj, key, value)
}
}
The store
Proxy intercepts all property get and set operations and — respectively — builds and queries the relationship table.
There is one big question remaining: what is currentlyRenderingComp
in the get trap and how do we know which component is rendering at the moment? This is where view
comes into play.
let currentlyRenderingComp = undefined
export function view (Comp) {
return class ReactiveComp extends Comp {
render () {
currentlyRenderingComp = this
super.render()
currentlyRenderingComp = undefined
}
}
}
view
wraps a component and instruments its render method with a simple logic. It sets the currentlyRenderingComp
flag to the component while it is rendering. This way we have all the required information to build the relations in our get traps. object
and property
are coming from the trap arguments and component
is the currentlyRenderingComp
— set by view
.
Let’s get back to the notes app and see what happens in the reactive code.
import React from 'react'
import { view, store } from 'react-easy-state'
const notepad = store({
author: 'Mr. Note Maker',
notes: []
})
const NotesApp = view(() =>
notepad.notes.map(note => <Note note={note} />)
)
const Note = view(({ note }) =>
<p>{note.text} by {notepad.author}</p>
)
-
NotesApp
renders for the first time. -
view
setscurrentlyRenderingComp
to theNotesApp
component while it is rendering. -
NotesApp
iterates thenotes
array and renders aNote
for each note. - The Proxy around
notes
intercepts all get operations and saves the fact thatNotesApp
usesnotes.length
to render. It creates a(notes, length, NotesApp)
relation. - The user adds a new note, which changes
notes.length
. - Our reactive core looks up all components in relation with
(notes, length)
and re-renders them. - In our case:
NotesApp
is re-rendered.
The Real Challenges
The above section shows you how to make an optimistic reactive core, but the real challenges are in the numerous pitfalls, edge cases, and design decisions. In this section I will briefly describe some of them.
Scheduling the Renders
A transparent reactivity library should not do anything other than constructing, saving, querying and cleaning up those (object, property, component)
relations on relevant get/set operations. Executing the renders is not part of the job.
Easy State collects stale components on property mutations and passes their renders to a scheduler function. The scheduler can then decide when and how to render them. In our case the scheduler is a dummy setState
, which tells React: ‘I want to be rendered, do it when you feel like it’.
// a few lines from easy-state's source code
this.render = observe(this.render, {
scheduler: () => this.setState({}),
lazy: true
})
Some reactivity libraries do not have the flexibility of custom schedulers and call forceUpdate
instead of setState
, which translates to: ‘Render me now! I don’t care about your priorities’.
This is not yet noticeable — as React still uses a fairly simple render batching logic —but it will become more significant with the introduction of React's new async scheduler.
Cleaning Up
Saving and querying ternary relations is not so difficult. At least I thought so until I had to clean up after myself.
If a store object or a component is no longer used, all of their relations have to be cleaned up. This requires some cross references — as the relations have to be queryable by component
, by object
and by (object, property)
pairs. Long story short, I messed up and the reactive core behind Easy State leaked memory for a solid year.
After numerous ‘clever’ ways of solving this, I settled with wiping every relation of a component before all of its renders. The relations would then build up again from the triggered get traps — during the render.
This might seem like an overkill, but it had a surprisingly low performance impact and two huge benefits.
- I finally fixed the memory leak.
- Easy State became adaptive to render functions. It dynamically un-observes and re-observes conditional branches — based on the current application state.
import React from 'React'
import { view, store } from 'react-easy-state'
const car = store({
isMoving: false,
speed: 0
})
function Car () {
return car.isMoving ? <p>{car.speed}</p> : <p>The car is parking.</p>
}
export default view(Car)
Car
is not — needlessly re-rendered on speed
changes when car.isMoving
is false.
Implementing the Proxy Traps
Easy State aims to augment JavaScript with reactivity without changing it in a breaking way. To implement the reactive augmentation, I had to split basic operations into two groups.
Get-like operations retrieve data from an object. These include enumeration, iteration and simple property get/has operations. The
(object, property, component)
relations are saved inside their interceptors.Set-like operations mutate data. These include property add, set and delete operations and their interceptors query the relationship table for stale components.
get-like operations | set-like operations |
---|---|
get | add |
has | set |
enumeration | delete |
iteration | clear |
After determining the two groups, I had to go through the operations one-by-one and add reactivity to them in a seamless way. This required a deep understanding of basic JavaScript operations and the ECMAScript standard was a huge help here. Check it out if you don’t know the answer to all of the questions below.
- What is a property descriptor?
- Do property set operations traverse the prototype chain?
- Can you delete property accessors with the delete operator?
- What is the difference between the target and the receiver of a get operation?
- Is there a way to intercept object enumeration?
Managing a Dynamic Store Tree
So far you have seen that store
wraps objects with reactive Proxies, but that only results in one level of reactive properties. Why does the below app re-render when person.name.first
is changed?
import { store, view } from 'react-easy-state'
const person = store({
name: { first: 'Bob', last: 'Marley' }
})
export default view(() =>
<div>{person.name.first + person.name.last}</div>
)
To support nested properties the ‘get part’ of our reactive core has to be slightly modified.
import { saveRelation } from './reactiveWriring'
const storeCache = new WeakMap()
export function store (obj) {
const reactiveStore = storeCache.get(obj) || new Proxy(obj, traps)
storeCache.set(obj, reactiveStore)
return store
}
const traps = {
get (obj, key) {
saveRelation(obj, key, currentlyRenderingComp)
const result = Reflect.get(obj, key)
if (typeof result === 'object' && currentlyRenderingComp) {
return store(result)
}
return storeCache.get(result) || result
}
}
The most important section is the final if
block between line 15–18.
It makes properties reactive lazily — at any depth — by wrapping nested objects in reactive Proxies at get time.
It only wraps objects, if they are used inside a component’s render — thanks to the
currentlyRenderingComp
check. Other objects could never trigger renders and don’t need reactive instrumentation.Objects with a cached reactive wrapper are certainly used inside component renders, since the
currentlyRenderingComp
check— at line 15 — passed for them previously. These objects may trigger a reactive render with property mutation, so the get trap has to return their wrapped versions.
These points — and the fact that relations are cleaned up before every render — results in a minimal, adaptive subset of nested reactive store properties.
Monkey Patching Built-in Objects
Some built-in JavaScript objects — like ES6 collections — have special ‘internal slots’. These hidden code pieces can't be altered and they may have expectations towards their this
value. If someone calls them with an unexpected this
, they fail with an incompatible receiver error
.
Unfortunately, Proxies are also invalid receivers in these cases and Proxy wrapped objects throw the same error.
To work around this, I had to find a viable alternative to Proxies for built-in objects. Luckily they all have a function based interface, so I could resort to old-fashioned monkey patching.
The process is very similar to the Proxy based approach. The built-in’s interface has to be split into two groups: set-like and get-like operations. Then the object’s methods have to be patched with the appropriate reactivity logic — namely constructing and querying the reactive relations.
A Bit of Intuition
I was a bit overgeneralizing when I stated that the reactive core is made with cold logic only. In the end, I had to use some intuition too.
Making everything reactive is a nice challenge, but goes against user expectations. I collected some meta operations — that people don’t want to be reactive — and left them out of the fun.
none reactive get-like operations | none reactive set-like operations |
---|---|
Object.getOwnPropertyDescriptor() | Object.defineProperty() |
Well-known Symbol keyed properties | Well-known Symbol keyed properties |
These choices were made by intuition during my usage test rounds. Others might have a different approach to this, but I think I collected a sensible subset of the language. Every single operation in the above table has a good reason not to be reactive.
Conclusion
The reactive core — implemented in this article — is not in the source of React Easy State. In reality, the reactive logic is in a more general library — called the Observer Utility — and Easy State is just a thin port for React. I intentionally simplified this to make it more digestible, but the presented ideas are still the same. I hope you learned something new if you made it so far!
If this article captured your interest please help by sharing it. Also check out the Easy State repo and leave a star before you go.
Thanks!
(This article was originally published on Medium)
Top comments (0)