Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to implement shouldComponentUpdate with this.context? #2517

Closed
gaearon opened this issue Nov 13, 2014 · 126 comments
Closed

How to implement shouldComponentUpdate with this.context? #2517

gaearon opened this issue Nov 13, 2014 · 126 comments

Comments

@gaearon
Copy link
Collaborator

gaearon commented Nov 13, 2014

I know this.context is not officially there but quite a few libraries rely on it, and it seems like it's getting into shape with #2509.

I'm trying to understand how exactly shouldComponentUpdate is supposed to be implemented with context in mind. I noticed it accepts a third argument (nextContext) and I can extend PureRenderMixin to also check it:

  shouldComponentUpdate: function(nextProps, nextState, nextContext) {
    return !shallowEqual(this.props, nextProps) ||
           !shallowEqual(this.state, nextState) ||
           !shallowEqual(this.context, nextContext); // this will throw without context, read on
  }

Components that don't opt into this.context by not omitting contextTypes will not get this third argument, which is understandable.

However this presents a problem when we have a <Middle /> component in between between <Top /> context owner and <Bottom /> context consumer. If <Middle /> implements a restrictive shouldComponentUpdate, there is no way for <Bottom /> to react to <Top />'s context updates at all:

(fiddle)

var Bottom = React.createClass({
  contextTypes: {
    number: React.PropTypes.number.isRequired
  },

  render: function () {
    return <h1>{this.context.number}</h1>
  }
});

var Middle = React.createClass({
  shouldComponentUpdate: function (nextProps, nextState, nextContext) {
    return false;
  },

  render: function () {
    return <Bottom />;
  }
});

var Top = React.createClass({
  childContextTypes: {
    number: React.PropTypes.number.isRequired
  },

  getInitialState: function () {
    return { number: 0 };
  },

  getChildContext: function () {
    return { number: this.state.number };
  },

  componentDidMount: function () {
    setInterval(function () {
      this.setState({
        number: this.state.number + 1
      });
    }.bind(this), 1000);
  },

  render: function() {
    return <Middle />;    
  }
});

React.render(<Top />, document.body);

The same problem would occur if I tried to give Middle a generic context-aware shouldComponentUpdate as I wrote above, because Middle has no this.context unless it opts in.

This is possible to work around by adding contextTypes to Middle, but it doesn't look like a good solution. You'd need to explicitly add necessary contextTypes on every level with smart shouldComponentUpdate so it's too easy to slip up.

Will this be solved by #2112? Is there another solution in the meantime? What is the recommended way?

@jimfb
Copy link
Contributor

jimfb commented Nov 14, 2014

This is an extremely good question. Thanks for raising it!

This is a case where you're manually pruning/optimizing the tree recalculation. It seems to me that you'd need to request access to any context variables that are of relevance to your calculation. We could also consider doing other fancy things like pass in a hash of the full context (or even the full context) if necessary.

@sebmarkbage - thoughts?

@gaearon
Copy link
Collaborator Author

gaearon commented Nov 14, 2014

Yeah, the problem is middle components don't know what context some distant child might need. There is no way they can know which contextTypes to declare to even be able to correctly implement shouldComponentUpdate.

@gaearon
Copy link
Collaborator Author

gaearon commented Nov 14, 2014

I wonder if context propagation could happen in a separate tree traversal, without being blocked by falsy shouldComponentUpdate in the middle?

Basically, when parent's context changes, it should mark all its descendants that receive this context as dirty, no matter how distant. Ancestor's context change should have the same effect as a state change for descendants who opt into this context—they should receive new context regardless of what their parents said.

@andreypopp
Copy link
Contributor

@gaearon I think in that case you need to re-render everything and so shouldComponentUpdate won't have an effect of pruning subtrees. Otherwise context will be in inconsistent state with element tree.

@gaearon
Copy link
Collaborator Author

gaearon commented Nov 14, 2014

@andreypopp

I think that shouldComponentUpdate on middle components should have no effect on whether their descendants receive the new context, just like it has no effect on whether they receive new state:

http://jsbin.com/geseduneso/2/edit?js,output

(If this was the case, both numbers would be incrementing)

Where is the inconsistency?

@gaearon
Copy link
Collaborator Author

gaearon commented Nov 14, 2014

To put it differently, I think context should work exactly the same as if context owner had something like a “store” into which result of its getChildContext() gets written in componentWillUpdate, and all descendant context consumers that declared keys from that context in contextTypes, should receive that context it as if they were subscribed to that “store” and updated their own state.

I don't mean the actual implementation—but I want to show that this model is not any more inconsistent than any Flux app with shouldComponentUpdate in the middle. Context should act like a “sideway storage”, but scoped to a subtree.

@gaearon
Copy link
Collaborator Author

gaearon commented Nov 14, 2014

Edit: this won't work because parent may change

I talked to Glenjamin on IRC and he convinced me changing context might be a bad idea per se. You lose ability to reason about why something was updated if some root update implicitly causes different child updates.

But then, the only reasonable solution I see is to completely forbid context changes. That is, make getChildContext() similar to getInitialState(), which only gets called once before component is mounted.

This would make context a hell lot simpler to think about. We can remove third parameter from shouldComponentUpdate since context never updates.

In case when you need updates from context owner (e.g. like react-router wants activeRoutes to be passed top-down for usage in ActiveState mixin cc @mjackson), nothing prevents you from passing { addChangeListener, removeChangeListener, getActiveRoutes } in context. Descendants can now subscribe to changes and put them in state.

Is this a reasonable solution?

@glenjamin
Copy link
Contributor

I think the key question to answer is:

In what scenarios is passing data through context preferable to passing data through props or triggering an event to make a component call its own setState.

I've been using context for passing object references around quite happily, as I only ever write to those objects, not read. eg. this.context.triggerAction("something")

@jimfb
Copy link
Contributor

jimfb commented Nov 14, 2014

The use case for context has been for parameters that are applicable across a potentially large subtree, but which you don't want to pass through more generic container nodes. An example might be a color theme, where a large number of nodes might listen to see if the background color is supposed to be white or black; you wouldn't want to pass those around everywhere as parameters.

Making getChildContext() behave more like getInitialState() wouldn't actually solve the problem, because you could always replace a parent node which provides a given context value with another parent node that provides a different context value. For the affected subtree, this is indistinguishable from mutating the context variable's value.

I think we can find a solution that avoids having users wire up change listeners.

I think @andreypopp may be right. Or at the very least, we need to provide a way for shouldComponentUpdate to know if anything in the context has changed, so it can decide to always return true if there was a context variable change.

I'll chat with @sebmarkbage later today and see what he thinks.

@gaearon
Copy link
Collaborator Author

gaearon commented Nov 14, 2014

Making getChildContext() behave more like getInitialState() wouldn't actually solve the problem, because you could always replace a parent node which provides a given context value with another parent node that provides a different context value. For the affected subtree, this is indistinguishable from mutating the context variable's value.

Ouch. You're totally right, I haven't considered this.

@gaearon
Copy link
Collaborator Author

gaearon commented Nov 14, 2014

@JSFB On a second thought, I don't quite understand your comment. If you replace a parent node, entire child subtree will re-mount anyway, wouldn't it?

@jimfb
Copy link
Contributor

jimfb commented Nov 14, 2014

Currently, yes, it will re-mount, but that is partly an implementation detail. You can imagine (and we have been discussing the implementations and ramifications of) reparenting subtrees without loosing node state.

@sebmarkbage and I chatted, and came to the following conclusions:

  • shouldComponentUpdate is a complex escape hatch, the implementation of which requires you to have a solid understanding of what is happening under the component. For this reason, it's not unreasonable to assume you know which context variables are being used, since you already need to know which properties are being used and how they're being used.
  • This change probably isn't making the situation much worse than it already was, and it's an improvement over using the old "owner" relationship. Therefore, this issue is still worth discussion, but is probably non-blocking.
  • Contexts are a complex feature/experiment at this point, and we're probably going to need to do more thinking before we officially support/document them. As we learn more, we'll likely continue to iterate on this topic.

@sebmarkbage, let me know if I missed anything!

Good discussions though! Thanks for all the feedback!

@gaearon
Copy link
Collaborator Author

gaearon commented Nov 14, 2014

Thank you for taking time to discuss this!

it's not unreasonable to assume you know which context variables are being used, since you already need to know which properties are being used and how they're being used.

If a top-level Feed component implements shouldComponentUpdate by using PureRenderMixin to avoid extra updates, it doesn't mean that Feed knows that somewhere inside it there is a Cell that happens to contain a Link that depends on router's context.

This gets even worse when frameworks (such as most popular React routing framework) use context and you may not even be aware of it. Somewhere, there are apps that don't change active link state because somebody optimized top-level components and had literally no idea they had to declare corresponding contextTypes to even get nextContext in their shouldComponentUpdate.

There is no way components are aware of all their possible descendants. Basically, if I move a context-dependant component anywhere inside a PureRenderMixin-enabled component, it will break but very subtly. Therefore, if you use context, the only way to avoid this subtle breakage of unrelated components is to never implement shouldComponentUpdate which goes against what React is saying about it.

Some frameworks on top of React, such as @swannodette's Om, make PureRenderMixin-ish shouldComponentUpdate default, it's weird to cut them off like that. That means context-breaking shouldComponentUpdate in every component for them.

I agree this is not blocking for your work, but I can't agree that current situation is satisfactory if context is to be used at all. It's not just hard to implement shouldComponentUpdate with context now—it's downright impossible unless we make assumption that each component is always aware of what its children could be.

Popular libraries already rely on context heavily. I understand it's not a supported feature, but in my opinion it either needs to at least be possible to make it work with shouldComponentUpdate, or it should be disabled, and libraries should be forced to not use it, because it is subtly broken.

@sebmarkbage
Copy link
Collaborator

This has been known from the start with introduced contexts. We need to be able to have undocumented and unsupported features as experimental features to be able to iterate on them to find special cases. For example, if we didn't have contexts we wouldn't have known that they need to changed to be container-based instead of owner-based. Subtle breakage is part of the contract of using undocumented features.

I think what we need to do is by-pass shouldComponentUpdate if there's a new context anywhere in the parent tree. Potentially with a shouldUpdateChildContext or something that determines if the entire subtree needs to reconcile.

@gaearon
Copy link
Collaborator Author

gaearon commented Nov 14, 2014

@sebmarkbage

I suppose this could work because context is hardly meant to change often.
In this case, shouldComponentUpdate doesn't need a third parameter, does it?

@sebmarkbage
Copy link
Collaborator

Correct, it was always kind of useless.

@mjackson
Copy link
Contributor

I think what we need to do is by-pass shouldComponentUpdate if there's a new context anywhere in the parent tree.

This makes a lot of sense. Context changes in the parent need to override falsy shouldComponentUpdates in the subtree.

But it begs the question: how do we know when context changes? For state and props we have setState and render/setProps. It seems like we would need to keep a copy of the current context around somewhere and diff it with the result from getChildContext whenever render is called.

@ghost
Copy link

ghost commented Nov 23, 2014

I'm using contexts as a way of managing store references. I like this because I can explicitly define what stores are required, so

  1. I can mock out sample data in just the stores I need to test a component, avoiding having to instantiate my larger framework, and
  2. pass in simple read-only stores with the known data for server-side rendering, which simplifies the server-side case as the bulk of the application code is not used.

So, looking at an example,

With my implementation, store data is accessed only through getters. My elements do not cache store data. I want a single version of the truth. As a simple non-async example, my render() would typically have something like

var peopleStore = this.context.peopleStore;
var person = peopleStore.get(this.props.personId);
return <div>person.fullName</div>;

Components attach listeners to stores. I haven't fully decided on the granularity. Stores all have an onChange event. However, I haven't decided on two other things,

  1. listeners for individual property changes
  2. if stores should contain stores

In this example, if "people" was "fb users", it's a large complex async store. Should I reuse the store structure for a PersonStore? One for general collection (getFriends, getPerson, etc) but many unique instances of the Person store type for an individual person.

So in my example, my Component requires the People store as a context param. Then it uses the personId prop to identify and subscribe to a specific Person store.

Now, to introduce some dynamic changes. Let's say the currently logged in user logs out and someone else logs in. Ignoring the fact that you'd probably redirect/refresh the page in a real application, how would this element update? Let's also assume that this element is still on the page and isn't just destroyed.

I would expect the application logic to first remove/destroy the existing People store. For that, my component needs to stop listening for updates. For this, I would recommend a ReactClass API. Eg,

onContextChange(prev, new)

The element can then compare the two peopleStore instances. Let's ignore public data and assume the new PeopleStore is null. The element would unsubscribe from the previous Store and trigger a render(). The render would then show some 'user unknown' type message.

When the user logs in as another user, a new Store is created, the element reattaches, and render() does its thing with new data.

Behind the scenes, "this.render()" couldn't be synchronous. For any of my design/example to make any sense at all, the render() calls need to be collected by the framework and batched together.

Unlike props, listening to stores is outside of the core React role of managing the rendering. That's why I don't think a context change should be involved in shouldComponentUpdate(). And despite my example including changing context values (a new store object), I don't think stores are going to be immutable in their high-level nature. I think that a typical flux application design will operate with a subscriber model, callbacks for async, etc. The base store object will usually live for the life of the application.

@gaearon
Copy link
Collaborator Author

gaearon commented Feb 26, 2015

There's quite a bit of interest in this. (See references above.)

@slorber
Copy link
Contributor

slorber commented Feb 27, 2015

Yes @gaearon .

I want to pass localization data in context, and let the user be able to change its language at runtime, letting all the components re-render themselves with the updated translations,currencies, date formattings... As far as I understand, this seems hard to do for now.

See also
formatjs/formatjs#58
#3038

@gpbl
Copy link

gpbl commented Feb 27, 2015

@slorber I also adopted a custom PureRenderMixin, it clearly could get broken with stricter shouldComponentUpdates in the middle, as @gaearon says (or if the third parameter will disappear). You can always rely on props, however. Let see how this issue will evolve :)

@slorber
Copy link
Contributor

slorber commented Feb 27, 2015

@gpbl check that:
#3038 (comment)

It seems to me that in case we want to re-render the whole app from top in case of context change, we can unmount and remount the node in the same event loop tick, and it does not produce flickering effects (constantly). This is a perfect workaround for dealing with user language changes.

@waldreiter
Copy link
Contributor

@slorber Calling unmountComponentAtNode makes bad user experience, since it causes the loss of all local state.

I think it is time to declare context an official feature. Not using context is not an option for most people, because the router and ReactIntl uses it.

@xgqfrms-GitHub
Copy link

pure-component

https://60devs.com/pure-component-in-react.html

https://hackernoon.com/react-purecomponent-considered-harmful-8155b5c1d4bc

@Diokuz
Copy link

Diokuz commented Oct 11, 2017

Is there any solution right now?

@gaearon
Copy link
Collaborator Author

gaearon commented Dec 9, 2017

As we know from this thread the existing solution is pretty fundamentally broken.
We can’t fix the current API without making all apps slower (which we’d like to avoid :-).

Therefore we propose a new API that handles the same use cases but doesn’t have those design flaws. The plan is for APIs to exist side by side, and later to phase out the old API when people have migrated.

Check out the discussion: reactjs/rfcs#2

@gaearon
Copy link
Collaborator Author

gaearon commented Jan 25, 2018

@acdlite just landed a PR with the new context API: #11818.

I think we can consider this closed. The new API doesn't have this problem. The old API can't be fixed, and we'll deprecate it at some point after major libraries upgrade to the new API.

The new API will be available and documented in one of the next minor React 16.x releases.

@eplawless
Copy link

@gaearon Not sure if you hear this frequently enough, but: you guys are doing a great job and I appreciate your work. 🍺

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

No branches or pull requests