DEV Community

Cover image for Deep dive into React keys bugs
Nikita Kozlov
Nikita Kozlov

Posted on

Deep dive into React keys bugs

A few days ago, I was casually browsing open positions and one job application had a quick question: "What is wrong with this React code?"

<ul>{['qwe', 'asd', 'zxc'].map(item => (<li>{item}</li>))}</ul>
Enter fullscreen mode Exit fullscreen mode

Quick answer would be that it's missing key property, but at this moment I caught myself on the feeling that I don't deeply understand what are React keys and what can go wrong if we use it incorrectly. Let's figure it out together!

✋ Stop here for a moment, can you come up with an actual bug caused by misusing React keys? Please share your example in the comments!

What are React keys anyway

This will be a bit of simplified explanation, but it should be enough to dive into examples.

When we have some previous inner state and the new inner state, we want to calculate the difference between them, so we can update them DOM to represent the new inner state.

diff = new_state - old_state
new_dom = old_dom + diff
Enter fullscreen mode Exit fullscreen mode

Let's take a look at this example, there is a list of items, and we are adding new item to the bottom of the list.

Diff example 1

Computing this diff won't be that hard, but what happens if we shuffle the new list?

Diff example 2

Computing diff over these changes suddenly isn't that easy, especially when there are children down the tree. We need to compare each item with each to figure out where something moved.

Keys for the rescue! Basically with keys you are hinting to React where all items moved in this shuffle, so it doesn't need to calculate it itself. It can just take existing items and put them in the right place.

Diff keys hints

So what bad can happen if we ignore or misuse these keys?

Case 1. Performance issues

Here is the simple app if you want to play with it yourself.

We can use a simple component which just logs if props were updated.

let Item: FC<any> = ({ item }) => {
    let [prevItem, setPrevItem] = useState(undefined);
    useEffect(() => {
        console.log('On update', item, prevItem);
        setPrevItem(item);
    }, [item]);

    return <div>{item.title}</div>;
};
Enter fullscreen mode Exit fullscreen mode

Example 1. Add items to the end of the list, don't use keys

Case 1 ok

As you may expect, there are just new components.

Example 2. Add items to the start of the list, don't use keys

case 1 bug

Things aren't going as expected here, there are n updates on each click where n is the number of items in the list. On each new item, all items shift to the next component, which may be a bit confusing at first.

Take another look at the console log here again.

Example 3 & 4. Add items anywhere, use ID as a key

case 1 fix

It works perfectly, no unneeded updates, React know exactly where each component moved.

Case 2. Bugs with inputs

Here is the simple app if you want to play with it yourself.

The issue with keys in this example is that if you don't re-create DOM elements because of incorrect React keys, these elements can keep user input, when underlying data was changed.

In this example, there is just a list of Items.

{items.map((item) => (
    <Item item={item} onUpdate={handleUpdate} onDelete={handleDelete} />
))}
Enter fullscreen mode Exit fullscreen mode

And each item is just an input with a control button.

let Item = ({ item, onUpdate, onDelete }) => {
    // ...

    return (
        <div>
            <input
                defaultValue={item.title}
                placeholder="Item #..."
                onChange={handleChange}
            />
            &nbsp;
            <button onClick={handleDelete}>x</button>
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

Also, there is a dump of an inner state down on the page

{JSON.stringify(items, null, 2)}
Enter fullscreen mode Exit fullscreen mode

Example1. Create a few items and delete the first one, don't use any keys.

Before deletion:
case 2 ok

After deletion:
case 2 bug

As you see, inner state got unsynchronized with DOM state, because inner models shifted as in the first example, but view stayed the same.

This happens because React doesn't actually recreate an element of the same type (docs), but just updates the property.

Example 2. Create a few items and delete the first one, use ID as a key.

case 2 fix

As expected, everything works fine here.

Case 3. Bugs with effects & DOM manipulations

Here is the simple app if you want to play with it yourself.

The fun part is that React keys isn't only about lists, they may be used with singe item as well.

Let's imagine that we have a task to show some notifications for users for 5 seconds, e.g. these are some "💰 Deals 🤝".

Some straightforward implementation when you just hide this box when timer fires.

// We want this message to disapear in 5 seconds
let Notification = ({ message }) => {
    let ref = useRef<HTMLDivElement | null>(null);

    useEffect(() => {
        setTimeout(() => {
            if (ref.current != null) {
                ref.current.style.display = 'none';
            }
        }, 5000);
    }, [message]);
    return <div ref={ref}>{message}</div>;
};
Enter fullscreen mode Exit fullscreen mode

Example 1. Generate notification, wait a bit, generate again.

case 3 bug

🐞 Nothing happens if we try to generate another notification.

This is because React doesn't re-create the component just because of an updated property, it expects the component to handle this on its own.

Example 2. Generate notification, wait a bit, generate again, but use message as a key.

case 3 fix

It works!

Case 4. Bugs with animations

Here is the simple app if you want to play with it yourself.

What if we want to somehow highlight newly created items in our fancy to-do list?

@keyframes fade {
    from {
        color: red;
        opacity: 0;
    }
    to {
        color: inherit;
        opacity: 1;
    }
}

.item {
    animation: fade 1s;
}
Enter fullscreen mode Exit fullscreen mode

Example 1. Add new item to the end, don't use any keys.

case 4 ok

Looks ok to me.

Example 2. Add new item to the start, don't use any keys.

case 4 bug

Something is off, we are adding items to the start, but the last item is highlighted.

This happens again because React shifts inner models, same issue as for bug with inputs.

Example 3. Add new item to the start, use ID as a key.

case 4 fix

Everything works perfectly.

Final notes

So as we figured out, React keys aren't something magical, they are just hinting React if we need to re-create or update some component.

As for the initial question:

<ul>{['qwe', 'asd', 'zxc'].map(item => (<li>{item}</li>))}</ul>
Enter fullscreen mode Exit fullscreen mode

Here is the stup where you can try all solutions.

Solution 1: Do nothing.

In this concrete example, this list should work just fine because there are just 3 items, and you don't update them, but it won't be as much performant and there will be an annoying warning in the console.

console warning

Solution 2: Item as a key.

If you are sure that this list have only unique values, e.g. contact information, you can use these values as keys.

      <ul>
        {['qwe', 'asd', 'zxc'].map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
Enter fullscreen mode Exit fullscreen mode

Solution 3: Index as a key.

If you are sure that this list never changes by user or anyone else except by the developer, you can use index as a key.

      <ul>
        {['qwe', 'asd', 'zxc'].map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
Enter fullscreen mode Exit fullscreen mode

Be careful using indexes as keys because in all examples above you can set keys as indexes and all bugs will persist.

Solution 4: Generated keys.

You also can try to generate the keys.

let generateKey = () => {
  console.log('Generating key...');
  return Math.trunc(Math.random() * 1000).toString();
};

/// ...
    <ul>
        {['qwe', 'asd', 'zxc'].map((item) => (
          <li key={generateKey()}>{item}</li>
        ))}
    </ul>
Enter fullscreen mode Exit fullscreen mode

In this case, you need to consider that these keys will be generated every time you update the component's state.

multiple updates

Solution 5: Keys which are generated once

To solve previous issue you need to move this array somewhere outside of a React component and generate keys manually.

let addKeysToArray = (array) =>
  array.map((item) => ({
    key: generateKey(),
    value: item,
  }));

let array = ['qwe', 'asd', 'zxc']
let arrayWithKeys = addKeysToArray(array)
console.log(arrayWithKeys)
Enter fullscreen mode Exit fullscreen mode

array with keys example

References


p.s.: I'm looking for a remote senior frontend developer position, so if you are hiring or if you can reference me, please take a look on my cv 👋

Discussion (1)