Updating Objects in State
State can hold any kind of JavaScript value, including objects. But you shouldn't change objects that you hold in the React state directly. Instead, when you want to update an object, you need to create a new one (or make a copy of an existing one), and then set the state to use that copy.
- How to correctly update an object in React state
- How to update a nested object without mutating it
- What immutability is, and how not to break it
- How to make object copying less repetitive with Immer
What's a mutation?
You can store any kind of JavaScript value in state.
const [x, setX] = useState(0);
So far you've been working with numbers, strings, and booleans. These kinds of JavaScript values are "immutable", meaning unchangeable or "read-only". You can trigger a re-render to replace a value:
setX(5);
The x
state changed from 0
to 5
, but the number 0
itself did not change. It's not possible to make any changes to the built-in primitive values like numbers, strings, and booleans in JavaScript.
Now consider an object in state:
const [position, setPosition] = useState({ x: 0, y: 0 });
Technically, it is possible to change the contents of the object itself. This is called a mutation:
position.x = 5;
However, although objects in React state are technically mutable, you should treat them as if they were immutable--like numbers, booleans, and strings. Instead of mutating them, you should always replace them.
Treat state as read-only
In other words, you should treat any JavaScript object that you put into state as read-only.
This example holds an object in state to represent the current pointer position. The red dot is supposed to move when you touch or move the cursor over the preview area. But the dot stays in the initial position:
import { useState } from 'react';
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0
});
return (
<div
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}
style={{
position: 'relative',
width: '100vw',
height: '100vh',
}}>
<div style={{
position: 'absolute',
backgroundColor: 'red',
borderRadius: '50%',
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}} />
</div>
);
}
body { margin: 0; padding: 0; height: 250px; }
The problem is with this bit of code.
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}
This code modifies the object assigned to position
from the previous render. But without using the state setting function, React has no idea that object has changed. So React does not do anything in response. It's like trying to change the order after you've already eaten the meal. While mutating state can work in some cases, we don't recommend it. You should treat the state value you have access to in a render as read-only.
To actually trigger a re-render in this case, create a new object and pass it to the state setting function:
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
With setPosition
, you're telling React:
- Replace
position
with this new object - And render this component again
Notice how the red dot now follows your pointer when you touch or hover over the preview area:
import { useState } from 'react';
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0
});
return (
<div
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
style={{
position: 'relative',
width: '100vw',
height: '100vh',
}}>
<div style={{
position: 'absolute',
backgroundColor: 'red',
borderRadius: '50%',
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}} />
</div>
);
}
body { margin: 0; padding: 0; height: 250px; }
Local mutation is fine
Code like this is a problem because it modifies an existing object in state:
position.x = e.clientX;
position.y = e.clientY;
But code like this is absolutely fine because you're mutating a fresh object you have just created:
const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);
In fact, it is completely equivalent to writing this:
setPosition({
x: e.clientX,
y: e.clientY
});
Mutation is only a problem when you change existing objects that are already in state. Mutating an object you've just created is okay because no other code references it yet. Changing it isn't going to accidentally impact something that depends on it. This is called a "local mutation". You can even do local mutation while rendering. Very convenient and completely okay!
Copying objects with the spread syntax
In the previous example, the position
object is always created fresh from the current cursor position. But often, you will want to include existing data as a part of the new object you're creating. For example, you may want to update only one field in a form, but keep the previous values for all other fields.
These input fields don't work because the onChange
handlers mutate the state:
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: 'bhepworth@sculpture.com'
});
function handleFirstNameChange(e) {
person.firstName = e.target.value;
}
function handleLastNameChange(e) {
person.lastName = e.target.value;
}
function handleEmailChange(e) {
person.email = e.target.value;
}
return (
<>
<label>
First name:
<input
value={person.firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:
<input
value={person.lastName}
onChange={handleLastNameChange}
/>
</label>
<label>
Email:
<input
value={person.email}
onChange={handleEmailChange}
/>
</label>
<p>
{person.firstName}{' '}
{person.lastName}{' '}
({person.email})
</p>
</>
);
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }
For example, this line mutates the state from a past render:
person.firstName = e.target.value;
The reliable way to get the behavior you're looking for is to create a new object and pass it to setPerson
. But here, you want to also copy the existing data into it because only one of the fields has changed:
setPerson({
firstName: e.target.value, // New first name from the input
lastName: person.lastName,
email: person.email
});
You can use the ...
object spread syntax so that you don't need to copy every property separately.
setPerson({
...person, // Copy the old fields
firstName: e.target.value // But override this one
});
Now the form works!
Notice how you didn't declare a separate state variable for each input field. For large forms, keeping all data grouped in an object is very convenient--as long as you update it correctly!
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: 'bhepworth@sculpture.com'
});
function handleFirstNameChange(e) {
setPerson({
...person,
firstName: e.target.value
});
}
function handleLastNameChange(e) {
setPerson({
...person,
lastName: e.target.value
});
}
function handleEmailChange(e) {
setPerson({
...person,
email: e.target.value
});
}
return (
<>
<label>
First name:
<input
value={person.firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:
<input
value={person.lastName}
onChange={handleLastNameChange}
/>
</label>
<label>
Email:
<input
value={person.email}
onChange={handleEmailChange}
/>
</label>
<p>
{person.firstName}{' '}
{person.lastName}{' '}
({person.email})
</p>
</>
);
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }
Note that the ...
spread syntax is "shallow"--it only copies things one level deep. This makes it fast, but it also means that if you want to update a nested property, you'll have to use it more than once.
Using a single event handler for multiple fields
You can also use the [
and ]
braces inside your object definition to specify a property with dynamic name. Here is the same example, but with a single event handler instead of three different ones:
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: 'bhepworth@sculpture.com'
});
function handleChange(e) {
setPerson({
...person,
[e.target.name]: e.target.value
});
}
return (
<>
<label>
First name:
<input
name="firstName"
value={person.firstName}
onChange={handleChange}
/>
</label>
<label>
Last name:
<input
name="lastName"
value={person.lastName}
onChange={handleChange}
/>
</label>
<label>
Email:
<input
name="email"
value={person.email}
onChange={handleChange}
/>
</label>
<p>
{person.firstName}{' '}
{person.lastName}{' '}
({person.email})
</p>
</>
);
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }
Here, e.target.name
refers to the name
property given to the <input>
DOM element.
Updating a nested object
Consider a nested object structure like this:
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
If you wanted to update person.artwork.city
, it's clear how to do it with mutation:
person.artwork.city = 'New Delhi';
But in React, you treat state as immutable! In order to change city
, you would first need to produce the new artwork
object (pre-populated with data from the previous one), and then produce the new person
object which points at the new artwork
:
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);
Or, written as a single function call:
setPerson({
...person, // Copy other fields
artwork: { // but replace the artwork
...person.artwork, // with the same one
city: 'New Delhi' // but in New Delhi!
}
});
This gets a bit wordy, but it works fine for many cases:
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
function handleNameChange(e) {
setPerson({
...person,
name: e.target.value
});
}
function handleTitleChange(e) {
setPerson({
...person,
artwork: {
...person.artwork,
title: e.target.value
}
});
}
function handleCityChange(e) {
setPerson({
...person,
artwork: {
...person.artwork,
city: e.target.value
}
});
}
function handleImageChange(e) {
setPerson({
...person,
artwork: {
...person.artwork,
image: e.target.value
}
});
}
return (
<>
<label>
Name:
<input
value={person.name}
onChange={handleNameChange}
/>
</label>
<label>
Title:
<input
value={person.artwork.title}
onChange={handleTitleChange}
/>
</label>
<label>
City:
<input
value={person.artwork.city}
onChange={handleCityChange}
/>
</label>
<label>
Image:
<input
value={person.artwork.image}
onChange={handleImageChange}
/>
</label>
<p>
<i>{person.artwork.title}</i>
{' by '}
{person.name}
<br />
(located in {person.artwork.city})
</p>
<img
src={person.artwork.image}
alt={person.artwork.title}
/>
</>
);
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }
img { width: 200px; height: 200px; }
Objects are not really nested
An object like this appears "nested" in code:
let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};
However, "nesting" is an inaccurate way to think about how objects behave. When the code executes, there is no such thing as a "nested" object. You are really looking at two different objects:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
The obj1
object is not "inside" obj2
. For example, obj3
could "point" at obj1
too:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
let obj3 = {
name: 'Copycat',
artwork: obj1
};
If you were to mutate obj3.artwork.city
, it would affect both obj2.artwork.city
and obj1.city
. This is because obj3.artwork
, obj2.artwork
, and obj1
are the same object. This is difficult to see when you think of objects as "nested". Instead, they are separate objects "pointing" at each other with properties.
Write concise update logic with Immer
If your state is deeply nested, you might want to consider flattening it. But, if you don't want to change your state structure, you might prefer a shortcut to nested spreads. Immer is a popular library that lets you write using the convenient but mutating syntax and takes care of producing the copies for you. With Immer, the code you write looks like you are "breaking the rules" and mutating an object:
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
But unlike a regular mutation, it doesn't overwrite the past state!
How does Immer work?
The draft
provided by Immer is a special type of object, called a Proxy, that "records" what you do with it. This is why you can mutate it freely as much as you like! Under the hood, Immer figures out which parts of the draft
have been changed, and produces a completely new object that contains your edits.
To try Immer:
- Run
npm install use-immer
to add Immer as a dependency - Then replace
import { useState } from 'react'
withimport { useImmer } from 'use-immer'
Here is the above example converted to Immer:
import { useImmer } from 'use-immer';
export default function Form() {
const [person, updatePerson] = useImmer({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
function handleNameChange(e) {
updatePerson(draft => {
draft.name = e.target.value;
});
}
function handleTitleChange(e) {
updatePerson(draft => {
draft.artwork.title = e.target.value;
});
}
function handleCityChange(e) {
updatePerson(draft => {
draft.artwork.city = e.target.value;
});
}
function handleImageChange(e) {
updatePerson(draft => {
draft.artwork.image = e.target.value;
});
}
return (
<>
<label>
Name:
<input
value={person.name}
onChange={handleNameChange}
/>
</label>
<label>
Title:
<input
value={person.artwork.title}
onChange={handleTitleChange}
/>
</label>
<label>
City:
<input
value={person.artwork.city}
onChange={handleCityChange}
/>
</label>
<label>
Image:
<input
value={person.artwork.image}
onChange={handleImageChange}
/>
</label>
<p>
<i>{person.artwork.title}</i>
{' by '}
{person.name}
<br />
(located in {person.artwork.city})
</p>
<img
src={person.artwork.image}
alt={person.artwork.title}
/>
</>
);
}
{
"dependencies": {
"immer": "1.7.3",
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"use-immer": "0.5.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }
img { width: 200px; height: 200px; }
Notice how much more concise the event handlers have become. You can mix and match useState
and useImmer
in a single component as much as you like. Immer is a great way to keep the update handlers concise, especially if there's nesting in your state, and copying objects leads to repetitive code.
Why is mutating state not recommended in React?
There are a few reasons:
- Debugging: If you use
console.log
and don't mutate state, your past logs won't get clobbered by the more recent state changes. So you can clearly see how state has changed between renders. - Optimizations: Common React optimization strategies rely on skipping work if previous props or state are the same as the next ones. If you never mutate state, it is very fast to check whether there were any changes. If
prevObj === obj
, you can be sure that nothing could have changed inside of it. - New Features: The new React features we're building rely on state being treated like a snapshot. If you're mutating past versions of state, that may prevent you from using the new features.
- Requirement Changes: Some application features, like implementing Undo/Redo, showing a history of changes, or letting the user reset a form to earlier values, are easier to do when nothing is mutated. This is because you can keep past copies of state in memory, and reuse them when appropriate. If you start with a mutative approach, features like this can be difficult to add later on.
- Simpler Implementation: Because React does not rely on mutation, it does not need to do anything special with your objects. It does not need to hijack their properties, always wrap them into Proxies, or do other work at initialization as many "reactive" solutions do. This is also why React lets you put any object into state--no matter how large--without additional performance or correctness pitfalls.
In practice, you can often "get away" with mutating state in React, but we strongly advise you not to do that so that you can use new React features developed with this approach in mind. Future contributors and perhaps even your future self will thank you!
- Treat all state in React as immutable.
- When you store objects in state, mutating them will not trigger renders and will change the state in previous render "snapshots".
- Instead of mutating an object, create a new version of it, and trigger a re-render by setting state to it.
- You can use the
{...obj, something: 'newValue'}
object spread syntax to create copies of objects. - Spread syntax is shallow: it only copies one level deep.
- To update a nested object, you need to create copies all the way up from the place you're updating.
- To reduce repetitive copying code, use Immer.
Fix incorrect state updates
This form has a few bugs. Click the button that increases the score a few times. Notice that it does not increase. Then edit the first name, and notice that the score has suddenly "caught up" with your changes. Finally, edit the last name, and notice that the score has disappeared completely.
Your task is to fix all of these bugs. As you fix them, explain why each of them happens.
import { useState } from 'react';
export default function Scoreboard() {
const [player, setPlayer] = useState({
firstName: 'Ranjani',
lastName: 'Shettar',
score: 10,
});
function handlePlusClick() {
player.score++;
}
function handleFirstNameChange(e) {
setPlayer({
...player,
firstName: e.target.value,
});
}
function handleLastNameChange(e) {
setPlayer({
lastName: e.target.value
});
}
return (
<>
<label>
Score: <b>{player.score}</b>
{' '}
<button onClick={handlePlusClick}>
+1
</button>
</label>
<label>
First name:
<input
value={player.firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:
<input
value={player.lastName}
onChange={handleLastNameChange}
/>
</label>
</>
);
}
label { display: block; margin-bottom: 10px; }
input { margin-left: 5px; margin-bottom: 5px; }
Here is a version with both bugs fixed:
import { useState } from 'react';
export default function Scoreboard() {
const [player, setPlayer] = useState({
firstName: 'Ranjani',
lastName: 'Shettar',
score: 10,
});
function handlePlusClick() {
setPlayer({
...player,
score: player.score + 1,
});
}
function handleFirstNameChange(e) {
setPlayer({
...player,
firstName: e.target.value,
});
}
function handleLastNameChange(e) {
setPlayer({
...player,
lastName: e.target.value
});
}
return (
<>
<label>
Score: <b>{player.score}</b>
{' '}
<button onClick={handlePlusClick}>
+1
</button>
</label>
<label>
First name:
<input
value={player.firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:
<input
value={player.lastName}
onChange={handleLastNameChange}
/>
</label>
</>
);
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }
The problem with handlePlusClick
was that it mutated the player
object. As a result, React did not know that there's a reason to re-render, and did not update the score on the screen. This is why, when you edited the first name, the state got updated, triggering a re-render which also updated the score on the screen.
The problem with handleLastNameChange
was that it did not copy the existing ...player
fields into the new object. This is why the score got lost after you edited the last name.
Find and fix the mutation
There is a draggable box on a static background. You can change the box's color using the select input.
But there is a bug. If you move the box first, and then change its color, the background (which isn't supposed to move!) will "jump" to the box position. But this should not happen: the Background
's position
prop is set to initialPosition
, which is { x: 0, y: 0 }
. Why is the background moving after the color change?
Find the bug and fix it.
If something unexpected changes, there is a mutation. Find the mutation in App.js
and fix it.
import { useState } from 'react';
import Background from './Background.js';
import Box from './Box.js';
const initialPosition = {
x: 0,
y: 0
};
export default function Canvas() {
const [shape, setShape] = useState({
color: 'orange',
position: initialPosition
});
function handleMove(dx, dy) {
shape.position.x += dx;
shape.position.y += dy;
}
function handleColorChange(e) {
setShape({
...shape,
color: e.target.value
});
}
return (
<>
<select
value={shape.color}
onChange={handleColorChange}
>
<option value="orange">orange</option>
<option value="lightpink">lightpink</option>
<option value="aliceblue">aliceblue</option>
</select>
<Background
position={initialPosition}
/>
<Box
color={shape.color}
position={shape.position}
onMove={handleMove}
>
Drag me!
</Box>
</>
);
}
import { useState } from 'react';
export default function Box({
children,
color,
position,
onMove
}) {
const [
lastCoordinates,
setLastCoordinates
] = useState(null);
function handlePointerDown(e) {
e.target.setPointerCapture(e.pointerId);
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
}
function handlePointerMove(e) {
if (lastCoordinates) {
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
const dx = e.clientX - lastCoordinates.x;
const dy = e.clientY - lastCoordinates.y;
onMove(dx, dy);
}
}
function handlePointerUp(e) {
setLastCoordinates(null);
}
return (
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
width: 100,
height: 100,
cursor: 'grab',
backgroundColor: color,
position: 'absolute',
border: '1px solid black',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transform: `translate(
${position.x}px,
${position.y}px
)`,
}}
>{children}</div>
);
}
export default function Background({
position
}) {
return (
<div style={{
position: 'absolute',
transform: `translate(
${position.x}px,
${position.y}px
)`,
width: 250,
height: 250,
backgroundColor: 'rgba(200, 200, 0, 0.2)',
}} />
);
};
body { height: 280px; }
select { margin-bottom: 10px; }
The problem was in the mutation inside handleMove
. It mutated shape.position
, but that's the same object that initialPosition
points at. This is why both the shape and the background move. (It's a mutation, so the change doesn't reflect on the screen until an unrelated update--the color change--triggers a re-render.)
The fix is to remove the mutation from handleMove
, and use the spread syntax to copy the shape. Note that +=
is a mutation, so you need to rewrite it to use a regular +
operation.
import { useState } from 'react';
import Background from './Background.js';
import Box from './Box.js';
const initialPosition = {
x: 0,
y: 0
};
export default function Canvas() {
const [shape, setShape] = useState({
color: 'orange',
position: initialPosition
});
function handleMove(dx, dy) {
setShape({
...shape,
position: {
x: shape.position.x + dx,
y: shape.position.y + dy,
}
});
}
function handleColorChange(e) {
setShape({
...shape,
color: e.target.value
});
}
return (
<>
<select
value={shape.color}
onChange={handleColorChange}
>
<option value="orange">orange</option>
<option value="lightpink">lightpink</option>
<option value="aliceblue">aliceblue</option>
</select>
<Background
position={initialPosition}
/>
<Box
color={shape.color}
position={shape.position}
onMove={handleMove}
>
Drag me!
</Box>
</>
);
}
import { useState } from 'react';
export default function Box({
children,
color,
position,
onMove
}) {
const [
lastCoordinates,
setLastCoordinates
] = useState(null);
function handlePointerDown(e) {
e.target.setPointerCapture(e.pointerId);
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
}
function handlePointerMove(e) {
if (lastCoordinates) {
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
const dx = e.clientX - lastCoordinates.x;
const dy = e.clientY - lastCoordinates.y;
onMove(dx, dy);
}
}
function handlePointerUp(e) {
setLastCoordinates(null);
}
return (
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
width: 100,
height: 100,
cursor: 'grab',
backgroundColor: color,
position: 'absolute',
border: '1px solid black',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transform: `translate(
${position.x}px,
${position.y}px
)`,
}}
>{children}</div>
);
}
export default function Background({
position
}) {
return (
<div style={{
position: 'absolute',
transform: `translate(
${position.x}px,
${position.y}px
)`,
width: 250,
height: 250,
backgroundColor: 'rgba(200, 200, 0, 0.2)',
}} />
);
};
body { height: 280px; }
select { margin-bottom: 10px; }
Update an object with Immer
This is the same buggy example as in the previous challenge. This time, fix the mutation by using Immer. For your convenience, useImmer
is already imported, so you need to change the shape
state variable to use it.
import { useState } from 'react';
import { useImmer } from 'use-immer';
import Background from './Background.js';
import Box from './Box.js';
const initialPosition = {
x: 0,
y: 0
};
export default function Canvas() {
const [shape, setShape] = useState({
color: 'orange',
position: initialPosition
});
function handleMove(dx, dy) {
shape.position.x += dx;
shape.position.y += dy;
}
function handleColorChange(e) {
setShape({
...shape,
color: e.target.value
});
}
return (
<>
<select
value={shape.color}
onChange={handleColorChange}
>
<option value="orange">orange</option>
<option value="lightpink">lightpink</option>
<option value="aliceblue">aliceblue</option>
</select>
<Background
position={initialPosition}
/>
<Box
color={shape.color}
position={shape.position}
onMove={handleMove}
>
Drag me!
</Box>
</>
);
}
import { useState } from 'react';
export default function Box({
children,
color,
position,
onMove
}) {
const [
lastCoordinates,
setLastCoordinates
] = useState(null);
function handlePointerDown(e) {
e.target.setPointerCapture(e.pointerId);
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
}
function handlePointerMove(e) {
if (lastCoordinates) {
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
const dx = e.clientX - lastCoordinates.x;
const dy = e.clientY - lastCoordinates.y;
onMove(dx, dy);
}
}
function handlePointerUp(e) {
setLastCoordinates(null);
}
return (
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
width: 100,
height: 100,
cursor: 'grab',
backgroundColor: color,
position: 'absolute',
border: '1px solid black',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transform: `translate(
${position.x}px,
${position.y}px
)`,
}}
>{children}</div>
);
}
export default function Background({
position
}) {
return (
<div style={{
position: 'absolute',
transform: `translate(
${position.x}px,
${position.y}px
)`,
width: 250,
height: 250,
backgroundColor: 'rgba(200, 200, 0, 0.2)',
}} />
);
};
body { height: 280px; }
select { margin-bottom: 10px; }
{
"dependencies": {
"immer": "1.7.3",
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"use-immer": "0.5.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
This is the solution rewritten with Immer. Notice how the event handlers are written in a mutating fashion, but the bug does not occur. This is because under the hood, Immer never mutates the existing objects.
import { useImmer } from 'use-immer';
import Background from './Background.js';
import Box from './Box.js';
const initialPosition = {
x: 0,
y: 0
};
export default function Canvas() {
const [shape, updateShape] = useImmer({
color: 'orange',
position: initialPosition
});
function handleMove(dx, dy) {
updateShape(draft => {
draft.position.x += dx;
draft.position.y += dy;
});
}
function handleColorChange(e) {
updateShape(draft => {
draft.color = e.target.value;
});
}
return (
<>
<select
value={shape.color}
onChange={handleColorChange}
>
<option value="orange">orange</option>
<option value="lightpink">lightpink</option>
<option value="aliceblue">aliceblue</option>
</select>
<Background
position={initialPosition}
/>
<Box
color={shape.color}
position={shape.position}
onMove={handleMove}
>
Drag me!
</Box>
</>
);
}
import { useState } from 'react';
export default function Box({
children,
color,
position,
onMove
}) {
const [
lastCoordinates,
setLastCoordinates
] = useState(null);
function handlePointerDown(e) {
e.target.setPointerCapture(e.pointerId);
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
}
function handlePointerMove(e) {
if (lastCoordinates) {
setLastCoordinates({
x: e.clientX,
y: e.clientY,
});
const dx = e.clientX - lastCoordinates.x;
const dy = e.clientY - lastCoordinates.y;
onMove(dx, dy);
}
}
function handlePointerUp(e) {
setLastCoordinates(null);
}
return (
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
width: 100,
height: 100,
cursor: 'grab',
backgroundColor: color,
position: 'absolute',
border: '1px solid black',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transform: `translate(
${position.x}px,
${position.y}px
)`,
}}
>{children}</div>
);
}
export default function Background({
position
}) {
return (
<div style={{
position: 'absolute',
transform: `translate(
${position.x}px,
${position.y}px
)`,
width: 250,
height: 250,
backgroundColor: 'rgba(200, 200, 0, 0.2)',
}} />
);
};
body { height: 280px; }
select { margin-bottom: 10px; }
{
"dependencies": {
"immer": "1.7.3",
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"use-immer": "0.5.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}