Skip to content

Instantly share code, notes, and snippets.

@chenglou
Last active April 5, 2021 19:15
Show Gist options
  • Star 37 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save chenglou/34b155691a6f58091953 to your computer and use it in GitHub Desktop.
Save chenglou/34b155691a6f58091953 to your computer and use it in GitHub Desktop.
Better feature for React key

key is pretty much crucial for state perservation in React. As of React 0.13 it can't do the following things:

  • Clone state
<Comp key={1} /><Comp key={1} />
  • Preserve component state across different parents:
// first render
<div><Comp key={1} /></div>
// second render, state destroyed
<span><Comp key={1} /></span>
  • More generally, teleport state to another subtree completely:
// render of myComp1
<Comp key={uuid} />
// next render, myComp2
<Comp key={uuid} />

The reason I want these is to unlock some insane tricks, e.g.

  • Teleport a half-done animation to another place, i.e. animated transition across views (easily solvable otherwise).
  • A generalized version of frosted glass effect.
  • Other similar logic, e.g. drag and drop component where a stateful component is transported somewhere else.

But I dislike key for a few reasons, one being that it's basically a user-generated hash, and that's unreliable and doesn't scale: for example, that duplicate component trick is risky, as it's not clear whether we really wanted <Comp key={1} /><Comp key={1} /> or if we accidentally did a key collision.

The (only?) other used alternative I've seen for key is cursors. But I've repeatedly heard that while these solve the problem (?), they're a bit tedius to work with. Is this true? Please leave a comment if you have any experience regarding this. The solution doesn't need reactive updates, or easier state propagation, or any of the nice extras; it just needs to solve the above few cases.

@gaearon
Copy link

gaearon commented May 26, 2015

Not sure how I feel about this, but to avoid clashes, you can forbid the user to define custom keys.

Consider an opaque key that is created by React.createKey().
It can return an object like { isReactNewStyleKey: true, key: '... some random id ...' } on every invocation.

// Think of `createKey` as of `ref` in reverse
import { createKey } from 'react';

class Gallery {
  constructor(props) {
    super(props);

    // The key shares the lifecycle with this Gallery instance.

    // Because we pass `this`, when this instance is unmounted, the key's state will be destroyed.
    // There is no way to accidentally clash keys because `createKey` requires an explicit owner.

    this.imageKey = createKey(this);
  }

  render() {

    // If `key` is a new-style key, React will reuse the <Image />  instance from an internal mapping.
    // As I said before, it's a bit like a ref in reverse.

    if (this.props.lightbox) {
      return (
        <Lightbox>
          <Image key={this.imageKey} />
        </Lightbox>
      );
    } else {
      return <Image key={this.imageKey} />;
    }
  }
}

Because the new-style key is opaque to user, they can't generate a bad key by accident.
Every key created by createKey is unique.
If keys is a string, React just falls back to the old algorithm.

To teleport the state to another component, one could hold the key in some parent component, and transfer it as a prop:

import { createKey } from 'react';

class Parent {
  constructor(props) {
    super(props);
    this.draggedItemKey = createKey(this);
  }

  render() {
    return (
      <div>
        <DragSource draggedItemKey={this.draggedItemKey} />

        {isDragging &&
          <DragLayer draggedItemKey={this.draggedItemKey} />
        }
      </div>
    )
  }
}

class DragSource {
  static propTypes = {
    draggedItemKey: PropTypes.symbol
  }

  render() {
    return (
      <CustomComponent key={this.props.draggedItemKey}>
        I can teleport!
      </CustomComponent>
    );
  }
}

class DragLayer {
  static propTypes = {
    draggedItemKey: PropTypes.symbol
  }

  render() {
    return (
      <CustomComponent key={this.props.draggedItemKey}>
        I teleported but my state is preserved!
      </CustomComponent>
    );
  }
}

// etc

This could also use the context:

import { createKey } from 'react';

class Parent {
  static childContextTypes = {
    draggedItemKey: PropTypes.symbol
  };

  getChildContext() {
    if (!this.draggedItemKey) {
      this.draggedItemKey = createKey(this);
    }

    return {
      draggedItemKey: this.draggedItemKey
    };
  }

  render() {
    return (
      <div>
        <DragSource />

        {isDragging &&
          <DragLayer />
        }
      </div>
    )
  }
}

class DragSource {
  static contextTypes = {
    draggedItemKey: PropTypes.symbol
  }

  render() {
    return (
      <CustomComponent key={this.context.draggedItemKey}>
        I can teleport!
      </CustomComponent>
    );
  }
}

class DragLayer {
  static contextTypes = {
    draggedItemKey: PropTypes.symbol
  }

  render() {
    return (
      <CustomComponent key={this.context.draggedItemKey}>
        I teleported but my state is preserved!
      </CustomComponent>
    );
  }
}

It's probably crazy but hard to say until you build some real code with it. Anyway, the only real change from what you suggested is the opaqueness and, thus, the need to store the key.

Solved problems:

  • Keys can't clash because user doesn't truly generate them.
  • You're free to move the key up the component hierarchy, thereby indicating that state is preserved.
  • The state has deterministic lifecycle. When the state owner (determined by the createKey(this) argument) unmounts, the state is destroyed.

@threepointone
Copy link

I don't have much to contribute, but I'll try.

  1. I've resigned myself to the fact that I probably don't understand how to hold state inside components. Trying to do synchronous animations in trees with different parents led me to keep pushing the state higher and higher up, until most of the app got modelled in flux stores. I now use component state for toggling display etc, but anything non trivial is 'hoisted'. Also, replayable animations for free.
  2. For a similar problem, I've been using WeakMaps to correlate store 'instances' and flux tokens. Gives a smaller api, and eliminates the dev need to manually maintain keys.

Hope this helps.

@jaredly
Copy link

jaredly commented May 26, 2015

@gaearon I like it! I wonder if there's a way to do this as a HOC :) for prototypability without needing it immediately in React itself.

Something like react-stateful, that also manages the global lookup

@jaredly
Copy link

jaredly commented May 26, 2015

Also, I know this is mainly talking about state, but being able to re-use the DOM elements when a child is moved to a different parent is what I'm most excited about.
This is however the kind of thing that can't be solved by a HOC or just "hoisting state", and needs first-class support by the reconciliation alg.

@chenglou
Copy link
Author

@gaearon your last point touched something I didn't want to mention but I'll do it: whatever key-like solution we choose, the state of the component linked to that unique key is destroyed if on next render the key doesn't exist anywhere anymore. Otherwise (if you allow the state linked to key to persist across renders where it's not present), you'd have no idea when to garbage collect that state, since that same key might appear an hour later. With async rendering this gets way too hacky. Which is actually one of the reason I'm trying to look into cursors: you don't have this problem with them, I think.

Your first-class key solution is close to what we've imagined before. The trouble I had with it is the following: if you receive an array from an unknown source and generate components based on it, to be able to keep the state of these list items with the first-class key, you'd need to generate them, store them in the state (and keep track of which list item already has a key and which list item is new...), then in your render, access those new keys from the state. Can't do that reliably.

If anyone's wondering about the use-case of preserving the state of a dynamic list across, say, reparenting, imagine a list of images that you want to put in a light box, or a list of items that you drag from container1 and drop into container2, or animated transition that takes one component, animate it halfway, and move it into another container to finish its animation.

@threepointone then maybe what you're saying is that cursor + one global state is effectively the way to go? =)

@jaredly: yeah, this should be an optimization that comes more or less for free once we solve this problem.

@gaearon
Copy link

gaearon commented May 26, 2015

whatever key-like solution we choose, the state of the component linked to that unique key is destroyed if on next render the key doesn't exist anywhere anymore. Otherwise (if you allow the state linked to key to persist across renders where it's not present), you'd have no idea when to garbage collect that state

Actually I think my solution works around this problem by passing the "real" state owner explicitly into createKey call. Therefore the state always has an owner somewhere up the tree. (even if that owner doesn't “see” that state, and the grandchild is ignorant of that owner—kinda like cursors :-) This limits reparenting to the part of the tree that is below the createKey call, but you can always move it higher up if you want to. I think it's a sane decision because React components reflect the app lifecycle, and there's no better way to choose a time when to GC than when “this root-ish component unmounts” (which may be a route handler, for example).

@threepointone
Copy link

well, maybe not one global state, I still split my stores across modules. But yes, cursors/observable-like top level state gives me most mileage with least drama. Especially when I need the data across components.

@chenglou
Copy link
Author

@gaearon ok, that makes more sense! At least for that issue.

@gaearon
Copy link

gaearon commented May 26, 2015

Here's another, more declarative take on it.
Note that keys now have explicit types, so that they can be type-checked before the rendering.

This also should work with reparenting arrays and/or nested arrays:

class Gallery {
  // This lets us typecheck keys *and* specify
  // that some of them are actually maps:
  static keyTypes = {
    // Expose this.keys.lightbox to refer to the single Lightbox instance
    lightbox: KeyTypes.instanceOf(Lightbox),

    // Expose this.keys.images to refer to the multiple Image instances
    images: KeyTypes.mapOf(
      KeyTypes.instanceOf(Image)
    )

    // We could also have more nesting if we wanted (for nested maps in render).
    // reallyNestedThings: KeyTypes.mapOf(
    //    KeyTypes.mapOf(
    //      KeyTypes.instanceOf(SomeComponent)
    //    )
    //  )
  };

  render() {
    return (
      <div>
        {this.props.images.map(image => {

          // Gets the memoized image key for a given ID.
          // React provides getChildKey() because we specified KeyTypes.mapOf() above:
          let imageKey = this.keys.images.getChildKey(image.id);
          let image = <Image key={imageKey} />;
          // That's pretty much how key works in current React, but *scoped*
          // to the first-class this.props.images key, and potentially scoped even more, for nested arrays.

          if (image !== this.state.activeImage) {
            return image;
          }

          // Gets the only <Lightbox /> key so even if
          // the active image changes, Lightbox instance is the same:
          let lightboxKey = this.keys.lightbox;
          return (
            <Lightbox key={lightboxKey}>
              {image}
            </Lightbox>
          );

          // Note that all keys are declared in keyTypes above, and trying to use
          // them with different component types would throw an error, as it's impossible
          // to reparent an element to an incompatible type.
        })}
      </div>
    );
  }
}

Again, some of this.keys could be passed down to enable teleportation.
It would also be possible to validate them using KeyTypes inside propTypes or contextTypes:

import { createKey } from 'react';

class Parent {
  static keyTypes = {
    draggedItem: KeyTypes.instanceOf(CustomComponent)
  };

  static childContextTypes = {
    draggedItemKey: PropTypes.keyOf(
      KeyTypes.instanceOf(CustomComponent)
    )
  };

  getChildContext() {
    return {
      draggedItemKey: this.keys.draggedItem
    };
  }

  render() {
    return (
      <div>
        <DragSource />

        {isDragging &&
          <DragLayer />
        }
      </div>
    )
  }
}

class DragSource {
  static contextTypes = {
    draggedItemKey: PropTypes.keyOf(
      KeyTypes.instanceOf(CustomComponent)
    ).isRequired
  }

  render() {
    return (
      <CustomComponent key={this.context.draggedItemKey}>
        I can teleport!
      </CustomComponent>
    );
  }
}

class DragLayer {
  static contextTypes = {
    draggedItemKey: PropTypes.keyOf(
      KeyTypes.instanceOf(CustomComponent)
    ).isRequired
  }

  render() {
    return (
      <CustomComponent key={this.context.draggedItemKey}>
        I teleported but my state is preserved!
      </CustomComponent>
    );
  }
}

@syranide
Copy link

I haven't really followed the discussion, could it not make sense to be able to somehow mark elements in render to be instantiated even though they aren't returned in render. So a component can be alive in the background (nodes too for perf) even when it's not inserted/rendered, this could then be rendered anywhere.

class Test {
  render() {
    var handle;
    // will be continuously kept alive in the background
    var handle1 = this.renderButDoNotMount(<Comp1 key="..." />);
    if (random) {
      handle = handle1;
    } else {
      // will be destroyed when unmounted, just as a normal component
      handle = this.renderButDoNotMount(<Comp2 key="..." />);
    }
    return <div>{handle}</div>;
  }
}

That's a trivial example (and not 100% complete). One could imagine passing an instance as props to other components as well. This could also work neatly as a way to avoid juggling visibility (display: none) for elements that are hidden but you don't want to destroy (for perf and state-keeping).

I don't think it makes sense to allow rendering the same instance twice, neither technically or practically (most frontends couldn't do it properly and mirroring hover states and so on makes no sense). EDIT: As for the frosted-glass effect, I don't think this functionality is the correct approach, it seems like a hack to achieve an effect better accomplished via other means.

@jaredly
Copy link

jaredly commented May 27, 2015

So I put together react-teleporter to try out this idea :)
Because I wanted to do it without hacking React internals, each teleportable component is rendered as it's own React root. So probably not super scalable, but interesting :) Here's an example of teleporting a component around a table, while preserving internal state:

example

To use, you need a teleportable and a teleparent. The teleparent can request keys (either anonymous, or associated with some id), and then use them with children.

The readme also has an example of a list of elements, and then raising one of the elements out of the list (and up two steps in the Component hierarchy).

basically:

@teleparent
class Main extends React.Component {
  componentWillMount() {
    this._childKey = this.props.makeTelekey()
  },
  render() {
    if (condition) {
      return <other><hierarchy><Child telekey={this._childKey}/></hierarchy></other>
    }
    return <some>
      <stuff>
        <Child telekey={this._childKey} ... other props/>
      </stuff>
    </some>
  }
}

@teleportable
class Child extends React.Component {
  // no changes in here, just use state & w/e as you wish
}

@jimfb
Copy link

jimfb commented May 27, 2015

@syranide I think your solution is semantically similar, but @gaearon's allows the components props/children to be changed during re-render without discarding state, which is likely an important use case. That said, I think @gaearon's solution suffers from a problem of when to garbage collect (what if a component passes a key to the parent via a callback? The owner could die before the key does), (what if a component generates a new key for each element in a list, and the list keeps getting new elements? You could easily leak memory despite holding no references).

Either solution, I think you are effectively trying to hang on to a component tree (either explicitly or by reference) in order to preserve state. I find myself agreeing with @threepointone, that state should be hoisted up in these situations. Then, re-using nodes is purely a perf optimization (modulo the state of poorly-designed nodes who's state (like focus) can't be pulled out, but we can (maybe) ignore these in most re-parenting situations). See: facebook/react#3653 (comment)

I think hoisting up state elegantly solves all the use cases mentioned in @chenglou's gist, where key is replaced with an opaque internalState object as described in facebook/react#3653 (comment).

@syranide
Copy link

syranide commented Jun 8, 2015

Damn gists not notifying me of replies :)

@jimfb You may be right, I'm mainly approaching this in-terms of being feature complete with how you would previously manually manage your components if you didn't have React to do it for you. I find that you end up with the most basic ideas then which make for a good unopinionated starting point, you avoid any unnecessary assumptions and closing any doors (controlled components are great, but I still think uncontrolled components may play an important role). "Back then" you would simply hang on to a reference to your unparented component tree (as you say) and keep it somewhere handy so that it can be given a parent again when desired.

PS. I don't think "reusing nodes" should be reduced to the idea of only being considered a "perf optimization".

@threepointone
Copy link

I'm currently obsessed with 'render callbacks'/'children as a function' to represent values changing over time. I made 2 small animation libs to test it out, and they're quite fun (imo) - react-springs / react-ease. I also had a small discussion regarding this on react/3398

Pertinent to this discussion -

  • I can now abstract out animation 'state' with a (Spring/Ease/etc) component that's easy to move up in the hierarchy, if need be. this makes it easier to move the target element across trees, as long as the 'animation component' is a parent to both. (than, say, adding a mixin and marking this.state.name1, .name2 as 'special', etc).
  • keys for elements aren't tied to my animation requirements

@chenglou
Copy link
Author

@threepointone: oh man, we basically did the same thing for the spring library. Here's mine: https://github.com/chenglou/react-animation

I've been trying to solve the animation problem for the longest time and I think the children as function piece helped in cracking it. I'll be talking about it during React-Europe actually. But for now I wanted it to stay relatively hidden so that I could polish and release it.

@dantman
Copy link

dantman commented Sep 27, 2015

I experimented with writing a mixin for a different syntax for these.

React.createClass({
  mixins: [ReusableBlockMixin],
  // ...
  render() {
    var {Block} = this,
        {singleColumn} = this.state,
        nav = <Block tag="nav">
          ...
        },
        main = <Block tag="main">
          ...
        };

    return singleColumn
      ? <div>
        {nav}
        {main}
      </div>
      :  <div>
        <div className="nav">{nav}</div>
        <div className="main">{main}</div>
      </div>;
    }
  }
});

The Block component is individual to components using this api. My use of tag rather than key was inspired by the Android fragments api. Block's tagged children are scoped to the component that owns Block. So when the component is unmounted then the tagged blocks are unmounted. Otherwise when a tagged Block is unmounted the rendered children are just detached and linger around. If another Block with the same tag appears then it attaches the children rendered for that tag.

@camwest
Copy link

camwest commented Oct 21, 2017

Now that React 16 is out has anyone put any more thought into this issue?

@dantman
Copy link

dantman commented Mar 12, 2018

I've opened an RFC with a proposal for an API that allows for reparenting:

reactjs/rfcs#34

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