React Element in State: What could go wrong?

2 mins |

January 10, 2022

All most applications we build have some form of dynamic UI. Where user interacts with the app, and then we create/update/delete elements in the app. We use React State to store dynamic values. And our interactions update the state, which updates our UI. But we need to be a bit careful when we store these dynamic UIs (React Elements) in state instead of storing just the data required to render React Elements.

Let’s see what could go wrong using our favorite example, ToDo list app. Where we can just add some ToDo’s (For sake a simplicity, ToDo’s are generated randomly).

    You can click on “Add ToDo” to create your ToDo list

    1function TodoList() {
    2 const [todos, setTodos] = React.useState([])
    3 function handleAddTodo() {
    4 setTodos([
    5 ...todos,
    6 <Todo key={todos.length} text={generateRandomTodo()} />,
    7 ])
    8 }
    9 return (
    10 <CodeDemo>
    11 <ul style={{ padding: "10px" }}>{todos}</ul>
    12 <button onClick={handleAddTodo}>Add ToDo</button>
    13 </CodeDemo>
    14 )
    15}

    Here, we are appending the component <Todo /> to the state todos. Do you see any potenial bug here? If you do then awesome 🕺, let see how we can solve it in this blog.

    If you weren’t able to see any bug, you can try to add some more features to discover the bug. Let’s add a way to delete a ToDo.


      1function handleAddTodo() {
      2 setTodos([
      3 ...todos,
      4 <Todo
      5 key={todos.length}
      6 text={generateRandomTodo()}
      7+ onDelete={() => handleDelete(todos.length)}
      8 />,
      9 ])
      10 }

      To discover the bug, you can try to adding three ToDos and then delete the second one. You might see that you are deleting the second ToDo, but it is deleting the third one too.

      To inspect what is happening, we can add logs to our handleDelete function. We see that when are deleting the second item, the index is 1 as expected. But the todos just has two items, it should have been three.

      1function TodoList() {
      2 const [todos, setTodos] = React.useState([])
      3
      4 function handleDelete(index) {
      5 console.log("handleDelete", { index, todos })
      6 setTodos(todos.filter((_, i) => i !== index))
      7 }
      8
      9 function handleAddTodo() {
      10 setTodos([
      11 ...todos,
      12 <Todo
      13 key={todos.length}
      14 text={generateRandomTodo()}
      15 onDelete={() => handleDelete(todos.length)}
      16 />,
      17 ])
      18 }
      19 return (
      20 <CodeDemo>
      21 <ul style={{ padding: "10px" }}>{todos}</ul>
      22 <button onClick={handleAddTodo}>Add ToDo</button>
      23 </CodeDemo>
      24 )
      25}

      We have a closure bug here, so we can see that the todos has the value when () => handleDelete(index) was created for the second item. At that moment todos had only two items. So we can fix that by using the state setter function.

      1function handleDelete(index) {
      2- setTodos(todos.filter((_, i) => i !== index))
      3+ setTodos(currentTodos => currentTodos.filter((_, i) => i !== index))
      4 }

        Oh! awesome, we have fixed the bug. But we have a problem. If you try adding another ToDo, after deleting you see that we get a warning from React that there are duplicate keys. But for now let it be 🤷.

        Let’s add another feature to our ToDo app. We want our users to be a bit careful when the delete a ToDo. Let’s introduce delete mode. They can only delete ToDo’s if they are in delete mode.

        1function TodoList() {
        2 const [todos, setTodos] = React.useState([])
        3 const [isDeleteMode, setIsDeleteMode] = React.useState(false)
        4
        5 function handleDelete(index) {
        6 if (!isDeleteMode) {
        7 alert("Please enable delete mode first")
        8 return
        9 }
        10 setTodos(currentTodos => currentTodos.filter((_, i) => i !== index))
        11 }
        12
        13 function handleAddTodo() {
        14 setTodos([
        15 ...todos,
        16 <Todo
        17 key={todos.length}
        18 text={generateRandomTodo()}
        19 onDelete={() => handleDelete(todos.length)}
        20 />,
        21 ])
        22 }
        23 return (
        24 <CodeDemo>
        25 <div>
        26 <input
        27 type="checkbox"
        28 checked={isDeleteMode}
        29 onChange={() => setIsDeleteMode(!isDeleteMode)}
        30 id="deleteMode1"
        31 ></input>
        32 <label htmlFor="deleteMode1"> Enable Delete Mode</label>
        33 </div>
        34
        35 <ul style={{ padding: "10px" }}>{todos}</ul>
        36 <button onClick={handleAddTodo}>Add ToDo</button>
        37 </CodeDemo>
        38 )
        39}


          Oh no! We have a bug here. We can see that when we delete a ToDo, we are getting an alert🚨 saying Please enable delete mode first. Even though we have enabled the delete mode. When we debug we see that the isDeleteMode is false. This is the same bug as before 🤦. Our isDeleteMode is not being updated. It is same as when the () => handleDelete(index) was created by handleAddTodo.

          Our earlier fix only worked, when handleDelete was only dependent on one state todos. But now it dependent on two states, isDeleteMode and todos.

          To fix this, we will have to re-create () => handleDelete(index) when isDeleteMode changes. But that will only solve the problem for now. If handleDelete has more dependency, then our current fix will break.

          Let’s fix this once in for all. We will refactor it a bit, instead of todos keeping the React elements, let it just hold the data required to render the element.

          1.
          2.
          3
          4 function handleAddTodo() {
          5- setTodos([
          6- ...todos,
          7- <Todo
          8- key={todos.length}
          9- text={generateRandomTodo()}
          10- onDelete={() => handleDelete(todos.length)}
          11- />,
          12- ])
          13+ setTodos([...todos, { text: generateRandomTodo() }])
          14 }
          15
          16.
          17.
          18
          19- <ul style={{ padding: "10px" }}>{todos}</ul>
          20+ <ul style={{ padding: "10px" }}>
          21+ {todos.map((todo, index) => (
          22+ <Todo
          23+ key={index}
          24+ onDelete={() => handleDelete(index)}
          25+ text={todo.text}
          26+ />
          27+ ))}
          28+ </ul>
          29.
          30.

            Bonus

            Our key for Todo is not unique. We can fix that by using id instead of using index. We need any unique identifier while creating todo. We could use the current time. But that is not always good, if the backend is saving this data, let the backend create this ID for you. But for now the current date time works fine.


              Here is our final solution. We have a unique ID for each ToDo. We can use that to delete our ToDo too.

              1function TodoList() {
              2 const [todos, setTodos] = React.useState([])
              3 const [isDeleteMode, setIsDeleteMode] = React.useState(false)
              4
              5 function handleDelete(idToDelete) {
              6 if (!isDeleteMode) {
              7 alert("Please enable delete mode first")
              8 return
              9 }
              10 setTodos(currentTodos =>
              11 currentTodos.filter(todo => todo.id !== idToDelete)
              12 )
              13 }
              14
              15 function handleAddTodo() {
              16 setTodos([
              17 ...todos,
              18 { text: generateRandomTodo(), id: new Date().getTime() },
              19 ])
              20 }
              21
              22 return (
              23 <CodeDemo>
              24 <div>
              25 <input
              26 type="checkbox"
              27 checked={isDeleteMode}
              28 onChange={() => setIsDeleteMode(!isDeleteMode)}
              29 id="deleteMode"
              30 ></input>
              31 <label htmlFor="deleteMode"> Enable Delete Mode</label>
              32 </div>
              33
              34 <ul style={{ padding: "10px" }}>
              35 {todos.map(todo => (
              36 <Todo
              37 key={todo.id}
              38 onDelete={() => handleDelete(todo.id)}
              39 text={todo.text}
              40 />
              41 ))}
              42 </ul>
              43 <button onClick={handleAddTodo}>Add ToDo</button>
              44 </CodeDemo>
              45 )
              46}

              Well, that fixes our bugs. To make sure that we don’t add these bugs later, you can learn about writing tests for them from Testing Lists Items With React Testing Library.


              Got a Question? Bala might have the answer. Get them answered on #AskBala

              Edit on Github

              Subscribe Now! Letters from Bala

              Subscribe to my newsletter to receive letters about some interesting patterns and views in programming, frontend, Javascript, React, testing and many more. Be the first one to know when I publish a blog.

              No spam, just some good stuff! Unsubscribe at any time

              Written by Balavishnu V J. Follow him on Twitter to know what he is working on. Also, his opinions, thoughts and solutions in Web Dev.