DEV Community

Cover image for The Ideas Behind React Easy State: Utilizing ES6 Proxies
Miklos Bertalan
Miklos Bertalan

Posted on

The Ideas Behind React Easy State: Utilizing ES6 Proxies

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>
)
  1. NotesApp renders for the first time.
  2. view sets currentlyRenderingComp to the NotesApp component while it is rendering.
  3. NotesApp iterates the notes array and renders a Note for each note.
  4. The Proxy around notes intercepts all get operations and saves the fact that NotesApp uses notes.length to render. It creates a (notes, length, NotesApp) relation.
  5. The user adds a new note, which changes notes.length.
  6. Our reactive core looks up all components in relation with (notes, length) and re-renders them.
  7. 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.

Memory leak

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.

  1. I finally fixed the memory leak.
  2. 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.

Error

Unfortunately, Proxies are also invalid receivers in these cases and Proxy wrapped objects throw the same error.

Proxy 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)