Skip to content

Instantly share code, notes, and snippets.

@sebmarkbage
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 => 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.

@Rich-Harris
Copy link

Thanks for writing this. Since it addresses Svelte specifically, I ought to just respond to these two points briefly:

  • the compiler output is 3-4x larger per component than the equivalent VDOM approach This is true, of course, in the sense that Svelte currently generates output code that is noticeably larger than the input code. I'm not sure it's a limitation of compiled output generally. We're optimising for small code-split chunks, but it would certainly be possible to compile to something that resembled React's approach, or even Glimmer's. This is a topic we intend to get around to writing more about
  • If you do this in a system that use syntax sugar like Svelte...You've lost that most users were equivalent when they get passed around This is a deliberate design choice, since we didn't want to enforce immutability on programmers who aren't ready for it. But you can tell a component (or the entire app) to expect immutable data and it will use straightforward referential equality tests instead of assuming the worst

@josepot
Copy link

josepot commented Sep 4, 2019

I love the hooks API and I'm very thankful to you and to the whole React team for it. However, there is one decision that I have a hard time understanding: why aren't the dependencies passed as arguments to the hook function? According to the docs that's what "conceptually they represent":

The array of dependencies is not passed as arguments to the effect function. Conceptually, though, that’s what they represent: every value referenced inside the effect function should also appear in the dependencies array.

If they were passed as arguments then we could define those functions outside of the component function, which IMO has a couple of potential advantages:

  • Avoids problems with stale closures
  • A slightly better performance by not creating new functions on each update?

I just have a hard time understanding why the React team seems to intentionally want to prevent code like this:

function loadUser(id, setUser, setError) {
  fetchUser(id).then(setUser, setError);
}

function User({userId}) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null); 
  useEffect(loadUser, [userId, setUser, setError]);
  
  if (error) {
    return 'Error';
  }
  
  if (!user) {
    return 'Loading...';
  }
  
  // TODO: render the user
}

I already know that I could create custom hooks that behave like that, but I don't want to because:

  • I don't want to increase the API surface for no good reason.
  • I don't want to do things in a "non standard"/"weird" way.
  • If this gets done via a custom hook then the perf would actually become slightly worse.

Could you please elaborate on the reasoning for deliberately not passing the dependencies as arguments? 🙏

Thanks a lot!

@Lucifier129
Copy link

Lucifier129 commented Sep 5, 2019

Nice article to read!

In practice, React also has a mutable part too, such in useEffect and event-handler

// the mutable part in React
const Test = () => {
  let ref = useRef()

  useEffect(() => {
     // react mutate ref.current after rendering
    // we can perform side-effects like data-fetching, dom-manipulation and so on.
  })

  let handleClick = event => {
    // impure code go here
  }

  return <div ref={ref} onClick={handleClick}>click me</div>

}

And there are many libraries in React community try to combine mutable + change tracking and immutability + referential equality .

Like immer

import produce from "immer"

const baseState = [
	{
		todo: "Learn typescript",
		done: true
	},
	{
		todo: "Try immer",
		done: false
	}
]
// immutable world
const nextState = produce(baseState, draftState => {
        // mutable world
	draftState.push({todo: "Tweet about it"})
	draftState[1].done = true
})

Like bistate(I am the author).

import React from 'react'
import { useBistate, useMutate } from 'bistate/react'

export default function Counter() {
  // create state via useBistate
  let state = useBistate({ count: 0 })

  // safely mutate state via useMutate
  let incre = useMutate(() => {
    state.count += 1
  })

  // immutable world
  let decre = useMutate(() => {
    // mutable world
    state.count -= 1
  })

  return (
    <div>
      <button onClick={incre}>+1</button>
      {state.count}
      <button onClick={decre}>-1</button>
    </div>
  )
}

With them, I feel it is so good to mutate state in React mutable part.

@tiye
Copy link

tiye commented Sep 5, 2019

It's a tradeoff is scalability.

...in scalability?

@realflavioc
Copy link

@josepot I love that idea

@ryansolid
Copy link

All the mutable + change tracking approaches mentioned here tend to all get hatched into some coarser grained system anyway. Svelte still handles its updates at the Component level (keeps code small) and Vue feeds into a Virtual DOM.

I agree there are 2 approaches. I respect React embracing what it is to push the approach forward. If only the popular end of mutable side was as progressive. Instead we end up with Vue, always teetering on the right rope, not wanting to ever choose a side that isn't smack in the middle.

There has been progress on the mutable side taking it all the way down to the rendering showing incredible performance. But this isn't the forum for me to self promote.I just want to express that I'm fully onboard with what React's doing. It makes an incredible amount of sense and opens up the potential for incredible things. The React team and their accomplishments carries my continual awe and respect. And that at the same time there are solid mutable solutions out there just under the surface. And that's great too.

@ivancuric
Copy link

Warning: snark ahead.

So React is trading performance for pseudo-immutability and introducing a dozen ways to cache and break immutability to use as an escape hatch in order to keep apps either working properly on the platform or to have some semblance of performance.

@olimsaidov
Copy link

@josepot Very interested

@djagya
Copy link

djagya commented Sep 5, 2019

I always wonder what would be the way to extract a useEffect handler function beside creating a custom hook (that would be very specific anyway and used by just this one component).

So the question about passing hook deps as a function args asked by @josepot seems actual to me, too.

@thysultan
Copy link

@josepot Dyo does this but instead of apply-ing the arguments passes the array as is as the first argument:

useEffect(function([id, setUser, setError]) {
    // ...
}, [userId, setUser, setError]);

It avoids the potential arity disparity and dealing with fn#apply(), i don't see why React couldn't do the same.

@Aternus
Copy link

Aternus commented Sep 6, 2019

setFn functions are guaranteed to have the same identity FYI

@ryansolid
Copy link

ryansolid commented Sep 6, 2019

I wonder if React team is picturing a time when providing the explicit dependencies is optional and they don't want to be too hasty promoting that pattern of using function parameters in order to leave the door open.

Other than that I don't see why not. I've seen other libraries use the parameters for a reducing pattern(call with previous value), but the fact effects return disposal method means that will never be the case.

Maybe consistency. useCallback can have arguments.

@danielkcz
Copy link

danielkcz commented Sep 6, 2019

@thysultan @josepot Generally, I agree it would be super convenient to have deps in argument.

But I see a potential issue when you want an effect to run on every render (omit 2nd arg) then suddenly the effect would have to depend on closure again. It could be very confusing to have such different behavior imo. Those deps are just an optimization, after all, it shouldn't be used to declare different behavior.

Another "issue" is that it would be kinda forcing you to always declare effectFn outside the scope. If you would not, it would make code harder to read imo.

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

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

That effect is kinda drowned there and there is also obvious clash of variable names. Naming args differently from deps could be even more confusing.

@porfirioribeiro
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) {
    setInput(e.target.value);
  }

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

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

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
image

@danielkcz
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.

@ryansolid
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.

@danielearwicker
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.

@MarcoPolo
Copy link

Thanks for making this write up!
Small write-up on my thoughts: https://marcopolo.io/code/why-react-response/

@beders
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?

@quantuminformation
Copy link

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

https://www.youtube.com/playlist?list=PLCrwuqjmVebK08Cwz_XB55cNKFfFYOMGo

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