Skip to content

Implement Better Refs API #1373

Closed
Closed

Description

@sebmarkbage

The ref API is broken is several aspects.

  • You have to refer to this.refs['myname'] as strings to be Closure Compiler Advanced Mode compatible.
  • It doesn't allow the notion of multiple owners of a single instance.
  • Magical dynamic strings potentially break optimizations in VMs.
  • It needs to be always consistent, because it's synchronously resolved. This means that asynchronous batching of rendering introduces potential bugs.
  • We currently have a hook to get sibling refs so that you can have one component refer to it's sibling as a context reference. This only works one level. This breaks the ability to wrap one of those in an encapsulation.
  • It can't be statically typed. You have to cast it at any use in languages like TypeScript.
  • There's no way to attach the ref to the correct "owner" in a callback invoked by a child. <Child renderer={index => <div ref="test">{index}</div>} /> -- this ref will be attached where the callback is issued, not in the current owner.

I think that the solution must ultimately be some kind of first class ref that can be passed around. These refs can be chained to create multi-owner refs very efficiently. By creating this object for the ref, we can also get rid of keeping track of owners on descriptors. Saving perf for the common idiomatic case of not using refs.
A secondary goal, which may or may not be as important is the idea of making the resolution of refs asynchronous so that you can respond after a batched flush/reconciliation.

The concept of a first class ref is basically a reference to an object that doesn't exist yet. Luckily there's a first class notion of this in the language already... It's called a Promise.
You create a new Ref instance which is just Promise object that will eventually resolve to the actual instance.

class Foo {

  myDivRef = React.createRef();

  handleTick() {
    this.myDivRef.then(myDivNode => {
      this.setState({ width: myDivNode.offsetWidth });
    });
  }

  render() {
    return (
      <C tick={this.handleTick}>
        <div ref={this.myDivRef} />
        <CustomComponent context={this.myDivRef} />
      </C>
    );
  }

}

Since this builds on top of Promises we would be able to get async/await language features that allow us to do something like this:

  async handleTick() {
    this.setState({ width: (await this.myDivRef).offsetWidth });
  }

This solves all those use cases AFAIK. The asynchronous API is a little difficult to deal with. But it makes it less weird than the alternative when batching is involved.

An unsolved problem is that refs can update to point to a different instance. In that case the Promise would need to be re-resolved. This is why Promises are not good enough and we ultimately need something like an Observable that can handle multiple values. We can't wait for that spec though. Maybe we just allow our promises to be reset and if you call then(...) again, you get a new value?

Activity

  1. sebmarkbage commented on Apr 8, 2014

    @sebmarkbage
    ContributorAuthor

    On top of this we could build a dynamic RefMap that lazily creates ref promises. This allows easy creation of refs for sets of data. It also makes a nicer upgrade path for existing code that assumes that these are just strings:

    class Foo {
      refs = new React.RefMap();
    
      handleTick() {
        this.refs.get('myDiv').then(myDivNode => {
          this.setState({ width: myDivNode.offsetWidth });
        });
      }
    
      render() {
        return (
          <C tick={this.handleTick}>
            <div ref={this.refs.get('myDiv')} />
            <CustomComponent context={this.refs.get('myDiv')} />
          </C>
        );
      }
    }

    We also probably need to provide a synchronous API as an upgrade path:

      handleTick() {
        var myDivNode = this.myDivRef.doExpensiveWorkRightNowAndLetMeHaveMyNodeNow();
        this.setState({ width: myDivNode.offsetWidth });
      }

    Similarly we need this for ref maps.

  2. sophiebits commented on Apr 8, 2014

    @sophiebits
    Contributor

    This makes a lot of sense to me, though I guess I'm not totally convinced of the need for it to be async. I'd expect most uses to be in mount-ready handlers though I suppose it's possible for a DOM event handler to be called when an update is pending.

  3. sophiebits commented on Apr 8, 2014

    @sophiebits
    Contributor

    Do you have a plan for what to do with descriptors that are mounted in more than one place?

  4. sebmarkbage commented on Apr 8, 2014

    @sebmarkbage
    ContributorAuthor

    Descriptors that are mounted in more than one place should not have refs I guess. Warning maybe?

    Not only DOM events but we want to batch timers and data fetching events too. The goal being that flush only happens on rAF. Therefore any callback could have the refs pending.

    Additionally the behavior of didMount and didUpdate handlers are currently undefined in regards to when they fire in relation to their children and therefore refs. For example componentDidUpdate is not guaranteed to fire after the children has fully mounted.

  5. sebmarkbage commented on Apr 8, 2014

    @sebmarkbage
    ContributorAuthor

    Actually in the current state, since they're queued on the DOM generation queue, I guess they're guaranteed right now. We may be able to preserve this behavior. Not sure.

    An alternative API would be to force refs to be extracted in just those two lifecycle hooks. Then you can store them where ever. That might lead to memory leaks.

  6. sebmarkbage commented on Apr 8, 2014

    @sebmarkbage
    ContributorAuthor

    On a second thought, didMount/didUpdate is not enough to keep ref handles up-to-date. Since a child can choose to unmount/remount that descriptor at any point. Then those events are not enough. Potentially the ref life-cycle callback could be connected to the owner instance but that's not as flexible as a first class ref I guess.

    However, if you want something like a resize handle when a child is actually mounted/remounted it might be troublesome to set up a component-level subscription on the child. I.e. you have to call .then at some point before that. However, maybe that should be handled with a more explicit callback?

  7. sophiebits commented on Apr 8, 2014

    @sophiebits
    Contributor

    Does this mean that if a child never mounts its argument, the ref will "hang" and never resolve? That sounds odd to me.

  8. sebmarkbage commented on Apr 8, 2014

    @sebmarkbage
    ContributorAuthor

    Yea...

  9. sebmarkbage commented on Apr 8, 2014

    @sebmarkbage
    ContributorAuthor

    I suppose the promise should be rejected if the next flush doesn't lead the ref to be resolved.

      handleTick() {
        this.refs.get('myDiv').then(myDivNode => {
          this.setState({ width: myDivNode.offsetWidth });
        }, error => {
          this.setState({ width: 0 });
        });
      }
  10. sophiebits commented on Apr 8, 2014

    @sophiebits
    Contributor

    Presumably the same thing should happen if a ref isn't used at all? I guess that would make a reasonable API, but unless I'm missing something, each ref object won't know which component it belongs to (alternatively, a component won't have a list of all of its refs) and thus can't know when to mark itself as rejected.

  11. sebmarkbage commented on Apr 8, 2014

    @sebmarkbage
    ContributorAuthor

    Every ref object can go from resolved to unresolved and back. We track that in the same way as attach/detach ref. E.g. props.ref.attach(this); or if it's unmounted: props.ref.detach(this);

    The act of calling ref.then(...) can register that ref as "pending". After the next flush is done, all the pending refs gets their callback invokes.

    for (let ref of pendingRefs) {
      if (ref.hasAttachedInstance) ref.resolve(); else ref.reject();
    }
  12. self-assigned this
    on Apr 11, 2014
  13. sophiebits commented on Apr 15, 2014

    @sophiebits
    Contributor

    With this API you wrote out, there's no way to get a component instance, only a DOM node; this means you can't call methods, etc. on child components. Intentional?

  14. sebmarkbage commented on Apr 16, 2014

    @sebmarkbage
    ContributorAuthor

    Actually I had imagine that a component instance of a ReactDOMComponent could become the DOM node. This is still controversial though. The alternative is just an empty object with a getDOMNode method on it.

  15. sophiebits commented on Apr 16, 2014

    @sophiebits
    Contributor

    Well sometimes you want a ref to a composite, right? I haven't thought about it much but that sounds like an odd plan to me. The uniform .getDOMNode() across all types of browser components right now is pretty nice.

  16. 28 remaining items

  17. sebmarkbage commented on Nov 20, 2014

    @sebmarkbage
    ContributorAuthor

    This is also inherently an imperative API because it exposes an imperative handle. Even if it's just an ID or handle. We also already effective allow people to store ephemeral state on classes.

    An author should be prepared that a component class can be destroyed and restored at any given point. E.g. if we want to "hibernate" state and through away the class instance. This still allows it.

  18. sophiebits commented on Jan 23, 2015

    @sophiebits
    Contributor

    @sebmarkbage When updating, do refs get called on every rerender? (Or maybe even if shouldComponentUpdate returns false?)

  19. sebmarkbage commented on Jan 23, 2015

    @sebmarkbage
    ContributorAuthor

    no, just if it changes.

  20. sophiebits commented on Jan 23, 2015

    @sophiebits
    Contributor

    So if I render <Foo /> then render <Foo ref={(f) => this.foo = c} /> on top of it, the ref is never called?

  21. sebmarkbage commented on Jan 23, 2015

    @sebmarkbage
    ContributorAuthor

    No that would be a change in the ref. Just like if you change the ref name. So it should be called.

  22. sophiebits commented on Jan 23, 2015

    @sophiebits
    Contributor

    In conventional usage though it'll be a different function each time, yeah?

  23. sebmarkbage commented on Jan 23, 2015

    @sebmarkbage
    ContributorAuthor

    Good point. Didn't think about that. Maybe just fire if it goes from null to function?

  24. yingyuan-ld commented on Dec 21, 2018

    @yingyuan-ld

    看不懂,求翻译

  25. yoghurtxu commented on Feb 25, 2021

    @yoghurtxu

    good

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions