DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Coulibaly Daba Kevin
Coulibaly Daba Kevin

Posted on • Updated on

Beware of these traps on state and props management using react-hooks

The setup

We have two components. A parent component (Main), where some tag list resides. And a child component (TagList) that receives a tag list, each tag is rendered as a removable item.

A first approach could be something like the following.

Main Component

const Main = () => {
  const tags = ['one', 'two', 'three']

  return <TagList tags={tags} />
}

TagList Component

const TagList = (props = {}) => {
  const [tags, setTags] = useState(props.tags ?? [])

  const handleDeleteTag = index => {
    tags.splice(index, 1)
    setTags(tags)
  }

  const handleReset = () => setTags(props.tags)

  return (
    <div>
      {props.tags.map((tag, i) => (
        <div key={i}>
          <span>{tag}</span>
          <input type="button" value="x" onClick={() => handleDeleteTag(i)} />
        </div>
      ))}
      <input type="button" value="Reset" onClick={handleReset} />
    </div>
  )
}

Expectations

  • When the user clicks on an 'x' marked button, the corresponding tag on that line is removed.

  • When the user clicks on the reset button, after having made some changes to any item. The initial list should be displayed.

Results

If we run that code, we will notice that no matter which button is pressed, nothing seems to be happening.

But behind the scenes, if we open the "Components" tab of Google Chrome devtools, (it may be needed to unselect and reselect components) we notice something pretty interesting.

The TagList component state data have been updated, but props data have been modified too on both Components.

State changed but no update was triggered

Because the updated state object passed to the setTags is the variable provided by useState(props.tags), the same reference is detected, thus re-render operation is not triggered.

Components props data are modified

The useState and setTags methods passes its argument reference to the variable. In our case, it causes props.tags to mutate since it is passed as an argument on useState and setTags methods.

const [tags, setTags] = useState(props.tags ?? [])
const handleReset = () => setTags(props.tags)

Fix the issues

Reflect state updates

As we noticed, a state updated with a parameter that carries the same reference, won't cause a component to re-render. To fix the issue, we need to pass an argument with a different reference.

We'll make use of the ES6 spread operator to create a new array from updated tags.

const handleDeleteTag = index => {
  tags.splice(index, 1)
  setTags([...tags])
}

Prevent props to be changed

Since we know that useState and setTags mutates its passed parameter. We need to pass data in a way that doesn't lead props object to change.

const tagsInitialState = [...(props?.tags ?? [])]
const [tags, setTags] = useState(tagsInitialState)
const handleReset = () => setTags(tagsInitialState)

If you haven't been following on the latest ECMAScript specifications, that line may seem a bit tricky.

const tagsInitialState = [...(props?.tags ?? [])]

That line can be converted to.

const hasTags = props && props.tags && props.tags.length
const tagsInitialState = hasTags ? [...props.tags] : []

Final code

Our final TagList component code now looks like this

const TagList = (props = {}) => {
  const tagsInitialState = [...(props?.tags ?? [])]
  const [tags, setTags] = useState(tagsInitialState)

  const handleDeleteTag = index => {
    tags.splice(index, 1)
    setTags([...tags])
  }

  const handleReset = () => setTags(tagsInitialState)

  return (
    <div>
      {tags.map((t, i) => (
        <div key={i}>
          <span>{t}</span>
          <input type="button" value="x" onClick={() => handleDeleteTag(i)} />
        </div>
      ))}
      <input type="button" value="Reset" onClick={handleReset} />
    </div>
  )
}

I hope that helps!
Feel free to share thoughts in the comment section!

Top comments (5)

Collapse
 
clickclickonsal profile image
Sal Hernandez

I just want to say really good post! I like how you broke down the problem.

I wanted to point out that the useState does not mutate state, however, splice does mutate state. What you could do here is use slice which does the same thing as splice but without mutating the original array. And then you are correct that when calling setTags you want to create a new array. 😁

BTW I didn't know about that the ?. & the ?? features! Going to start using it tomorrow at work! Thanks! πŸ˜ƒ

const tagsInitialState = [...(props?.tags ?? [])]

Collapse
 
kcouliba profile image
Coulibaly Daba Kevin • Edited on

Thanks for your feedback! Your explanation about splice and slice is totally right and is another way to solve the state update issue.

I have been reproducing the argument variable mutation in a straightforward example on codesandbox.io to demonstrate that useState initialization passes its argument reference to output value. I have tried to find the cause in the source code, but I'm having a hard time finding out. I would appreciate any help or insight on that. :D

The optionnal chaining operator (?.) and nullish coalescing operator (??) are both currently stage 4 proposal. Make sure you are on the latest Babel version (at least 7.8.0)
It makes code really more predictable 😁

Collapse
 
clickclickonsal profile image
Sal Hernandez • Edited on

In the example, you are mutating the original array. The push method mutates the original array.

The reason why your example is working is that when you call updateComp, react triggers a re-render because there is a state change and thus your UI reflecting the changes to the array.

What you need to before updating an array is to make a copy of it so that you don't modify the original array. Then you can push/remove items in the clonedArray. and then you use the setValue method to update the value state. I reflected this in the code below using your example.

import React, { useState } from "react";
import "./styles.css";

function DisplayComp(props = {}) {
  // const [updateComp, doUpdateComp] = useState(false);
  const initialState = props.data; // that instantiation causes props to mutate
  // const initialState = [...props.data]; // that instantiation keeps props clean
  const [value, setValue] = useState(initialState);

  const handleClick = () => {
    const valueClone = [...value];
    valueClone.push((value[value.length - 1] || 1) * 2);
    setValue(valueClone)
    // doUpdateComp(!updateComp); // that line causes component to re-render
  };

  return (
    <div className="App">
      <h1>React Hooks state management</h1>
      <button onClick={handleClick}>Mutate</button>
      <p>Initial state value : {initialState.join(" ")}</p>
      <p>use state value : {value.join(" ")}</p>
    </div>
  );
}

export default function App() {
  return <DisplayComp data={[21]} />;
}
Collapse
 
juanfrank77 profile image
Juan F Gonzalez

Congrats on your first post!! Very well redacted, the content is especially useful for what I do.

Collapse
 
kcouliba profile image
Coulibaly Daba Kevin

Thanks for your encouragement! I am happy I could help.

Build Anything...


Use any Linode offering to create something for the DEV x Linode Hackathon 2022. A variety of prizes are up for grabs, inculding $1,000 USD. πŸ‘€

β†’ Join the Hackathon <-