Skip to content

Instantly share code, notes, and snippets.

@NE-SmallTown
Forked from sebmarkbage/WhyReact.md
Created December 6, 2019 03:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save NE-SmallTown/5e8ffe22d5ac4cfc0d00f10b3da482d3 to your computer and use it in GitHub Desktop.
Save NE-SmallTown/5e8ffe22d5ac4cfc0d00f10b3da482d3 to your computer and use it in GitHub Desktop.
Why is React doing this?

I heard some points of criticism to how React deals with reactivity and it's focus on "purity". It's interesting because there are really two approaches evolving. There's a mutable + change tracking approach and there's an immutability + referential equality testing approach. It's difficult to mix and match them when you build new features on top. So that's why React has been pushing a bit harder on immutability lately to be able to build on top of it. Both have various tradeoffs but others are doing good research in other areas, so we've decided to focus on this direction and see where it leads us.

I did want to address a few points that I didn't see get enough consideration around the tradeoffs. So here's a small brain dump.

"Compiled output results in smaller apps" - E.g. Svelte apps start smaller but the compiler output is 3-4x larger per component than the equivalent VDOM approach. This is mostly due to the code that is usually shared in the VDOM "VM" needs to be inlined into each component. The trajectory is steeper. So apps with many components aren't actually smaller. It's a tradeoff is scalability.

We hit a similar trajectory with the work we did on Prepack. That also had other problems but this is ultimately the reason we abandoned that particular approach. A lot of the win new frameworks see comes from a leaner ecosystem outside the core library. Similar to how the Preact community's app often are smaller just by virtue of excluding other community libraries and avoiding solving problems small apps don't hit... yet. React is working on how to scale it so we can have rich components but still initialize them lazily and responsibily scale up.

"DOM is stateful/imperative, so we should embrace it" - Arguably this is an argument against all modern frameworks becaues even when they have a state mutation model their view composition goes a reactive declarative API (e.g. templates), so that mismatch is still there somewhere.

Interestingly though, the DOM is imperative today but Jetpack Compose and SwiftUI are seeing wins by creating systems that take advantage of not exposing a fully imperative API in the core platform so maybe we'll see the same from browsers eventually.

"React leaks implemenation details through useMemo" - React relies on referential equality to track changes. (x1, x2) => if (x1 === x2) update(x2). Other libraries tend to model this through a dirty bit. (x, xChanged) => if (xChanged) update(x). This is the fundamental difference. Either way this implementation leaks either through referential equality or through change tracking APIs.

Referential equality you can mostly express pretty easily in your own code. E.g. if you do something like:

setUsers([...users.filter(user => user.name !== 'Sebastian'), {name: 'Sebastian'}]);

You can just pass these referentially equivalent objects around through arbitrary code and components that can compare them. E.g. if I later on pass any of these users to a component like <User user={user} />, then that component can still bail out due to referential identity.

If you do this in a system that use syntax sugar like Svelte:

users = [...users.filter(user => user.name !== 'Sebastian'), {name: 'Sebastian'}];

You've lost that most users were equivalent when they get passed around.

So you have to ensure that all your data structures that you use and those over your helps are tracked, e.g. with runtime proxies, to preserve the change tracking and do stuff like users.value.push({name: 'Sebastian'}).

Change tracking on the reader side is so involved that libraries that employ it usually hide it away behind compilers (a reason they often end up having to rely heavily on templates).

However, the recent heavy use of useMemo/useCallback in React has a similar effect. I have some ideas to auto-add useMemo/useCallback using a Babel compiler plugin so that you don't have to think about it same as how templating languages do it. But you always have the ability to do the memoization yourself too rather than being hidden in compiler magic. The nice thing about this approach is that it also use with external helpers that produce immutable values like the .filter(...) function above.

Note that this approach might have a small negative file size approach so the trick is finding a good balance between update performance and avoid scaling up at 3-4x file size but I'm optimistic.

"Stale closures in Hooks are confusing" - This is interesting because our approach really stems from making code consistent. This sometimes means making it equally confusing in all cases rather than easy in common ones. The theory is that if you have one way of doing it, you prepare your mental model for dealing with the hard problems. Even if it takes some getting used to up front.

One way this shows up is batching. Imagine this in a mutable reactive system:

<Foo onBar={() => { if (this.count < 10) this.count++; }} />

Is that equivalent to this?

<Foo onBar={this.count < 10 ? () => this.count++ : null} />

There are many subtle patterns similar to this.

Because of batching, if you invoke this within the same batch props.onBar(); props.onBar(); they're not equivalent since the render won't rerender. Most declarative systems like CSS layout or updating templates are batched because it's good for performance when multiple mutations overlap. It leads to this kind of quirks in all these libraries. The more you increase batching (e.g. concurrent mode batches more aggressively) the more cases can fall into this category. React addresses this by making the count stale in both scenarios instead of just one, forcing you to address it consistently.

Another case is referring to a value inside an asynchronous operation like:

useEffect(async () => {
  let data = await fetch(...);
  if (count < 10) { // stale count
    doStuff(data);
  }
}, []);

This is an example of a stale closure in React hooks because count could've updated between the mount and when the fetch returns.

The way to solve this in a mutable system is to make the count read from a mutable closure or object. E.g.

onMount(async () => {
  let data = await fetch(...);
  if (count.currentValue < 10) { // reactive/mutable count
    doStuff(data);
  }
});

However the issue with that is that now you still have to think about everything that might be accidentally closed over. E.g. if you hoist the value out to a named variable for readability:

onMount(async () => {
  let isEligible = count.currentValue < 10; // reactive/mutable count
  let data = await fetch(...);
  if (isEligible) {
    doStuff(data);
  }
});

So you can easily get into the same situation even with a mutable source value. React just makes you always deal with it so that you don't get too far down the road before you have to refactor you code to deal with these cases anyway. I'm really glad how well the React community has dealt with this since the release of hooks because it really sets us up to predictably deal with more complex scenario and for doing more things in the future.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment