React-hooks RFC QA

https://github.com/reactjs/rfcs/pull/68#issuecomment-439314884

So I've read through all the comments here and elsewhere and I wanted to write up a summary of my take aways.

Overall, it must be said that reception has been very strong. It's popular. It works. People are using it all over the place and in production. The name and use convention seem to have spread very well and been directly adopted by other libraries. That doesn't mean that other variants couldn't have worked too but I think it's safe to say that the current design isn't a total failure.

The main concerns around the mechanism breaks down to the injection of the actual implementation of hooks and the reliance of persistent call order. Some want one of these changed, or both. The "purest" model being something like monads.

Injection Model
Basically the argument breaks down to wanting to swap out the code that implements the hooks. This is similar to just the general dependency injection and inversion of control problem. React doesn't have its own dependency injection system (unlike, say, Angular). Often it doesn't need one because most entry points are pull, not push. For other code, the module system already provides a good dependency injection boundary. For testing we tend to recommend other techniques such as mocking at the module system level (e.g. using jest).

A few exceptions to this is APIs like setState, replaceState, isMounted, findDOMNode, batchedUpdates etc. A little , nown fact is that React already uses dependency injection to insert the "updater" into the Component base class. It's the third argument to the constructor. That Component doesn't actually do anything. This is what lets React have multiple different types of renderer implementations at different versions in the same environment like React ART or React Test Renderer. Custom renderers take advantage of this already.

In theory, third-party libraries like React-clones could use the updater to inject their implementation. In practice, most of them tend to use module shimming to replace the whole react module instead because there are other tradeoffs or other APIs that they want to achieve (e.g. removing the development mode stuff or merging base classes with implementation details).

Both of these options still remain in a Hooks world. The Hooks implementations are not actually implemented in the react package. It just calls the current "dispatcher". As I explained above, that can be temporarily overridden to whatever implementation you want at any given point. That's what React renderers does to have multiple rendererers all share the same API. E.g. you can have a hooks test dispatcher just for unit testing hooks. Currently it's behind a scary looking name but we can easily change that name, it's not a flaw of the design. Now the "dispatcher" could be moved into user space but this adds additional noise for something that almost never is relevant to the author of an individual component, just like most people here didn't know about the "updater" in React.

Overall though, we'll probably be moving to more static function calls because they work better for tree-shaking techniques and can be better optimized and inlined.

The other concern is that the main entry point for the hooks is on the react package instead of something third party. It is likely that other things will move out of the react package in the future and that hooks is most of what remains. So the bloat shouldn't be considered a concern. The only issue is then that these hooks go under the "react" brand rather than something more generic. E.g. Vue has considered a hooks API. However, the key to hooks is that the primitives are well defined. At this point Vue has completely different primitives and we've iterated on ours. It is likely that other libraries will come up with slightly different primitives. At this point it doesn't make sense to make these overly general too early. The fact that this first iteration is on the "react" package is just to illustrate that this is what our vision of the primitives are. If there tend to be overlap there is nothing stopping us from consolidating with other libraries on a third party named package and have the react ones forward to that package.

Reliance on Persistent Call Index
To be clear, the reliance of order of execution is not really want we're talking about. It doesn't matter if you put useState or useEffect first or anything like that.

React has plenty of patterns that rely on order of execution just by virtue of allow mutation within a render (which still makes the render itself pure).

let count = 0;

let children = items.map(item => {
count++;
return <Item item={item} key={item.id} />;
});

let header = <Header>Number of items {count}</Header>;
I can't just change the order of children and header in my code.

Hooks doesn't care about which order you put them, it cares that they have persistent order. Same one every time. This is very different from implied dependency between calls.

It would be better not to have to rely on persistent order - all things being equal. However, there are tradeoffs. E.g. syntax noise or other things being confusing.

Some think that it is worth the cost for puritism reasons alone. However, some also have practical concerns.

One concern is that it will be confusing. This can take happen at many levels. I think it is safe to say at this point that this isn't confusing to the point that people are just completely clueless or give up. In fact, it has been remarkably easy for people to pick up the basics.

A stronger concern is that it'll be hard to figure out what went wrong when something does go wrong. Even if you understand how it works, you can still make mistakes and in those cases you need to be able to easily figure out what went wrong and fix it. We have found a few of those issues. Almost always it would've been caught by the lint rule and its message has been sufficient to explain why. There are however more we can do there. We can make compiler hard error. We can trace more information about the hooks in development mode and warn for switching up the order. We can do some more work on the error messages in those cases to show the stacks that changed instead of just showing that something changed. There's a trend in type systems to model effects, such as Koka. I'd bet it's only a matter of time until this comes to JavaScript.

The other issue is that if these constraints makes it harder to write certain code. For normal conditional code this doesn't seem to be the case. It's typically pretty easy to refactor. The lack of early returns is a bit of an annoyance but not a big deal. There are other issues with Hooks that are hard but they're not related to the ordering issue.

However, for Hooks in loops, it can be pretty annoying to refactor it. The solution is often to break the loop body out into a separate function. This is inconvenient since you need to pass everything as props that otherwise would be implied by the closure scope. This is a larger issue in React that is not limited to Hooks. It is always best-practice to do this. It helps optimizations such as keeping the render cost down of changing a single item in a list of items. It ensures that each child can bail out independently. With Suspense it means that each child can fetch and render its dependencies in parallell instead of in sequence. Error boundaries has similar requirements. So regardless of solution to the Hooks issue alone, it'll still be best practice to split out loop bodies into separate components - which also solves the Hooks issue for loops.

That said, the very first implementation of Hooks had a way to create a keyed nested scope which we used as a compiler target. These does create a mechanism to support Hooks in a nested way. It's just not very ergonomic and easy to explain, and it suffers from the problems above anyway. We could add it in the future though if needed. It just shouldn't be the common case.

When looking at various alternative solutions, they each come with a number of downsides on their own.

Many of them still don't support loops. This is the biggest limiting factor about the unconditionality of Hooks and seem to be overlooked by many proposals. Just making it usable in conditions on its own isn't as valuable.
Many don't account for custom hooks being the common case. We see this as a very important goal to make lightweight both in terms of performance and syntax.
Once you do allow for conditions, some things start getting weird. E.g. you might see useState in a condition, but what does that mean? Does it mean that it is just scoped to be used within that block or does it also mean that its life time changes with? What does it mean to write if (c) useEffect(...)? Does that mean that the effect fires when the condition turns true or does it mean that it fires every time while the effect is true? Does it unmount when it's not or does it continue to follow the life-time of the component?
For proposals like, declaring hooks outside of the body, what does it mean to call a hook multiple times? Just because you technically can, doesn't make it less confusing.
Many of them use a lot of indirection and virtual dispatching which is hard to statically analyze which makes dead-code elimination, inlining and other types of optimizations harder. The current hooks proposal is very efficient given that it has only indexed properties, can be trivially minified and it has predictably O(1) lookup costs. Remember that file size matters and this is a place where the current design really shines.
As a side-note. One thing people have mentioned is that they're concerned about global state for concurrency. If JS supported threads in the future, or if we managed to compile to something that does, we'd like to be able support parallel executions of multiple components. However, this is a non-issue in practice because the little state we store to track the currently executing component and current hook index can easily just go into thread local storage - which is always a requirement in these solutions in some form anyway. Whether it is a mutable field or algebraic effects.

Debugging
A general concern is what the debugging story is going to be like. There are a few angles to that.

One is what the error story is. We have some work to make the error messages better. We should at least be able to good error handling when a hook rule violation is detected in DEV.

Breakpoint debugging becomes very easy because it's just using the normal execution stack. Unlike, what React normally does. Some of the alternative proposals would use much more indirection which would've made this harder.

Another issue is reflection of the tree. React DevTools allow you to inspect the current state of anything in the tree. In production it is common for things like class names to be minified. It's likely that as we add more production optimizations, like inling and dropping unnecessary props object, more things like this won't just automatically work without something like a source map. We don't believe in adding meta data to the API design for debugging purposes that would follow to production. However, many things can be done for development mode, just auxiliary meta data (like source maps) etc.

That said, we've already shown that we can extract a lot of reflective meta data using the Debug Tools API. We also plan to add more for having libraries have a good extension point for providing even richer reflective data for debugging purposes, or parsing of source lines to add names to individual useState calls.

Testing
It's clear that a testing story is important and we need to clearly document it before launching broader. That has been clear.

As for the technical details, I think the dependency injection point described above shows how you would do that.

I think there's a sense in API design that there's such a thing as a "testable" API. When people say that, I think they have in mind that something like a pure function with a few input variables that can be tested in isolation. React APIs are temptingly simple that you'd think that you would just want to call a render function directly or an individual hook directly.

Unfortunately, the richness of the API has a lot of subtleties. You don't always depend on it so you can often special case it or side step it in a simple test. However, as your code base grows, you hit more and more of these case and you don't want to reimplement all the subtleties of the React runtime in each of them. So you want a testing framework.

E.g. we built the shallow renderer for this use case for classes. It allows you to invoke all the life-cycles in the right or with the right semantics and all that. The same will be necessary to test all the subtleties of Hooks primitives.

However, in practice, we see that people don't use shallow renderer much. It's much more common to use a deeper render because the unit of work that you're testing usually depend on a few levels down to be useful to test. That already works today.

That said, we'll also include a way to test a custom hook directly in isolation from a component. All we to do is add something that mocks out the dispatcher and keeps the semantics of the primitives consistent.

API Design
useReducer
Will this replace Redux? Will it add burden to have to learn all that Flux stuff? Reducer is a much more narrow use case than many Flux frameworks in general. It's very simple. However, if you look at the direction of frameworks/languages like Vue, Reason, Elm. This general pattern of dispatching and then centralizing the logic to transition between states at a higher level seems to be having great success. It also solves many quirks with callbacks in React, leads to many more intuitive solutions around complex state transitions. Especially in a concurrent world.

In terms of bloat, it doesn't add any code that isn't already needed in React anyway. In terms of concept, I think it's a valuable concept to learn since the same pattern keeps popping up all over the place in various forms. Better to have one central API to manage it.

So I see useReducer as the central API more so than useState. useState is still nice since it's very concise for simple use cases and easy to explain, but people should probably look into useReducer or similar patterns early on.

That said, it also doesn't do many things that Redux and other Flux frameworks do. Often I think you just won't need it, so it'll likely be a less ubiquious pattern than it is today but it'll still be around.

Context Provider
It has been mentioned that ideally Context Provider shouldn't be exposed from a module when you only want to expose a way to Consume a Context. Seemingly useContext encourages you to expose the Context object. I think the way to do this is by exposing a custom hook for consumption. useMyContext = () => useContext(Private) This is better in general since you have the option to add custom logic, change it to be global or add deprecation warnings afterwards. It doesn't seem to be something the framework needs further abstractions to enforce.

One thing we could consider is making createContext return a hook directly to encourage this being the common pattern. [MyContextProvider, useMyContext] = createContext()

The other quirk about Context Provider is that there is no way to provide a new context using a hook. That still requires a wrapper component. A similar issue is that you can't attach event listeners to the current component via Hooks, or something like findDOMNode.

The reason for this is that Hooks, by design, are either self-contained or only observe values. This means that using a custom Hook can't affect anything in your component that you don't explicitly pass to that Hook. It never drills through any abstraction levels. It also means that order dependence doesn't matter. The only exception to this is if you're dealing with global mutable state like traversing the DOM. That's an escape hatch but not something that you're expected to abuse in the idiomatic React world. This also means that using Hooks are not order-dependent. In that it doesn't matter if call useA(); useB(); or useB(); useA();. Not unless you explicitly create a dependency by sharing data. let a = useA(); useB(a);

useEffect
By far the quirkiest Hook is useEffect. To be clear, it is expected that this is by far the hardest Hook to use since it's interoping with imperative code. Imperative code is hard to manage. That's why we try to stay declarative. However, moving from declarative to imperative is hard because declarative can handle many more different types of states and transitions per line of code. When you implement an effect you should ideally handle all of those cases too. Part of the goal here is to encourage handling more cases. So some quirkiness is ok if it does that.

The second argument is quirky no doubt. The reason it is the second argument and not the first is because for all these methods it is possible to write your code without it first and later add it. The nice thing about that property is that you can have a linter, code refactoring tool in IDE, or compiler automatically add it based on your callback. This is a lesson learned from C# where the order of syntax is designed to support features like auto-complete.

Maybe it should take a comparison function too. I haven't seen a case where you can't rewrite it as inputs yet but regardless if we add a comparison function we can possibly do that later. That will still need an inputs array so we know what to store and pass to the comparison function.

Using async functions as effects is not allowed atm. Meaning you have to jump through some hoops to do async clean up. Asynchronous effects are very difficult to get right since anything could happen between those steps. It also wouldn't be possible to clean up before you initialize the new effect. That's a property that otherwise holds for effects. It is possible we could relax that constraint in the future but I suspect it might be a bad pattern and maybe we shouldn't encourage it in the very first release.

The quirkiest thing about useEffect is the use of closures. This causes confusing semantics with regard to what value is actually going to be read at any given time. Because the closed over values are not actually reactive. They capture the state as it was. This is actually a nice property though. Due to batching and concurrent mode, there are many scenarios where things gets interleaved in unexpected ways. Captured values does cause bugs upfront due to counter intuitive closures, but once fixed they tend to have fewer race condition issues.

Another issue is the memory usage issue. I'd say that React's memory usage is pretty unpredictable in general since we memoize essentially everything in the tree. However, due to closures sharing execution environments it can lead to extra counter intuitive retension. These can always be solved by making it a custom hook but it's not always obvious that you have to. An optimizing compiler that is aware of this pattern can also trivially fix this.

One possible solution to this problem is that we could require useEffect to be the same function and instead pass all the input arguments as arguments to the function. Encouraging them to be hoisted out. This is problematic since the benefit of closures is how convenient it is to refer to computed values. Other patterns are encouraged to use closures. So this seems to undermine the idea that everything moves into the body of the function. That in turn solves other problems, like default props, computed values etc. I'm not sure that is worth it for the few remaining cases that will retain more than otherwise.

Missing APIs
As several have pointed out there are some missing APIs.

The second argument to setState doesn't work well in this model. Similarly we don't yet have something like UpdateWithSideEffects in ReasonReact. We have some ideas for how this could work though and think we can fit this in after the fact. E.g. using an emitEffect call in a reducer.

We don't have a way to bailout from within a single component due to a state transition. If we did have this, then we might in turn need something like forceUpdate to by-pass it for mutations.

We don't yet have an alternative to getDerivedStateFromError and componentDidCatch but we have an idea for an HOC that provides this functionality. E.g. catch((props, error) => { if (error) setState(...); useEffect(() => { if (error) ... }); }). That'll be added later.

There has been questions about if there could be lower level API to implement specific semantics for other languages like ClojureScript or Reason. This is something we'll definitely want to support but I'm not sure this should be done using the same mechanism as the public API. E.g. an optimizing compiler for React will need a different entry point to target that format. That mechanism can be used for a variety of lower level operations that doesn't have to optimize for ease of use. So we'll likely add that separately from the public API.

Types
I believe most JavaScript type concerns have been addressed given that both Flow and TypeScript has definitions now.

A more interesting question that hasn't been raised yet is if this can be soundly typed in other languages, like Rust or Reason, even in the case where call order mistakes happen. This hasn't yet been proven - at least not without runtime cost.

Compiler Optimizations
There was some concern about these functions not being pure breaking compiler optimizations. I think I can refute that. We do have a number of optimizations that we still wish to do at runtime or statically.

We actually pushed harder to get Hooks out because it is very suitable for optimizations. It carefully encourage many statically resolvable patterns.

Two of those optimizations is around merging components. For a component that is rendered unconditionally by a parent, this is trivial hooks. You can literally just call through. You can do this optimization in user space. Even for loops and conditions, we know how to add scopes to these which allows the same thing. Even dynamically rendered components we can skip creating additional Fibers, e.g. when a chain of parent components render a single child that also is a function component. We only need to keep track of where in the sequence the switches happen in case the function type changes.

Regarding memoization based optimizations, that still works. In a language with algebraic effects, memoizing functions simply just have to memoize the effects as well. The same thing applies here. Memoization just keeps track of what Hooks were issued during the call.

Many alternative that use objects, or pass first class functions need to be unwrapped in ways that actually make optimizations much harder due to their indirections. Generators is the hardest one.

Security
One concern raised here is about the overloaded API in setState that takes a function or a value of a function. This was a tough design decision and we weighed many tradeoffs. It's true that overloaded APIs sometimes cause unpredictable security problems. We've had one issue with that ourselves because children accepts a string or an element. That said, many overloaded APIs are also very useful abstractions that doesn't lead to security issues. I'll dig into this one a bit more to make sure we've done a reasonable evaluation of the risk.

The other issue that hasn't been raised but I should note. If you assume that a third-party Hook is untrusted, it can conditionally add/remove its state hooks and therefore start reading past the end of its Hooks. Allowing it to read state from the outer component. If you can execute code, normally all bets are off, but in a Caja/SES like environment this might be relevant. This is an unfortunate property.

Motivation
Why do all of these particular hooks have to be in core? Most of them don't really add significant cost in terms of bytes since all the mechanisms have to exist regardless. It's more about the conceptual overhead. Many of them are primitives that can't be implemented in user space or provides significant value by describing intent.

For example, useMemo could be made in user space today, but we'd like to be able to in the future keep state while throwing away memoized values. E.g. during a low memory situation or windowing components.

useCallback is just a simple wrapper around useMemo, but we have ideas for how we could optimize that further in the future, either statically or using runtime techniques.

useImperativeMethods provides an API that could be built in user space, but since we have several different ways to interact with refs, it is helpful to keep them maintained in a single canonical way. We've already changed refs twice in the past.

One argument that I keep hearing is that the motivation isn't strong enough because "classes are fine". Supposedly, it's the users trying to learn them that are broken. I think this argument is overfocusing on some clip that maybe someone has highlighted around how classes are hard to learn for newcomers. That's not the point.

The main motivation is that patterns like closures naturally creates copies of values which makes writing concurrent code a lot easier because you can store n number of states at any given point instead of just one in the case of a mutable class. This avoids a number of foot guns where classes seem intuitive but actually yield unpredictable results.

Classes may seem like the ideal thing to hold state since that's what they're designed for. However, React is more written like a declarative function that keeps getting executed over and over to simulate it being reactive. Those two things have an impedence mismatch and that keeps leaking when we think of these as classes.

Another issue is that classes in JS merge both methods and values on the same namespace. This makes it very hard to make optimizations because sometimes methods behave like static methods and sometimes behave like values that contain functions. The Hooks pattern encourages the use of more statically resolvable calls for helper functions.

In classes, each method has its own scope. It causes issues like us having to reinvent default props so that we can create a single shared resolved object across those. You also encourage sharing data between those methods using mutable fields on the class since the only shared thing is this. This is also problematic for concurrency.

Another issue is just that the conceptual mental model for React is just functions calling other functions recursively. There is a lot of value to express it in those terms to help build the correct mental model.

One concern that I highly sympathize with is that this will just be additional to the mental overhead of learning React - in the short term. That is because you're likely going to have to learn both Hooks and classes for the forseeable future. Either because you use both, your code base has classes from before or written by others, because an example you read on stackoverflow or a tutorial used classes, or because you're debugging a library that uses it.

While it will take many years. The bet is that one of these approaches will win. Either we'll have to roll back Hooks or classes can be less used overtime until they can be completely out of sight, out of mind.

I think it's fair criticism that we haven't provided a clear answer on this or a predictable roadmap for when classes could reasonably be removed from core and externalized as a compat layer. I don't think that's going to happen any time soon until we see if Hooks is actually better. Once that happens we can see if there is a timeline that classes could be deemphasized.

tag(s): none
show comments · back · home
Edit with markdown