React Element in State: What could go wrong?
2 mins |
January 10, 2022All 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 <Todo5 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([])34 function handleDelete(index) {5 console.log("handleDelete", { index, todos })6 setTodos(todos.filter((_, i) => i !== index))7 }89 function handleAddTodo() {10 setTodos([11 ...todos,12 <Todo13 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)45 function handleDelete(index) {6 if (!isDeleteMode) {7 alert("Please enable delete mode first")8 return9 }10 setTodos(currentTodos => currentTodos.filter((_, i) => i !== index))11 }1213 function handleAddTodo() {14 setTodos([15 ...todos,16 <Todo17 key={todos.length}18 text={generateRandomTodo()}19 onDelete={() => handleDelete(todos.length)}20 />,21 ])22 }23 return (24 <CodeDemo>25 <div>26 <input27 type="checkbox"28 checked={isDeleteMode}29 onChange={() => setIsDeleteMode(!isDeleteMode)}30 id="deleteMode1"31 ></input>32 <label htmlFor="deleteMode1"> Enable Delete Mode</label>33 </div>3435 <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.34 function handleAddTodo() {5- setTodos([6- ...todos,7- <Todo8- key={todos.length}9- text={generateRandomTodo()}10- onDelete={() => handleDelete(todos.length)}11- />,12- ])13+ setTodos([...todos, { text: generateRandomTodo() }])14 }1516.17.1819- <ul style={{ padding: "10px" }}>{todos}</ul>20+ <ul style={{ padding: "10px" }}>21+ {todos.map((todo, index) => (22+ <Todo23+ 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)45 function handleDelete(idToDelete) {6 if (!isDeleteMode) {7 alert("Please enable delete mode first")8 return9 }10 setTodos(currentTodos =>11 currentTodos.filter(todo => todo.id !== idToDelete)12 )13 }1415 function handleAddTodo() {16 setTodos([17 ...todos,18 { text: generateRandomTodo(), id: new Date().getTime() },19 ])20 }2122 return (23 <CodeDemo>24 <div>25 <input26 type="checkbox"27 checked={isDeleteMode}28 onChange={() => setIsDeleteMode(!isDeleteMode)}29 id="deleteMode"30 ></input>31 <label htmlFor="deleteMode"> Enable Delete Mode</label>32 </div>3334 <ul style={{ padding: "10px" }}>35 {todos.map(todo => (36 <Todo37 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
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