3

research/react-instance-identity-model at main · gactjs/research · GitHub

 1 year ago
source link: https://github.com/gactjs/research/tree/main/react-instance-identity-model
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

React's Instance Identity Model

Introduction

React is a declarative, reactive framework for building user interfaces. The central abstraction is a component, which maps state to a user interface declaration. A user interface declaration is a tree composed of components and intrinsic elements (components built into the rendering environment). A component is a blueprint that React instantiates creating an instance. As state changes, an instance's user interface is updated. React must transform the current user interface to the updated user interface. In order to realize this transformation, React uses a representation of the current and updated user interfaces called the virtual DOM 1. React compares the current and updated virtual DOMs to determine a course of action. This reconciliation process raises an important question of identity. When should an instance in the current user interface correspond to an instance in the updated user interface? React answers this question by following a set of rules that collectively comprise its instance identity model. The instance identity model determines when instances are created and destroyed. This creation and destruction influences nearly every part of a reactive user interface. In this document, we will explore React's instance identity model in depth.

Source Code

This document has an associated MIT licensed playground available here.

This document generally excludes styles for clarity.

Why Instance Identity Matters

Inexact Declarations

In React, the declarations in our components elide many details. Let's take a look at a simple Input component:

import { useState, type FunctionComponent } from "react";

export const Input: FunctionComponent = () => {
  const [value, setValue] = useState("");

  const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    setValue(e.target.value);
  };

  return (
    <input
      value={value}
      onChange={onChange}
    />
  );
};

The virtual node created by our Input on initial render will be2:

const inputVNode = {
  type: "input",
  key: null,
  props: {
    onChange,
    value: "",
  },
};

The declaration and corresponding virtual node leave out many crucial details:

  • Is our input focused ?
  • If focused, where's the cursor ?
  • Is any of the text in our input selected ?

The primary reason these details are left out is encapsulation. The built-in input is a complex element that provides many features. But this complexity is encapsulated. We get focus, cursor position, and selection support without having to do anything, and more importantly without having to know anything. If our declaration needed to provide these details, we would also have to manage these details. In other words, we would completely break encapsulation.

Fungibility

Fungibility is the property that something is exactly replaceable. The common example of something fungible is a dollar. If the dollar in your pocket is magically swapped with the dollar in my pocket, then we should be indifferent. Fungibility is rare. Most things in the world are not fungible. Even things that are fungible in theory lose fungibility in practice (e.g. each dollar has a serial number).

As discussed, our declarations are necessarily vague. And consequently, our instances are non-fungible because React doesn't have the data to create an exact replacement. This non-fungibility is the reason instance identity matters.

React's Instance Identity Model

React's instance identity model is comprised of two rules:

  1. An instance in the same position in the user interface as in the previous declaration is the same instance.
  2. Identity within a single level can be explicated by providing a key.3

We will now explore this model in depth in a series of examples.

Static Structure

Let's walk through the rendering of our Input component:

import { useState, type FunctionComponent } from "react";

export const Input: FunctionComponent = () => {
  const [value, setValue] = useState("");

  const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    setValue(e.target.value);
  };

  return (
    <input
      value={value}
      onChange={onChange}
    />
  );
};

The initial render will produce the following virtual node:

const inputVNode = {
  type: "input",
  key: null,
  props: {
    onChange,
    value: "",
  },
};

As we type, our Input is rerendered 4. Let's look at our virtual node after we type in "rerender":

const inputVNode = {
  type: "input",
  key: null,
  props: {
    onChange,
    value: "rerender",
  },
};

The value of our input is captured by the virtual node, but its focus and cursor state are not. Nevertheless, the updated user interface shows the correct value, focus, and cursor position. The input instance is the only thing rendered by our Input. Trivially, its position in the user interface is constant, and the first identity rule applies. Because the identity of our input persists across rerenders, its internal state is maintained.

The importance of identity preservation becomes conspicuous when it's disturbed. Let's make use of the second rule to dynamically change the identity of input.

import { useState, type FunctionComponent } from "react";

export const KeyInput: FunctionComponent = () => {
  const [value, setValue] = useState("");

  const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    setValue(e.target.value);
  };

  return (
    <input
      key={value === "rerender" ? 1 : 0}
      value={value}
      onChange={onChange}
    />
  );
};

KeyInput will provide an explicit key when the value is "rerender". When we type in "rerender", the virtual node rendered will be:

const keyInputVNode = {
  type: "input",
  key: 1,
  props: {
    onChange,
    value: "rerender",
  },
};

React looks for an input with key set to 1 in the previous render. However, because it does not exist, React creates a new input for us. Consequently, input focus and cursor position are lost.

Dynamic Collection

Instance identity becomes prominent once our user interface is structurally dynamic. Let's consider a user interface that let's us dynamically add and remove Inputs.

useDynamicCollection

The useDynamicCollection hook lets us manage a dynamic set of keys:

import { useRef, useState } from "react";
// createKey will produce an alphabetically sequential series of keys
import { createKeyFactory } from "../utils/createKeyFactory";

export type DynamicCollection = {
  keys: string[];
  add: () => void;
  remove: (removedKey: string) => void;
};

export const useDynamicCollection = (): DynamicCollection => {
  const [keys, setKeys] = useState<string[]>([]);
  const createKey = useRef(createKeyFactory()).current;

  const add = () => {
    setKeys((keys) => [...keys, createKey()]);
  };

  const remove = (removedKey: string) => {
    setKeys((keys) => keys.filter((key) => key !== removedKey));
  };

  return {
    keys,
    add,
    remove,
  };
};

Removable

The Removable component allows us to wrap other components in a removal user interface.

import type { FunctionComponent } from "react";
import { Button } from "./Button";

export type RemovableProps = {
  onRemove: () => void;
  children: React.ReactNode;
};

export const Removable: FunctionComponent<RemovableProps> = ({
  onRemove,
  children,
}) => {
  return (
    <div>
      {children}
      <Button onClick={onRemove}>Remove</Button>
    </div>
  );
};

DynamicCollection

When tree structure is dynamic, tree position is insufficient to identify instances. The instance in a given position may be completely different on each render. By using keys we can help React identify instances as long as their movement is contained to a single level.

To more clearly demonstrate the importance of providing keys to dynamic children, we will first leave them out.

import { type FunctionComponent } from "react";
import { useDynamicCollection } from "../hooks/useDynamicCollection";
import { Button } from "./Button";
import { Input } from "./Input";
import { Removable } from "./Removable";

export const DynamicCollection: FunctionComponent = () => {
  const { keys, add, remove } = useDynamicCollection();

  return (
    <div>
      <Button onClick={add}>Add</Button>

      {keys.map((key) => (
        <Removable
          onRemove={() => {
            remove(key);
          }}
        >
          <Input />
        </Removable>
      ))}
    </div>
  );
};

Initially, DynamicCollection renders:

const dynamicCollectionVNode = {
  type: "div",
  key: null,
  props: {
    children: [
      {
        type: Button,
        key: null,
        props: {
          onClick: add,
          children: "Add",
        },
      },
      // empty removable `Input` array
      [],
    ],
  },
};

After we click the "Add" button for the first time, our DynamicCollection instance renders:

const dynamicCollectionVNode = {
  type: "div",
  key: null,
  props: {
    children: [
      {
        type: Button,
        key: null,
        props: {
          onClick: add,
          children: "Add",
        },
      },
      [
        {
          type: Removable,
          key: null,
          props: {
            onRemove,
            children: {
              type: Input,
              key: null,
              props: {},
            },
          },
        },
      ],
    ],
  },
};

Let's say we type "first" into this input and then click the "Add" button again. Our DynamicCollection instance will render two removable Inputs, and the virtual node will look like this:

const dynamicCollectionVNode = {
  type: "div",
  key: null,
  props: {
    children: [
      {
        type: Button,
        key: null,
        props: {
          onClick: add,
          children: "Add",
        },
      },
      [
        {
          type: Removable,
          key: null,
          props: {
            onRemove,
            children: {
              type: Input,
              key: null,
              props: {},
            },
          },
        },
        {
          type: Removable,
          key: null,
          props: {
            onRemove,
            children: {
              type: Input,
              key: null,
              props: {},
            },
          },
        },
      ],
    ],
  },
};

At this point, our user interface seems to be working correctly. The state from our first Input is preserved (it reads "first"), and we also have a second removable Input. As long as we're just adding Inputs, tree position is sufficient to identify our instances.

Let's now try to remove the first Input by hitting its associated "Remove" button. Unfortunately, when the user interface updates, the second Input is removed instead of the first. There was an identity crisis.

Taking a look at the virtual node after removal makes the problem apparent:

const dynamicCollectionVNode = {
  type: "div",
  key: null,
  props: {
    children: [
      {
        type: Button,
        key: null,
        props: {
          onClick: add,
          children: "Add",
        },
      },
      [
        {
          type: Removable,
          key: null,
          props: {
            onRemove,
            children: {
              type: Input,
              key: null,
              props: {},
            },
          },
        },
      ],
    ],
  },
};

That's the same structure rendered after hitting the "Add" button for the first time. Thus, according to tree position, we wanted to preserve our first Input.

KeyDynamicCollection

The DynamicCollection bug is easy to fix, we just need to provide a key to each Removable:

import { type FunctionComponent } from "react";
import { useDynamicCollection } from "../hooks/useDynamicCollection";
import { Button } from "./Button";
import { Input } from "./Input";
import { Removable } from "./Removable";

export const KeyDynamicCollection: FunctionComponent = () => {
  const { keys, add, remove } = useDynamicCollection();

  return (
    <div>
      <Button onClick={add}>Add</Button>

      {keys.map((key) => (
        <Removable
          key={key}
          onRemove={() => {
            remove(key);
          }}
        >
          <Input />
        </Removable>
      ))}
    </div>
  );
};

It's illustrative to go through the virtual nodes when we provide a key.

The initial render is exactly the same as with no key.

After hitting "Add" for this first time:

const keyDynamicCollectionVNode = {
  type: "div",
  key: null,
  props: {
    children: [
      {
        type: Button,
        key: null,
        props: {
          onClick: add,
          children: "Add",
        },
      },
      [
        {
          type: Removable,
          key: "a",
          props: {
            onRemove,
            children: {
              type: Input,
              key: null,
              props: {},
            },
          },
        },
      ],
    ],
  },
};

After hitting "Add" the second time:

const keyDynamicCollectionVNode = {
  type: "div",
  key: null,
  props: {
    children: [
      {
        type: Button,
        key: null,
        props: {
          onClick: add,
          children: "Add",
        },
      },
      [
        {
          type: Removable,
          key: "a",
          props: {
            onRemove,
            children: {
              type: Input,
              key: null,
              props: {},
            },
          },
        },
        {
          type: Removable,
          key: "b",
          props: {
            onRemove,
            children: {
              type: Input,
              key: null,
              props: {},
            },
          },
        },
      ],
    ],
  },
};

After we hit the "Remove" button of our first Removable:

const keyDynamicCollectionVNode = {
  type: "div",
  key: null,
  props: {
    children: [
      {
        type: Button,
        key: null,
        props: {
          onClick: add,
          children: "Add",
        },
      },
      [
        {
          type: Removable,
          // this tells React we want the second input to persist
          key: "b",
          props: {
            onRemove,
            children: {
              type: Input,
              key: null,
              props: {},
            },
          },
        },
      ],
    ],
  },
};

Dynamic Wrapper

The primary limitation of React's instance identity model is that keys can only be used to distinguish instances within a single level of the user interface. In other words, if the parent of your instance changes, then React will always consider it to have a different identity regardless of key.

Let's say we have a Frame component, and want to provide a user interface to toggle framing an Input.

DynamicWrapper is a component that creates a dynamically framable Input:

import { useState, type FunctionComponent } from "react";
import { Button } from "./Button";
import { Frame } from "./Frame";
import { Input } from "./Input";

export const DynamicWrapper: FunctionComponent = () => {
  const [shouldFrame, setShouldFrame] = useState(false);

  const toggleShouldFrame = () => {
    setShouldFrame((v) => !v);
  };

  return (
    <div>
      {shouldFrame ? (
        <Frame>
          <Input />
        </Frame>
      ) : (
        <Input />
      )}
      <Button onClick={toggleShouldFrame}>Frame</Button>
    </div>
  );
};

When we render DynamicWrapper, we will see that our Input is cleared whenever we hit the toggle. On each toggle, React destroys our current Input instance, and creates a new instance. React thinks the identity of our Input has changed because it's position in the user interface has changed.

Unfortunately, because this move spans more than a single level, key will not help us. The following code is equally broken:

import { useState, type FunctionComponent } from "react";
import { Button } from "./Button";
import { Frame } from "./Frame";
import { Input } from "./Input";

export const DynamicWrapper: FunctionComponent = () => {
  const [shouldFrame, setShouldFrame] = useState(false);

  const toggleShouldFrame = () => {
    setShouldFrame((v) => !v);
  };

  return (
    <div>
      {shouldFrame ? (
        <Frame>
          <Input key="a" />
        </Frame>
      ) : (
        <Input key="a" />
      )}
      <Button onClick={toggleShouldFrame}>Frame</Button>
    </div>
  );
};

You might try to maintain tree stability by rewriting the component as follows:

import { Fragment, useState, type FunctionComponent } from "react";
import { Button } from "./Button";
import { Frame } from "./Frame";
import { Input } from "./Input";

export const DynamicWrapper: FunctionComponent = () => {
  const [shouldFrame, setShouldFrame] = useState(false);

  const toggleShouldFrame = () => {
    setShouldFrame((v) => !v);
  };

  const Wrapper = shouldFrame ? Frame : Fragment;

  return (
    <div>
      <Wrapper>
        <Input />
      </Wrapper>

      <Button onClick={toggleShouldFrame}>Frame</Button>
    </div>
  );
};

This example is syntactically deceptive. It's seems unambiguous that we intend to render a single Input throughout the life of a DynamicWrapper instance. But React does not consider the syntax of our declaration, but rather considers the virtual nodes produced by our DynamicWrapper on render.

When framing is off, DynamicWrapper renders:

const dynamicWrapperVNode = {
  type: "div",
  key: null,
  props: {
    children: [
      {
        type: Fragment,
        key: null,
        props: {
          children: {
            type: Input,
            key: null,
            props: {},
          },
        },
      },
      {
        type: Button,
        key: null,
        props: {
          onClick,
          children: "Frame",
        },
      },
    ],
  },
};

When framing is on, DynamicWrapper renders:

const dynamicWrapperVNode = {
  type: "div",
  key: null,
  props: {
    children: [
      {
        type: Frame,
        key: null,
        props: {
          children: {
            type: Input,
            key: null,
            props: {},
          },
        },
      },
      {
        type: Button,
        key: null,
        props: {
          onClick,
          children: "Frame",
        },
      },
    ],
  },
};

When React sees that the type switches from Fragment to Frame, it destroys everything else in the tree without further examination.

Workaround

There is no direct way to preserve the identity of our Input.

Since Frame is an internal component, we could try to modify its implementation to produce the same effect while maintaining tree structure. The original Frame component was implemented like this:

import type { FunctionComponent } from "react";

export type FrameProps = {
  children: React.ReactNode;
};

export const Frame: FunctionComponent<FrameProps> = ({ children }) => {
  return (
    <div className="flex p-3 border-4 rounded-md border-yellow-500">
      {children}
    </div>
  );
};

We could change Frame's implementation to the following:

import type { FunctionComponent } from "react";

export type FrameProps = {
  shouldFrame: boolean;
  children: React.ReactNode;
};

export const Frame: FunctionComponent<FrameProps> = ({
  shouldFrame,
  children,
}) => {
  return (
    <div
      className={
        shouldFrame ? "flex p-3 border-4 rounded-md border-yellow-500" : ""
      }
    >
      {children}
    </div>
  );
};

And then we could rewrite DynamicWrapper as follows:

import { useState, type FunctionComponent } from "react";
import { Button } from "./Button";
import { Frame } from "./Frame";
import { Input } from "./Input";

export const DynamicWrapper: FunctionComponent = () => {
  const [shouldFrame, setShouldFrame] = useState(false);

  const toggleShouldFrame = () => {
    setShouldFrame((v) => !v);
  };

  return (
    <div>
      <Frame shouldFrame={shouldFrame}>
        <Input />
      </Frame>

      <Button onClick={toggleShouldFrame}>Frame</Button>
    </div>
  );
};

This would work correctly because the tree structure remains identical regardless of frame state. However, this little hack will only work sometimes. In the common case, we cannot easily achieve the desired effect while maintaining the same tree structure. Further, when we're working with third-party components we're out of luck.

The commonly employed workaround for such identity issues is state-lifting. With state-lifting, you move your state higher in your user interface. You move your state up until you find somewhere in your tree that has a stable identity for at least the intended lifetime of your state.

Let's look at how we can make use of state-lifting in the frame example.

First we create a FungibleInput component:

import type { FunctionComponent } from "react";

export type FungibleInputProps = {
  value: string;
  onChange: (value: string) => void;
};

export const FungibleInput: FunctionComponent<FungibleInputProps> = ({
  value,
  onChange,
}) => {
  const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    onChange(e.target.value);
  };

  return (
    <input
      value={value}
      onChange={handleChange}
    />
  );
};

Then we make use of it in LiftDynamicWrapper:

import { useState, type FunctionComponent } from "react";
import { Button } from "./Button";
import { Frame } from "./Frame";
import { FungibleInput } from "./FungibleInput";

export const LiftDynamicWrapper: FunctionComponent = () => {
  const [value, setValue] = useState("");
  const [shouldFrame, setShouldFrame] = useState(false);

  const toggleShouldFrame = () => {
    setShouldFrame((v) => !v);
  };

  return (
    <div>
      {shouldFrame ? (
        <Frame>
          <FungibleInput
            value={value}
            onChange={setValue}
          />
        </Frame>
      ) : (
        <FungibleInput
          value={value}
          onChange={setValue}
        />
      )}

      <Button onClick={toggleShouldFrame}>Frame</Button>
    </div>
  );
};

Our new FungibleInput component is stateless; it takes its value and onChange handler as props. The LiftDynamicWrapper component now manages input state, and passes the required props to FungibleInput.

This version maintains value as desired. However, like our Input instance in the previous example, our FungibleInput instance is replaced on each toggle. The difference here is that FungibleInput's identity is meaningless. The replacement instance is identical in every perceptible way.

In this example, state was the only source of non-fungibility. But generally, any aspect of an instance that creates a perceptible difference between itself and its replacement must be managed for this technique to work. The consequences of lost state are often dramatic. However, other fungibility failures can be subtle. For example, failure to make effects idempotent5.

There's a way to understand state-lifting in terms of fungibility and encapsulation. In general, there's a tension between fungibility and encapsulation. If you know every possible thing about something, then you can create an exact copy. On the other hand, encapsulation hides information.

State-lifting is a way to tradeoff encapsulation for fungibility. By moving your state higher in the tree, you break encapsulation by exposing state management details. At the same time, you make the instance stateless. A stateless instance is more easily fungible.

In addition to breaking encapsulation, state-lifting has many other negative consequences: hinders composition, increases complexity, complicates debugging, and hurts performance.

Instance Identity Model and Reconciliation

The instance identity model is often improperly distinguished from reconciliation. The instance identity model specifies when an instance's identity is preserved, and is part of React's public API. On the other hand, the algorithm React uses to reconcile the current user interface to the updated user interface is an implementation detail. Crucially, the reconciliation algorithm must respect the instance identity model.

Conclusion

React's instance identity model impacts nearly every aspect of its programming model. The two rules that React uses to determine instance identity are easy to understand and have proved workable in practice. Nevertheless, they are inadequate for expressing common identity requirements. The workarounds for this inadequacy such as state-lifting have serious problems. There is a clear need for a more general instance identity model.

Footnotes

Footnotes

  1. Virtual DOM is the popular name. A more general name is virtual node. Browsers represent a UI as a DOM tree. Virtual nodes are commonly used to virtualize the DOM. Hence, virtual DOM.

  2. In practice, a virtual node contains various internal properties. These are not relevant to the discussion, and elided for clarity.

  3. This is almost the same as saying that siblings can be distinguished by providing a key. However, instances at the same level need not coexist.

  4. React does not guarantee that it will render a new user interface on each state update. React only guarantees that it will render a new user interface after state updates asynchronously. Thus, many state updates may be batched into a single paint, and the intermediate states may never materialize. But the core conceptual model is still state/view correspondence.

  5. In React, your effects should always be idempotent. A dependency array even with perfect referential integrity is incapable of ensuring accurate effect triggering.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK