Skip to content

Instantly share code, notes, and snippets.

Created September 4, 2019 20:33
Show Gist options
  • Save sebmarkbage/a5ef436427437a98408672108df01919 to your computer and use it in GitHub Desktop.
Save sebmarkbage/a5ef436427437a98408672108df01919 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 => !== '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 => !== '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
}, []);

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

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

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.

Copy link

porfirioribeiro commented Sep 6, 2019

@FredyC in that case that you want to inline the function, probably you could just ignore the args as now?

function User({userId}) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null); 

  useEffect(() => {
    fetchUser(id).then(setUser, setError);
  }, [userId, setUser, setError]);

My opinion on this matter

I'm using React for 3 years and embraced hooks since they where first announced, i think the developer experience is great!
But hooks does not come for free!

function Test() {
  const [input, setInput] = useState("");

  function handleInputChange(e) {

  function handleFormSubmit(e) {
    console.log("submit", input);

  return (
    <form onSubmit={handleFormSubmit}>
      <input value={input} onChange={handleInputChange} />
      <input type="submit" />

Code like this looks clean and concise, but it smells! Because all those declarations are done again every keystroke i press and the old closures need to be ditched by GC at some point.
I know that in latest Chrome V8 does a great job with GC and the cleanup is fast, still it smells and not everyone has high end devices and high optimized JS VM's
Also it would detach and attach event listeners to DOM elements but React includes this big Synthetic event layer to avoid that.

But if at some point your children is heavy render and you memo it, you will need to introduce another escape hatch, useCallback to make sure you always send the same closure to the props of the child.

I think @yyx990803 pointed all the pros and cons for the 3 approaches very well

Copy link

danielkcz commented Sep 6, 2019

@porfirioribeiro I think this was sufficiently covered in FAQ. Consider that React is actually creating a bunch of objects (createElement) on each render. In JS there is not that big difference between object and function. So unless you have some real experience where recreating functions would be a bottleneck then it's not worth to be worried about it. I would even say that there are several ways how to hinder performance just by bad practices.

Copy link

I'd add synthetic benchmarks that test absurd scenarios like doing updates on 10k row tables under browser simulated CPU throttling etc still have Hooks performing comparable if not a bit faster than Class Components. I wouldn't be concerned with Hook performance specifically. Google it. In many cases Hook solutions are more performant.

Copy link

@spion 👍 🦄 💯

Automatic dependency tracking on the other hand has been an enormous productivity booster.

It's the ultimate productivity booster. This entire problem space is about recomputing things when there is a change in something they depend on. ADT solves that problem. Everything else then becomes child's play.

Having to pass an array of volatile closed-over values to useEffect or useMemo is tolerable in little toy examples, but tediously error-prone in more complex real-world cases.

Copy link

Thanks for making this write up!
Small write-up on my thoughts:

Copy link

beders commented Sep 9, 2019

And here I'm sitting in my re-frame corner snickering a bit. All this looks like a solved problem.
What isn't a solved problem is the FRIGGING DOM.
Instead of working around this monster with ever new, slightly different, slightly faster or slower frameworks, why aren't we asking browser vendors to fix the cause?

Copy link

Been using React 3 years, enjoying Svelte a lot more the last 1 month. I even made some videos:

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