facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
229.42k stars 46.98k forks source link

Provide more ways to bail out inside Hooks #14110

Closed gaearon closed 5 years ago

gaearon commented 6 years ago

There's a few separate issues but I wanted to file an issue to track them in general:

gaearon commented 6 years ago

cc @markerikson you probably want to subscribe to this one

markerikson commented 6 years ago

Yay! Thanks :)

alexeyraspopov commented 6 years ago

useContext doesn't let you subscribe to a part of the context value (or some memoized selector) without fully re-rendering.

useContext receives observedBits as a second param. Isn't it the same?

gaearon commented 6 years ago

I guess you're right the context one is an existing limitation (ignoring the unstable part).

markerikson commented 6 years ago

@alexeyraspopov : nope! Here's an example:

function ContextUsingComponent() {
    // Subscribes to _any_ update of the context value object
    const {largeData} = useContext(MyContext);

    // This value may or may not have actually changed
    const derivedData = deriveSomeData(largeData);

    // If it _didn't_ change, we'd like to bail out, but too late - we're rendering anyway!
}

observedBits is for doing an early bailout without actually re-rendering, which means you can't locally do the derivation to see if it changed.

As an example, assuming we had some magic usage of observedBits in React-Redux:

Imagine our Redux state tree looks like {a, b, c, d}. At the top, we calculate bits based on the key names - maybe any change to state.b results in bit 17 being turned on. In some connected component, we are interested in any changes to state.b, so we pass in a bitmask with bit 17 turned on. If there's only a change to state.a, which sets some other bit, React will not kick off a re-render for this component, because the bitmasks don't overlap.

However, while the component is interested in changes to bit 17, it still may not want to re-render - it all depends on whether the derivation has changed.

More realistic example: a user list item is interested in changes to state.users, but only wants to re-render if state.users[23] has changed.

sam-rad commented 6 years ago

Perhaps a possible api would be:

function Blah() {
  // useContext(context, selectorFn);
  const val = useContext(Con, c => c.a.nested.value.down.deep);
}

And shallowEqual under the hood.

markerikson commented 6 years ago

@snikobonyadrad: won't work - the second argument is already the observedBits value:

export function useContext<T>(
  Context: ReactContext<T>,
  observedBits: number | boolean | void,
)

@gaearon : semi-stupid question. Given that returning the exact same elements by reference already skips re-rendering children, would useMemo() kinda already solve this?

function ContextUsingComponent() {
    const {largeData} = useContext(MyContext);
    const derivedData = deriveSomeData(largeData);

    const children = useMemo(() => {
        return <div>{derivedData.someText}</div>
    }, [derivedData]);
}
sophiebits commented 6 years ago

@markerikson Yes, but that means that ContextUsingComponent needs to know about this, even if you might otherwise want to put the two useContext+derive calls into a custom Hook.

markerikson commented 6 years ago

Yeah, I know, just tossing it out there as a sort of semi-stopgap idea.

Any initial thoughts to what a real API like this might look like?

Jessidhia commented 6 years ago

Crazy idea: add React.noop as a reconciler-known symbol, throw React.noop;

Not sure how that would mesh with this interrupting further hooks from running, and there is already a problem with the reconciler throwing out hooks that did already run before a component suspends.

ioss commented 6 years ago

I personally don't like noop, as I would expect it to do nothing. :) How about React.skipRerender or React.shouldComponentUpdate(() => boolean | boolean) or similar?

Also, it should be a call: React.whateverName(), which could then do whatever is needed (probably throw a marker, as you suggested) and especially ignore the call on the first render, which should probably not be skipped.

I also thought about the possibility to return early in the render method with a marker (React.SKIP_UPDATE), but that wouldn't work in custom hooks. On the other hand, skipping rerendering in custom hooks might be strange? What do you think?

dai-shi commented 6 years ago

Hi, I'm experimenting a new binding of Redux for React. For now, I use a workaround for bailing out. Does the scope of this issue cover this use case? https://github.com/dai-shi/react-hooks-easy-redux

brunolemos commented 6 years ago

I would enjoy something like this to avoid unnecessary re-renders:

const { data } = useContext(MyContext, result => [result.data])

where the second parameter would work like the second parameter of useEffect, except it's () => [] instead of [], with result being the response of useContext(MyContext).

Note: This is supposing the existing second param could change or react internally could check the typeof to see if it's a function or the observedBits

slorber commented 6 years ago

Hi,

I would also like the same api as @brunolemos describes, for using it with tools like Unstated which I use as a replacement for Redux store with a similar connect() hoc currently.

But I think there is ambiguity in your API proposal @brunolemos not sure exactly what happens if you return [result.data, result.data2] for example. If you return an array you should probably assign an array to the useContext result too?

Not sure exactly how observedBits works and could be helpful here, anyone can explain? If we have to create an observedBits it should probably be derived from the context value data slice we want to read no? so current api does not seen to do the job for this usecase.

What if we could do const contextValue = useContext(MyContext, generateObservedBitsFromValue)?

gnapse commented 6 years ago

won't work - the second argument is already the observedBits value:

@markerikson how official is this second argument? I see that it is not documented publicly yet.

I mention this because the api proposal mentioned by @sam-rad in this comment is what I was expecting it to be eventually, to solve the partial subscribing to context.

markerikson commented 6 years ago

@slorber : See these links for more info on observedBits:

@gnapse : The React team has said a couple times that they're not sure they want to keep around the observedBits aspect, which is why the Context.Consumer's prop is still called unstable_observedBits.

TotooriaHyperion commented 6 years ago

@snikobonyadrad: won't work - the second argument is already the observedBits value:

export function useContext<T>(
  Context: ReactContext<T>,
  observedBits: number | boolean | void,
)

@gaearon : semi-stupid question. Given that returning the exact same elements by reference already skips re-rendering children, would useMemo() kinda already solve this?

function ContextUsingComponent() {
    const {largeData} = useContext(MyContext);
    const derivedData = deriveSomeData(largeData);

    const children = useMemo(() => {
        return <div>{derivedData.someText}</div>
    }, [derivedData]);
}

Looks like useMemo can skip rendering wrapped in it, and the final update(by v-dom diff), but can't skip render function itself.

Personally,

I agree with using second argument as a selector with shallowEqual, since observedBits is a less common use case and selector can do what observedBits can do. Also some times the needed data is a combination of nested context value and props in view hierachy, especially in normalized data structure with a nested view, when we want only to check the result of map[key], instead of the reference of map or the value of key, passing a selector can be very convenient:

 function createSelectorWithProps(props) {
    return state => [state.map[props._id]];
 }

 function useContextWithProps(props) {
    return useContext(MyContext, createSelectorWithProps(props));
 }

 function ContextUsingComponent(props) {
     const [item] = useContextWithProps(props);

     // return ...........
 }

But how to handle using multiple context?

 function ContextUsingComponent(props) {
     const [item] = useContextsWithProps(props, context1, context2, context3);

     // return ...........
 }

Finally the problem focuses on [rerender after calculate the data]. Thus I thought we need to useState with useObservable. Observables trigger calculation, and shallowEqual the result, then set the result to local state. Just the same as react-redux is doing, but with a hooks api style.

danielkcz commented 6 years ago

I just found out about observedBits only thanks to @markerikson and somehow it felt like an awful solution. Working with bitmasks in JavaScript is not exactly something you see every day and imo devs are not really used to that concept much.

Besides, I find it rather awkward that I would need to kinda declare up front what my consumers might be interested in. What if there is a really an object (a.k.a store) with many properties and I would need each property to assign a bit number and most likely export some kind of map so a consumer can put a final bitmask together.

Well, in the end since I am a happy user of MobX, I don't care about this issue that much. Having an observable in the context, I can optimize rendering based on changes in that observable without any extra hassle of comparing stuff or having specific selectors. React won't probably introduce such a concept, but it could be one of the recommendations.

TotooriaHyperion commented 5 years ago

how about this

// assuming createStore return a observable with some handler function or dispatch
function createMyContext() {
  const Context = React.createContext();
  function MyProvider({ children }) {
    const [store, setState] = useState(() => createStore());
    return <Context.Provider value={store}>{children}</Context.Provider>
  }
  return {
     Provider: MyProvider,
     Consumer: Context.Consumer,
     Context,
  }
}

const context1 = createMyContext();
const context2 = createMyContext();

 function calculateData(store1, store2) {
    //return something
 }

function ContextUsingComponent() {
  const store1 = useContext(context1.Context);
  const store2 = useContext(context2.Context);
  const [calculated, setCalculated] = useState(() => calculateData(store1, store2));
  function handleChange() {
     const next = calculateData(store1, store2);
     if (!shallowEqual(next, calculated)) {
        setCalculated(next);
     }
  }
  useEffect(() => {
     const sub1 = store1.subscribe(handleChange);
     const sub2 = store2.subscribe(handleChange);
     return () => {
        sub1.unsubscribe();
        sub2.unsubscribe();
     }
  }, [store1, store2])

  // use calculated to render something.
  // use store1.dispatch/store1.doSomething to update
}
vijayst commented 5 years ago

Without React NOT providing an option to cancel updates especially from context changes and because using useReducer hook causes various design constraints, we have to resort to good old Redux. Wrote an article based on developing a Redux clone based on existing API - context and hooks which explains more.

Two things are clear.

  1. Can't use Context API for global state unless we create a wrapper component (HOC, defeating purpose of hooks)
  2. No way to share state across container components when we use useReducer hook. Very rare that an app has independent container components for useReducer hook to be effective.
TotooriaHyperion commented 5 years ago

@vijayst I think this shouldn't be implemented as a "cancel" operation, it should be implemented as a way of "notice" instead.

Finally the problem focuses on [check if we need rerender after calculate the data].

This is exactly what react-redux do. And I wrote a example above to implement a react-redux like mechanism. So let context to provide an observable is the convenient way to solve this problem. Rather than to "cancel" the update when we are already updating.

strayiker commented 5 years ago

Hello! I personally don't like the idea of providing a way to cancel a render that is alredy being performed because it make the behavior of the component non-linear and much more difficult to understand. Even more this aggravated by the fact that the cancellation may be performed inside a custom hook and will be hidden from your eyes.

The better solution, in my opinion, is to provide a way to subscribe to only what you exactly need.

How about to add subscribe and unsubscribe methods to the Context instance? Then useContext can be implemented like this:

const useContext = (Context, getDerivedValue) => {
  const [state, setState] = useState();

  const handleContextChange = (value) => {
    const derived = getDerivedValue(value);

    if (derived !== state) { // do not perform setState without need
      setState(derived);
    }
  }

  useEffect(() => {
    Context.subscribe(handleContextChange);
    return () => Context.unsubscribe(handleContextChange);
  }, []);

  return state;
};

Usage:

const Context = React.createContext();

const Component = () => {
  const value = useContext(Context, ctx => ctx.a.b.c.deep);
};

What about useState, it can check the strict equality of a previos and new values, and bailout the update. It should be enough to be able to do what we can do in a class components.

vijayst commented 5 years ago

I like @strayiker approach to useContext hook rather than bailing out. I am going to check if it works in my Redux clone - I already have a useStore hook which wants to do something like that.

vijayst commented 5 years ago

@strayiker So, i wrote an useStore hook (most likely how useRedux will be implemented later). Instead of subscribing / unsubscribing to Context (api does not exist now), I have some custom store helpers (like redux).

import { useEffect, useState } from 'react';
import { subscribe, unsubscribe } from './storeHelpers';
import shallowEqual from './shallowEqual';

let oldState;

export default function useStore(mapContextToState, initialState) {
    const [state, setState] = useState(initialState);
    oldState = state;

    useEffect(() => {
        subscribe(handleContextChange);
        return () => unsubscribe(handleContextChange);
    }, []);

    const handleContextChange = (context) => {
        if (typeof mapContextToState === 'function') {
            const newState = mapContextToState(context);
            if (!shallowEqual(newState, oldState)) {
                setState(newState);
            }
        } else {
            setState(context);
        }
    }

    return state;
}
strayiker commented 5 years ago

Another solution it's providing a way to ignore updates from nested hooks. May be something like another hook useShouldUpdate(hook, shouldUpdate). It can be used to avoid unnecessary updates when context/state is changing.

// Non-optimal hook that change state too often and subscribes to big context updates
const useHook = () => {
  const [state, setState] = useState(0); // frequently changed state
  const context = useContext(Context); // big context with many unnecessary fields, that changes too often

  useEffect(() => {
    const t = setInterval(() => setState(s => s + 1), 100); // 10 times per second + 1
    return () => clearInterval(t);
  }, []);

  return {
    state: parseInt(state / 10), // resulting state will be changed 1 time for 10 updates, but component will be re-rendered each time.
    slowValue: context.slowValue,
  };
};

// We use `useShouldUpdate` to bail out some of state and context changes 
const useDebouncedHook = () =>
  // Forces the component to ignore updates until the second argument will return true
  useShouldUpdate(useHook, (result, prevResult) => {
    return !shalowEqual(result, prevResult);
  });

Usage

const Component = () => {
  // will trigger re-render when state is multiple of 10 or slowValue was changed
  const { state, slowValue } = useDebouncedHook();

  return (
    <div>
      <div>{state}</div>
      <div>{slowValue}</div>
    </div>
  )
}

diagram

arackaf commented 5 years ago

What does useShouldUpdate do that causes the current render to be cancelled? Whatever it does, is what @markerikson needs access to for Redux.

However much you might think this increases complexity, there are numerous use cases where this would help a great deal.

Meligy commented 5 years ago

Would the ultimate solution for this, and issues around people drawing a mental picture of how the hooks run in every render, be to find a way to separate hooks from render somehow?

The component that uses hooks can return the render function. setState could return an object with get and set, and this object will be in scope of the render function:

export default HelloWorld = React.useHooks(props => {
  const name = setState('world');
  return () =>
    <div>Hello {name.get()}</div>;
});
markerikson commented 5 years ago

@arackaf : the point of this thread is that there is currently no way to bail out of the process of rendering a function component once it's started, and that that capability is needed before hooks can probably be finalized.

@Meligy : variations on that approach were suggested several times in the hooks RFC thread, and based on Sebastian's comments, I don't think they're going to alter the basic form of hooks to use that.

arackaf commented 5 years ago

@markerikson indeed - I may have been unclear. I was responding more to comments along the line of

I personally don't like the idea of providing a way to cancel a render that is alredy being performed because it make the behavior of the component non-linear and much more difficult to understand

followed by suggestions for a useShouldUpdate hook which would seemingly do the same thing.

Meligy commented 5 years ago

Ok. Sorry, I only caught up with most recent comments in RFC issue. And the approach sounded like a clear answer to "already rendering" issue because it would remove that situation completely. I fully respect if the people who came up with the idea and have way more context than me decided it's too late and/or a bad idea to alter the approach.

brunolemos commented 5 years ago

I'd like to read thoughts about this, specially from the core team

strayiker commented 5 years ago

@arackaf Not exactly the same. Suggested useShouldUpdate allow to avoid unnecessary rendering at all rather than cancel it when it's already performing.

dai-shi commented 5 years ago

Although this is kind of re-posting, I would like to understand what "bail out" means here (especially useState). Isn't it like "early return" in rendering?

I'm experimenting with a workaround using Exception and ErrorBoundary like this: https://github.com/dai-shi/react-hooks-easy-redux/blob/093003dc40905fa9ac80dd57777e2cd441c60b75/src/index.js#L87-L100 It can be too confusing as it breaks hooks rules.

markerikson commented 5 years ago

@dai-shi : ultimately, the goal is an equivalent of shouldComponentUpdate for function components. But, since a function component is just a function, and calling that function is re-rendering, then the function component has to start rendering in order to do anything.

dai-shi commented 5 years ago

@markerikson Thanks for the explanation.

What I could think of is:

import React, { KeepPreviousElement } from 'react';

const MyComponent = () => {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);
  const shouldUpdate = count > prevCount; // or whatever...
  if (!shouldUpdate) return <KeepPreviousElement />;
  return <div>{count}</div>;
};

This doesn't allow bailing out silently in a custom hook, though.


I also like @strayiker's useShouldUpdate style, but if I'd go for it, useShouldUpdate could be called several times in different custom hooks, and I'm not sure the behavior in this case (whether they are combined as logical AND or OR.)

strayiker commented 5 years ago

Here the POC of the useShouldUpdate that realized in user space with some hacks ¯\_(ツ)_/¯

Edit xo92v49yyz Open the console to see what happenning

dai-shi commented 5 years ago

Nice! Though, ideally, I'd expect a hook to be a simple fuction like:

const useGoodHook = delimiter => {
  const value = useBadHook(delimiter);
  const prev = usePrevious(value);
  useShouldUpdate((value, prev) => value !== prev, [value, prev]);
  return value;
};

Otherwise, we should call it differently from hooks (not use* convention). For example:

const useGoodHook = applyShouldUpdate(useBadHook, (value, prev) => value !== prev);
strayiker commented 5 years ago

Actually, yes, it should be called differently from hooks because it's not a hook.

dai-shi commented 5 years ago

BTW, my original proposition is something like this:

import React, { BailOutRendering } from 'react';

const useGoodHook = delimiter => {
  const value = useBadHook(delimiter);
  const prev = usePrevious(value);
  if (value !== prev) throw new BailOutRendering();
  return value;
};

(This might be too magical, though.)

The userland implementation of this is here and the codesandbox is here.

ruifortes commented 5 years ago

Why not just return "undefined" to bail out? Returning "null" already renders nothing "undefined" would just rerender the same thing.

I understand that hooks have to be called in the exact same order but it also seams that as long as you keep that order you can return early. Is it so?

Basically there would be no need for a context selector, just use the render function.

With just a simple hook to compare last values and a return undefined would do the trick.

I createad an example in sandBox. Edit pw9nnx3k20

"withBail" is a HOC that injects a bail function that returns last rendered value. The "useShouldUpdate" (that actually should be name "useShallowEqual") just shallow compares current and last values.

The App component provides the context and passes a prop to "MyComp" A counter and remaiders are used to change context data and prop at different times.

The changes to state are even done in useMemo not useEffect. Would this be a problem?

Seams simple enough. I'm I missing something?

markerikson commented 5 years ago

@ruifortes : I don't think return undefined would be selected as the approach, because it's too likely that someone might accidentally not return anything from their component (which also returns undefined). Goodness knows I've made that mistake myself.

ioss commented 5 years ago

At the moment, I am in favor of only rendering, when it is needed, opposed to bailing out while rendering. We'll always have the possibility to return the previously memoized child components.

To achieve less rendering, I'd say we need / should have:

While everything can be kind of solved with useRef, setState as forceUpdate, etc. or returning memoized children, I'd rather be able to use the base hooks without causing unwanted rerenders and don't have to use multiple hooks just to implement non-rerendering versions of the plain react hooks.

@ruifortes: Personally I wouldn't be to happy to give undefined as return value such an important meaning. Also returning something from render to mark "bail out", wouldn't allow customHooks to "bail out". As explained above I'd rather be able to write simple customHooks, which will only cause needed rerenders.

ruifortes commented 5 years ago

I agree that having this single "omnipotent" (and I don't mean idempotent) function called all the time is a little weird but I think it's inline with the new hooks paradigm that actually is a lot about memoization. I like the fact that React avoids "magic" and tries to be as pure and barebone as possible and although this "managed sequential memoization" seams a little magical it really isn't. (and correct me if I'm misusing definitions here cause my background is not in CS)

At the moment, I am in favor of only rendering, when it is needed, opposed to bailing out while rendering. We'll always have the possibility to return the previously memoized child components.

The don't think that you're "rerendering" just because you called this omni function if it doesn't put in motion any expensive processes like DOM diffing.

I would argue that some bailing mechanism is necessary because this "omni" function is not just about rendering but mainly about state because you need to run it to read state.

Preventing this function from being called seams more confusing to me.

You're arguing that "useContext" should accept a selector that prevents calling this omni function again as well as "useState" should do diffing. That could be a "nice helper" but not so pure and barebones.

With the bailing mechanism you could even take out "useReducer" and "React.memo" and even make "useContext" more like current setState (or is observedBits such on important optimization?)

"useEffect" and "useLayoutEffect" can't go anywhere because they mark important key positions in the actual rendering process.

Basically you can do all props, state and context diffing in this omni function as long as you call hook in the same sequence before bailing.

I don't get what wrong with exploring the semantics of "undefined" and "null" in the return value.
Having useCallback function called in the wrong scope is way more annoying. Wouldn't this also create memory leaks?

Using some "useShallowDiff" hook (that's just an helper hook and could have a better name. useChanged?? isChanged?? "use" is weird here). It's better to return true for changes though.

Something like this (using some counters just as POC testing and didn't actually run it):

function(props) {

 const {someInlineCallback, hide, ...rest} = props

 const [callCounter, setCallCounter] = useState(0)
 const [RenderCounter, setRenderCounter] = useState(0)

 // setCallCounter NOT called if changed prevents infinite loop. Does this really work??? not sure
 // hook can be used in the condition as long it's always called...right?
 if(!useShallowDiff([callCounter])) setCallCounter] (counter => counter +1)

 // you can "render" (because you're changing DOM) nothing returning null
 // Semantically you can read it as actually requiring something to happen as opposed to "undefined"
 if(hide) return null

 // I'm using the bitwise assignment because it always calls the hooks and it's quite practical here.
 // That why I prefer the hook to return true for changes...you can't go the other way around
 var shouldUpdate |= useShallowDiff(rest)

 // would there be any problem if the following hooks are not always called if order is kept?
 const {c1, c2, ...dontneedthesebutcanhavethem} = useContext(appContext)

 // the hook shallow compares all arguments so you need to wrap them in array here
 shouldUpdate |= useShallowDiff([c1, c2])

 const {device} = useMediaQuery()

 shouldUpdate |= useShallowDiff([device])

 // of course you could also call it only once like this:
 // shouldUpdate |= useShallowDiff(rest, [c1, c2, device])
 // or even
 // if(useShallowDiff(rest, [c1, c2, device])) return

 if(!shouldUpdate) return

 // the following with trigger another call but would just set state not rerender. Has long as you don't include it it the should update diff of course...
 setRenderCounter(counter => counter +1)

 // you could also skip some expensive child component creating where

 return (
  <div>This is where rerendering would really happen!!</div>
 )

}

I'm I missing something? I normally do :-P

PS: @markerikson: I changed my profile picture :-) ...SimpsonizeMe was free then, hope I'm not infringing :-P

ruifortes commented 5 years ago

actually the "Cannot update during an existing state transition" is still there... Think this kind of recursive function could be quite pure couldn't it? Is there a reason "setState" (not useState, the setter) can't be called directly, outside "useEffect" or "useLayoutEffect"? There's no way to call a setter before the it's hook anyway and by immediately recalling the function there would be no side effects to worry about...right? Calling setState asynchronously would have to be done in an effect hook though...

lysoff commented 5 years ago

I like it @sam-rad 's way, that is, component is marked for rendering according to selector (if provided):

Perhaps a possible api would be:

function Blah() {
  // useContext(context, selectorFn);
  const val = useContext(Con, c => c.a.nested.value.down.deep);
}

And shallowEqual under the hood.

It would be convenient to implement useSelector with this approach

useSelector(/* [ */ context /* ] */, props, {
  someValue: someValueSelector,
  ...
});
sebmarkbage commented 5 years ago

There's something troublesome about the premise here.

If you're recomputing a render function, you're better off doing the comparison at the end because at the end you have the most information about whether that computation will yield the same effective value or not. I.e. this is equivalent to the returned child being wrapper in a memo HoC for the type or useMemo around the element.

Also, there is some cost to recomputing the render function. If you have lots and lots of things listening to your context value this can add up. However, that's not the extent of the cost, we also have to traverse down the tree and clone all parents to get to a point where we can bail out. It also adds some small cost to all renders.

I think that for most cases, the granularity of the bailout we're talking about won't matter. However, for the cases where it does matter, you probably want it to be even faster than what these optimizations above would get you.

The fastest would be if we don't even have to traverse down the tree nor call the render function. That's what you get for free by comparing old state and not calling setState at all. That should be the goal.

useState

So here's the plan for useState:

This has the effect of most common updates being ignored. It is possible that you add one update and then adds another update that reverts it to the base state. This approach would unnecessarily schedule renders in that case. However, this is an unusual case and when those two states have different priorities, it's a feature to be able to show intermediate states.

forceUpdate

This also has the effect of making mutation not able to rerender. E.g. this pattern breaks:

let [obj, updateObj] = useState({counter: 0});
function onClick() {
  obj.counter++;
  updateObj(obj);
}

We don't want to encourage mutation of state objects. It is already kind of a mess to use React with mutation this way and concurrent mode just makes it worse.

You can still use them if you're careful though and we already have a way to store mutable objects; useRef. You just need a way to trigger the update.

let obj = useRef({counter: 0});
let forceUpdate = useForceUpdate();
function onClick() {
  obj.current.counter++;
  forceUpdate();
}

Until we know how frequently this pattern is needed in modern code, we don't have to make it built in. It is easily implemented in user space:

function forcedReducer(state) {
  return !state;
}
function useForceUpdate() {
  return useReducer(forcedReducer, false)[1];
}

useContext

Context is tricky because we want to try to avoid storing unnecessary state and causing a lot more hidden work to happen. useContext is pretty light weight to use a lot for reads. If we add custom comparisons and stuff it can suddenly become a lot heavier and there's little to indicate in the API that it is.

However, we do have a pretty lightweight mechanism for this already. The changed bits API. It doesn't require storing the old state for comparison at each node. Instead it uses the provider to store a single copy of the old value which can be used for comparison so it doesn't scale up with number of readers.

We could do a similar thing for custom selectors.

I don't know what is best. Either it takes a selector and only returns the value of the selector:

let abc = useContext(A, a => a.b.c);

You can use multiple useContext calls to read multiple values.

let abc = useContext(A, a => a.b.c);
let ade = useContext(A, a => a.d.e);

Another idea is that the selector isn't actually used for extracting the value. It's only used to extract a dependency array similar to how useMemo accepts an array and you promise only to read from those values.

let a = useContext(A, a => [a.b.c, a.d.e]);
let abc = a.b.c;
let ade = a.d.e;

Which of these APIs doesn't matter. The important part is how the mechanism works.

The target context hook object only stores the selector function. This is also where we store which bits a listener is subscribed to. These are not stateful and don't have to be reconciled against each other (unlike state or memo).

When the Provider updates, we compute the changed bits, scan the subtree for all context listeners for this particular context and if those bits overlap with the listened to bits. If it does we need to mark that subtree to be traversed and cloned, but if it doesn't we don't have to walk down that subtree at all. We can do the same thing with a custom function instead of changed bits.

Whenever Provider updates, we scan the tree for contexts. If those have a selector on them, we call the selector with the old context and the new context. If the resulting value(s) doesn't match, we mark it to be traversed. But if it does match we don't have to do anything and we avoid walking down the tree and rerendering.

The major downside of this approach is that the scanning of the tree is a synchronous operation that scales up with number of nodes. We only accept that because we know we can optimize that path to be extremely fast since it's a single hot loop with no function calls and trivial operations that fit in registers.

If we added this capability we might have to start calling functions in this loop which would make it radically slower. More over since these functions are arbitrary user code, it is easy to put expensive stuff in there accidentally.

That said, I don't think this feature is actually that useful. Perhaps even the changed bits one isn't. If you care enough about the performance of this particular update not overrendering, then you have to be expecting very few rerenders (maybe just one?) based on the overall set of many. You're probably operating at a timescale where a few extra renders matter and at that time scale maybe context scanning matters too. So you're might end up using subscriptions instead of this feature anyway. E.g. setting changed bits to zero and then manually force updating the relevant children.

Luckily, this API can be added after the fact so we can add it after the MVP release of hooks. We can discuss it in a separate RFC along with "changed bits" and a subscription based context API.

Jessidhia commented 5 years ago

If you really need one, this is what I think is the cleanest way to implement a force update.

function useForceUpdate() {
  const [,setState] = useState(Symbol())
  return useCallback(() => { setState(Symbol()) }, [])
}

I am also a "dirty modern JS user" 😇

But this also takes up 2 slots in the hook list, while your useReducer only takes 1.

sebmarkbage commented 5 years ago

A standalone forceUpdate also has an unfortunate effect of the semantics being tied to a component boundary. Everything else allows us to change the component boundary since it's modular.

E.g. we can always inline one component into its parent and preserve semantics.

We can also outline a component. We can extract a set of hooks into a new component if they're self-contained. useForceUpdate doesn't say which refs belongs with it so if you extract it, it no longer rerenders the right component.

So I think I'd prefer something more like let [value, forceUpdate] = useMutableState(() => ...); as the API if we make a built-in one.

Jessidhia commented 5 years ago

That said, I don't think this feature is actually that useful. Perhaps even the changed bits one isn't. If you care enough about the performance of this particular update not overrendering, then you have to be expecting very few rerenders (maybe just one?) based on the overall set of many.

This is exactly the problem redux is running into. A react-redux provider is (almost always) the top level component in any react-redux application, and to prevent tearing (where the redux state can be updated by dispatches in the middle of a single react reconciliation) it also puts the current state in the provider, thus using React to ensure a full reconciliation + commit pass only ever uses the same view of the state.

Because of this, any update to any part of the redux tree, however small and/or irrelevant, causes every single component with a dependency on the react-redux context to update.

Historically, even before PureComponent has a thing, react-redux has always implemented PureComponent-like behavior on things connected to the state because of how easily this can explode.

In other words, what is desired for react-redux is to be able to add the specific values read from inside the context to the list of things React.memo checks before invoking render on a context update, if the component is inside a React.memo.

TotooriaHyperion commented 5 years ago

This is the simplest and extremely performant way I have found:

  1. use a behaviorSubject as a store.
  2. passing down stores by useContext
  3. subscribe to stores and props, do calculation, set the result to local state if it changes(this triggers update).

createStore from https://github.com/buhichan/rehooker

and

useObservable from https://github.com/LeetCode-OpenSource/rxjs-hooks

const ctx = React.createContext({
  store1: createStore({}),
  store2: createStore({}),
});

function Component(props) {
  const {
    store1,
    store2
  } = useContext(ctx);
  const calculatedData = useObservable((inputs$) =>
    inputs$.pipe(
      mergeMap(([s1, s2, dependentProp]) =>
        combineLatest(s1.stream, s2.stream)
        .pipe(map((v1, v2) => {
          // expensive calculate with v1, v2, dependentProp
          return .......;
        }))
      )), {},
    [store1, store2, props.dependentProp]);

  return ......;
}

mutations:

store1.next(oldState => newState);