I see the comparison of Redux to Context quite often lately and wanted to act on this.
Redux is performant by nature, it uses the concept of selectors to memoize values and rerender selectively, context on the other hand will propagate through to every Consumer subscribed to the whole context.
The clear difference between these two we can notice is that when x amount of components are subscribed to individual pieces of state, for redux this will rerender the amount of times the state has changed while with the current form of context this will be x.
One of the big prerequisites we're lacking out for this concept to succeed is strict-equality on vnodes, this means that if the children did not change from render 1 --> render 2 we won't have to evaluate these children. This is a very common pattern when it comes to contextProviders.
Let's consider the following scenario:
const App = () => (
<MyProvider>
<MyRoutes />
</MyProvider>
);
When <App /> updates this whole tree will rerender since the function will execute and reevaluate all these children, let's for clarity look at a hypothetical <MyProvider /> implementation.
const { Provider } = createContext();
const MyProvider = ({ children }) => {
const [state, setState] = useState();
return (
<Provider value={{ state, setState }}>
{children}
</Provider>
)
}
As we can see there's a prop injected named children, if this doesn't change the vnode is equal and shouldn't be diffed. This means that if a Consumer calls setState the MyProvider component will alter its internal state but the children, injected by <App /> (which hasn't reexecuted), will still be the same so we can safely decide to not diff this part.
This means that instead of walking the tree downwards we'll only trigger the contextConsumers.
To come back to our selective context propagation, we have two options to support this effectively.
We can supply a rerender function similar to shouldComponentUpdate, useContext/others could allow a second argument (or a new hook) that allows the user to selectively compare the old and new context and decide wether this hook needs an emit. This would always return the full context (important note when reading the next proposition).
const MyName = () => {
const { name } = useContext(userContext, (prev, curr) => prev.name !== curr.name);
return <p>Welcome {name}</p>;
}
Selector-style, for this case useContext/others can accept an argument that selects a certain value out of context, if this value hasn't changed compared to the previous instance it won't cause a rerender. The return value of useContext/others will be the selected value as opposed to the full context.
const MyName = () => {
const name = useContext(userContext, ctx => ctx.name);
return <p>Welcome {name}</p>;
}
Option 1 will probably be the smallest, size-wise and introduce less context retrievals in general. This means that we have less calls to useContext/less nesting of context.Consumer since if we'd need more properties we'd need to introduce more selectors (or complex ones). Less useContext calls means less comparisons which can only benefit performance + for option 1 we have the current infrastructure ready to go.
This RFC aims at bringing out of the box perf improvements to Preact.createContext not a replacement of Redux. There are many things that are in Redux that would have to be reimplemented in user land (in the form of libraries potentially) to replace for instance, logging, ....
A downside I see to Option 1 is that an incorrect "should update" callback could lead to re-renders not happening when they should. For example, supposing properties foo and bar are initially extracted from the context, and the "should update" callback checks if they changed. Then later, an additional property baz is extracted from the context, but the author forgets to update the "should update" callback accordingly. With the selector approach, this kind of mistake is harder to make.
A downside of the selector approach is that I can see someone doing something like this and not realizing that a re-render will always happen:
const { foo, bar } = useContext(userContext, ctx =>
({ foo: ctx.foo, bar: ctx.bar })
);
Perhaps a debug check or ESLint rule could catch that kind of mistake?
@robertknight i think that also happens with Redux,
At least when i worked with Redux last time, i remember having some issues with the return of mapStateToProps.
What redux does is that it always assume you return a object and do a shallow equals to avoid that rerender, but still you can mess with object equality at a different depth and get too much rendering.
Good point @robertknight + @porfirioribeiro. To me that seems like a point in favor of Option 1: we don't have to do an object comparison in order to fix the issue.
FWIW it's easy to implement Option 2 atop Option 1 (see below), but I don't think the same can be said for the other way around.
function useSelector(context, selector) {
const prev = useRef();
const ctx = useContext(context, ctx => {
const selected = selector(ctx);
const update = selected !== prev.current;
prev.current = selected;
return update;
});
return prev.current; // or selector(ctx)
}
Option 1 seems like a footgun similar to shouldComponentUpdate where you can start using the context differently and forget to update your shouldUpdate. by being intentionally more restrictive here you avoid the ability to be inconsistent between what is read and what will cause an update.
FWIW React may add selectors to useContext and it seems more likely it will be option 2 style. Is there concern that these APIs will be different?
@gnoff That's the first thing I hear about that, when I was making this RFC I looked around the React ecosystem only to find RFC's/PR's/user-land implementations that weren't really being responded to by the core-team. Are they going to implement the selector pattern in React 16?
I can't say what React core team will actually do but Sebastian left this comment a while back
https://github.com/reactjs/rfcs/pull/119#issuecomment-586532975
and separately (don't recall where) suggested that there is no need for useContextSelector and then second arg of useContext is fine.
I can't say for certain they wouldn't go with option 1 but I find it highly unlikely given how they developed previous hooks apis where correctness is favored over more powerful apis that might lead to erroneous use without care
selectors in context are not imminent on React side but I do think they will arrive eventually
Thanks a lot for all of this information @gnoff I'll be certain to look into all of this in the near future, we'll clear this up asap.
Also thanks a lot for pointing out a bug, we never thought about this case https://github.com/preactjs/preact/pull/2501
Most helpful comment
@robertknight i think that also happens with Redux,
At least when i worked with Redux last time, i remember having some issues with the return of mapStateToProps.
What redux does is that it always assume you return a object and do a shallow equals to avoid that rerender, but still you can mess with object equality at a different depth and get too much rendering.