skip to Main Content

I have the following data structured in this way:

type GroupType = {
    id: number // Always unique
    type: "group"
    groups: (GroupType | OtherType)[]
}

type OtherType = {
    id: number // Always unique
    type: "other"
    text: string
}

The ids in both of these types are always unique, even between the GroupType and the OtherType.

I have the following component:

type Props = {
    group: GroupType
}

export default function Group({ group }: Props) {
    const elements = group.groups.map(ele => {
        if (ele.type === "other") return <p key={ele.id}>{ele.text}</p>
        else return <Group key={ele.id} group={ele} />
    })

    return (
        <div>
            {elements}
        </div>
    )
}

Here is the situation, when the data (GroupType.groups) is rearranged, it reloads all components from the beginning (which is okay), but when I move one of them from one GroupType.groups to another GroupType.groups, it creates new components from the beginning because the elements in a list are only related to each other if they are siblings (differentiating them by the key). How do I make them related? (Whether using the key or not). The id key is always unique among all other objects regardless where each object is in the nesting.

Example

Let’s say we have the following data:

const group: GroupType = {
    id: 0,
    type: "group",
    groups: [
        {
            id: 1,
            type: "other",
            text: "text 1",
        },
        {
            id: 2,
            type: "group",
            groups: [
                {
                    id: 3,
                    type: "other",
                    text: "text 2",
                },
                {
                    id: 4,
                    text: "text 3",
                }
            ]
        },
        {
            id: 5,
            type: "other",
            text: "text 2",
        },
    ],
}

When the above is rendered as a component (<Group group={group}>), the following component structure is created:

App
  Group (key=0)
    p (key=1)
    Group (key=2)
      p (key=3)
      p (key=4)
    p (key=5)

Now, keys 1, 2, and 5 are siblings, which means that if I rearrange them, react will also rearrange them and not create an entirely new component. Keys 3 and 4 are siblings too, but keys 1 and 3 are NOT siblings, which mean that if I rearrange the keys and, for example, move key 3 to be before key 2

App
  Group (key=0)
    p (key=1)
    p (key=3)
    Group (key=2)
      p (key=4)
    p (key=5)

react will create a new component for it because it’s a new key between these siblings (1, 2, and 5). This is my problem. I do not want react to treat it as a new component, but rather has a siblings (if possible) of them.

Why do I need this?

On my website, a user can navigate the website with just the keyboard (for accessibility reasons). I have some components that move based on the keys pressed by the user and what the user selects (by focus-visible). When a user moves a component, it deselects that component, which is annoying because now they have to move through the components to get to the right component and move it again.

2

Answers


  1. if u want to relate them with key then u can use index of each element in key’s value, this can remove ur dependency on id cause u said id is unique

    Login or Signup to reply.
  2. Short answer

    As we know, the key is with respect to the container. Therefore even if there is no change in keys, a change in container will lead to recreating the same component.

    Detailed answer

    The above point puts the emphasis on the container. On the other side, a recursive rendering as the below code does, has a significant impact on its resulting containers.

    export default function Group({ group }: Props) {
       ...
       else return <Group key={ele.id} group={ele} />
       ...
       console.log(elements);
       return <div>{elements}</div>;
    }
    

    The console log in the code will help us to know that with the given data, this component is rendered twice with two separate data. It means the below single declaration rendered in two separate times. It is true since there is a recursive call for the nested group with id 2 in addition to the initial call for the id 0.

    <Group group={group} key="0" />
    

    Let us view the console log generated:

    // Array 1
    // 0:{$$typeof: Symbol(react.element), key: '1', ...
    // 1:{$$typeof: Symbol(react.element), key: '2', ...
    // 2:{$$typeof: Symbol(react.element), key: '5', ...
    
    // Array 2
    // 0:{$$typeof: Symbol(react.element), key: '3' ...
    // 1:{$$typeof: Symbol(react.element), key: '4' ...
    

    Observation

    These are the two distinct arrays React has created for us while rendering the component. In this case, the two arrays containing the items 1,2,5 and items 3,4 respectively.

    Whenever there is a change in the data resulting a change in the containing array, react will remove the component from the container it has been changed from, and will add the component to the container it has been changed to. This is the reason for the issue we have been facing in this post while moving an object from one group to another.

    Coming back to the point again, we face this issue since internally there are separate arrays for each nested group.

    One possible solution

    One solution may be to render in a way that it does not produce separate containers with respect to each group. However, this approach will necessitate a review on the recursive render. We need to find a way to render it so that the entire items contained in a single array, so that we can move items as we will. And react will not either remove or add components.

    The following sample code demoes two things:

    a. The existing render and the issue we now face.

    b. Render without recursion, so that the issue may be addressed.

    App.js

    import { useState } from 'react';
    
    export default function App() {
      const [someData, setSomeData] = useState(getInitialData());
    
      return (
        <>
          Existing declaration
          <Group group={someData} key="0" />
          <br />
          proposed declaration List
          <br />
          <List obj={someData} key="00" />
          <br />
          <button
            onClick={() => {
              setSomeData(moveTextFromGroup2toGroup0());
            }}
          >
            move text 3 from Group 2 to Group 0
          </button>
          <br />
          <button
            onClick={() => {
              setSomeData(moveTextWithinGroup2());
            }}
          >
            move text 3 withing Group 2
          </button>
          <br />
          <button
            onClick={() => {
              setSomeData(getInitialData());
            }}
          >
            Reset
          </button>
        </>
      );
    }
    
    function List({ obj }) {
      let items = [];
      let stack = [];
    
      stack.push(obj);
    
      while (stack.length) {
        const o = stack[0]; // o for object
        if (o.type === 'group') {
          // if group type, then push into stack
          // to process in the next iteration
          for (let i = 0; i < o.groups.length; i++) {
            stack.push({ ...o.groups[i], groupId: o.id });
          }
        } else {
          // if not group type, keep to render
          items.push(<A key={o.id} label={'Group ' + o.groupId + ':' + o.text} />);
        }
        stack.shift(); // remove the processed object
      }
      return items;
    }
    
    function Group({ group }) {
      const elements = group.groups.map((ele) => {
        if (ele.type === 'other')
          return <A key={ele.id} label={'Group ' + group.id + ':' + ele.text} />;
        else return <Group key={ele.id} group={ele} />;
      });
      console.log(elements);
      return <div>{elements}</div>;
    }
    
    function A({ label }) {
      const [SomeInput, setSomeInput] = useState('');
      return (
        <>
          <label>{label}</label>
          <input
            value={SomeInput}
            onChange={(e) => setSomeInput(e.target.value)}
          ></input>
          <br />
        </>
      );
    }
    
    function getInitialData() {
      return {
        id: 0,
        type: 'group',
        groups: [
          {
            id: 1,
            type: 'other',
            text: 'text 1',
          },
          {
            id: 2,
            type: 'group',
            groups: [
              {
                id: 3,
                type: 'other',
                text: 'text 3',
              },
              {
                id: 4,
                type: 'other',
                text: 'text 4',
              },
            ],
          },
          {
            id: 5,
            type: 'other',
            text: 'text 5',
          },
        ],
      };
    }
    
    function moveTextWithinGroup2() {
      return {
        id: 0,
        type: 'group',
        groups: [
          {
            id: 1,
            type: 'other',
            text: 'text 1',
          },
          {
            id: 2,
            type: 'group',
            groups: [
              {
                id: 4,
                type: 'other',
                text: 'text 3',
              },
              {
                id: 3,
                type: 'other',
                text: 'text 4',
              },
            ],
          },
          {
            id: 5,
            type: 'other',
            text: 'text 5',
          },
        ],
      };
    }
    
    function moveTextFromGroup2toGroup0() {
      return {
        id: 0,
        type: 'group',
        groups: [
          {
            id: 1,
            type: 'other',
            text: 'text 1',
          },
          {
            id: 3,
            type: 'other',
            text: 'text 3',
          },
          {
            id: 2,
            type: 'group',
            groups: [
              {
                id: 4,
                type: 'other',
                text: 'text 4',
              },
            ],
          },
          {
            id: 5,
            type: 'other',
            text: 'text 5',
          },
        ],
      };
    }
    

    Test run

    On loading the app

    enter image description here

    Test to move the component Text 3 in Group 2 to Group 0 – using the recursive rendering

    enter image description here

    After clicking the button "move text 3 from Group 2 to Group 0".

    enter image description here

    Observation

    The component has been moved from Group 2 to Group 0 as we can verify from the labels, however, the input has been lost. It means, React has removed the component from Group 2 and newly added it to Group 0.

    We shall do the same test with rendering without recursion

    enter image description here

    After clicking the button "move text 3 from Group 2 to Group 0".

    enter image description here

    Observation

    The component has been moved by retaining the input. It means, React has neither removed nor added it.

    Therefore the point to take note may this:

    Components with keys will retain retain states as long as its container is not changing.

    Aside :
    The component without keys will also retain states as longs its position in the container is not changing.

    Note:

    The sole objective of the proposed solution is not to say do not use recursive rendering and use imperative way as the sample code does. The sole objective here is to make it clear that – Container has great significance in retaining states.

    Citations:

    Is it possible to traverse object in JavaScript in non-recursive way?

    Option 2: Resetting state with a key

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search