Updating objects in state
Learn why changing an object in React state doesn't update the page, and how to copy it with spread, update nested objects, and use Immer, in live examples.
- 01Explain why mutation doesn't re-renderSay why changing an object in state shows nothing new: no set call, or the same object, which React's Object.is check skips.
- 02Update an object with a copyMake a new object with spread, change one field of a form's state object, and copy every level of a nested object you change.
- 03Use Immer when copies get deepWrite a change on Immer's draft and get a new object back, and say what Immer copies and what it shares.
An order React doesn't see change
In State: a component's memory, a tea card kept its quantity in state: a number. Real screens often keep an object in state instead, with several fields that belong together, like an order line with an item and a quantity. Here is one. Click Add one a few times.
import { useState } from "react"; export default function App() { const [order, setOrder] = useState({ item: "Green tea", quantity: 1 }); function addOne() { order.quantity = order.quantity + 1; } return ( <> <p>{order.quantity} × {order.item}</p> <button onClick={addOne}>Add one</button> </> );}Line 4 keeps the order in state: an object with item and quantity. Line 12 shows it: 1 × Green tea. addOne on lines 6 to 8 changes the object's quantity on line 7. You can click as often as you like: the page keeps saying 1 × Green tea.
The object really does change. But nothing on line 7 tells React about it: setOrder is never called, so React is never asked to render again. Changing an object that already exists, like this, is called mutation (to mutate means to change). Here is the fix:
import { useState } from "react"; export default function App() { const [order, setOrder] = useState({ item: "Green tea", quantity: 1 }); function addOne() { setOrder({ ...order, quantity: order.quantity + 1 }); } return ( <> <p>{order.quantity} × {order.item}</p> <button onClick={addOne}>Add one</button> </> );}Only line 7 is different. Instead of changing order, it builds a new object with spread: ...order copies every field of the old order into it, and quantity: order.quantity + 1 replaces the copied quantity. Then it hands the new object to setOrder. Click Add one: 2 × Green tea, 3 × Green tea, and so on.
setOrder, and React renders 2 × Green tea.At a bank, the clerk acts on the forms you hand in. If you write a new amount on the form you handed in last week, nothing happens: the clerk isn't looking at it. If you hand back that same form, the clerk sees a form already done and puts it aside. To change something, you fill in a fresh form with the change and hand that one in.
- In real life: The clerk
- In React: React
- In real life: The form you handed in last time
- In React: The object in state
- In real life: Writing on that form after you handed it in
- In React: Changing the object:
order.quantity = 2 - In real life: Handing the clerk the same form again
- In React:
setOrder(order) - In real life: Filling in a fresh form with the change
- In React:
setOrder({ ...order, quantity: 2 })
Treat every object in state as read-only. To change one, make a new object with the change, usually a copy made with spread, and pass the new object to the set function. React renders when it gets a different object, not when an object's fields change.
Why changing the object doesn't update the page
Numbers, strings, and booleans can't be changed at all. setQuantity(5) replaces the 1 in state with a 5, but the number 1 itself stays 1. Objects are different: you can change the fields of an object that already exists, and every name that points at that object sees the change. The Objects and references lesson shows why: a variable holds a reference to an object, like an address, not the object itself.
When you call a set function, React compares the new value with the one it has, using Object.is. For an object, Object.is asks one question: is this the very same object? It doesn't look at the fields. Run this plain JavaScript to see it answer (the Equality and sameness lesson covers Object.is in full):
const profile = { name: "Asha", city: "Delhi" };
const sameProfile = profile;
sameProfile.city = "Pune";
console.log(Object.is(profile, sameProfile));
console.log(profile.city);
const newProfile = { ...profile, city: "Goa" };
console.log(Object.is(profile, newProfile));
console.log(profile.city);Line 2 doesn't copy anything: sameProfile points at the same object as profile. So line 3 changes that one object, line 4 prints true, and line 5 prints Pune. Line 7 makes a new object with spread. Line 8 prints false: it's a different object, even though it was copied from the first. Line 9 prints Pune: making the copy didn't change the old object.
That gives mutation two ways to fail. If you change the object and call no set function, as in the overview, React isn't asked to render. If you change the object and then pass it to the set function, React compares it with what it has, finds the same object, and skips the render. This order tries the second way. Click Add one three times and watch the console under the result, then click Gift wrap.
import { useState } from "react"; export default function App() { const [order, setOrder] = useState({ item: "Green tea", quantity: 1, giftWrap: false }); function addOne() { order.quantity = order.quantity + 1; setOrder(order); console.log("quantity in the object:", order.quantity); } function toggleGiftWrap() { setOrder({ ...order, giftWrap: !order.giftWrap }); } return ( <> <p> {order.quantity} × {order.item} {order.giftWrap && ", gift wrapped"} </p> <button onClick={addOne}>Add one</button> <button onClick={toggleGiftWrap}>Gift wrap</button> </> );}Line 7 adds one to the quantity inside the object React has, and line 8 hands React that same object. Line 9 prints what the object holds. After three clicks, the console says quantity in the object: 2, 3, then 4, while the page still says 1 × Green tea.
Object.is says they're the same, so React skips the render and the page keeps saying 1.Now click Gift wrap. Line 13 makes a new object, so React renders, and the page jumps to 4 × Green tea, gift wrapped. The hidden quantity came along: ...order copied the changed quantity into the new object. This is what makes mutation bugs hard to find. The page is wrong for a while, then suddenly right after an unrelated click, and nothing points at the line that caused it.
Each row is one way to write the click handler, with what React does and what the page shows:
| The click handler does | Does React render? | What the page shows |
|---|---|---|
order.quantity = 2, and no set call | No. Nothing asked React to render. | The old quantity. The object holds the new one. |
order.quantity = 2, then setOrder(order) | No. Object.is finds the same object, so React skips it. | The old quantity, until something else renders the component. |
setOrder({ ...order, quantity: 2 }) | Yes. It's a different object. | The new quantity. The old object is unchanged. |
const next = { ...order };, next.quantity = 2;, setOrder(next) | Yes. next is a new object, changed before anything else has it. | The new quantity. The old object is unchanged. |
React's documentation says that when the new value is the same as the old one, React skips rendering the component and its children, though in some cases it may still need to call your component first. If it does, the page still doesn't change. So a console.log at the top of a component isn't proof that an update reached the page. Read the page.
Copy the object with spread
The usual way to make the new object is the object spread, ..., inside an object literal. It copies every field of an object into the new one, and any field you write after it replaces the copied field. Click Upgrade to Pro, then Move to Pune (no spread):
import { useState } from "react"; export default function App() { const [profile, setProfile] = useState({ name: "Asha", city: "Delhi", plan: "Free" }); function upgrade() { setProfile({ ...profile, plan: "Pro" }); } function moveWithoutSpread() { setProfile({ city: "Pune" }); } return ( <> <p>Name: {profile.name}</p> <p>City: {profile.city}</p> <p>Plan: {profile.plan}</p> <button onClick={upgrade}>Upgrade to Pro</button> <button onClick={moveWithoutSpread}>Move to Pune (no spread)</button> </> );}Line 4 keeps a profile with three fields. upgrade on line 7 copies name, city, and plan, then sets plan to "Pro". Click Upgrade to Pro: the plan changes, and the name and city stay.
...profile copies all three fields, then plan: "Pro" replaces the copied plan. The old object still says "Free".Now click Move to Pune (no spread). moveWithoutSpread on line 11 hands React an object with only a city. A set function replaces the whole value in state; it doesn't merge the new object into the old one. So the page shows Name: and Plan: with nothing after them: profile.name and profile.plan are now undefined, and React shows nothing for undefined. (Older React code with class components used this.setState, which did merge fields. The set function from useState doesn't.)
The order of the fields matters, because they're written from left to right and a later one wins. Run this plain JavaScript:
const profile = { name: "Asha", city: "Delhi", plan: "Free" };
const moved = { ...profile, city: "Pune" };
console.log(moved.city);
const notMoved = { city: "Pune", ...profile };
console.log(notMoved.city);Line 3 copies the profile, then writes city: "Pune", so line 4 prints Pune. Line 6 writes city: "Pune" first, then the spread copies city: "Delhi" over it, so line 7 prints Delhi. Put the spread first and your changes after it.
You don't have to write the new object in one expression. const next = { ...profile }, then next.city = "Pune", then setProfile(next) is fine too: next is an object you just made, and nothing else has it yet. Changing an object you just created is called local mutation, and the Keeping components pure lesson explains why it's safe. The rule is about objects that are already in state.
Try every way side by side. Pick a way to write the click handler, then click Move to Pune in the page. The page is a real React component, and the list under it shows what the handler did to the old object and what it handed React:
import { useState } from "react"; export default function App() { const [profile, setProfile] = useState({ name: "Asha", city: "Delhi" }); function moveToPune() { profile.city = "Pune"; } return ( <> <p>{profile.name} lives in {profile.city}.</p> <button onClick={moveToPune}>Move to Pune</button> </> );}ReactAsha lives in Delhi.
- The old object now holds
{ name: "Asha", city: "Delhi" }- setProfile got
not clicked yet- Object.is(old, new)
not clicked yet
Pick a way to write the click handler, then click Move to Pune in the page.
Change it and Change it, then set it leave the page saying Delhi while the old object says Pune. Spread into a new object and Copy, then change the copy show Pune, and the old object still says Delhi. Spread last makes a new object, so React renders, but the spread copies Delhi over Pune.
One state object for a whole form
A profile form has several text boxes. You could keep each one in its own state variable, or keep them all in one object, with one field per box. With one object, one change handler can update any field. Type in the boxes:
import { useState } from "react"; export default function App() { const [form, setForm] = useState({ name: "Asha", email: "asha@example.com", city: "Delhi" }); function handleChange(event) { setForm({ ...form, [event.target.name]: event.target.value }); } return ( <form> <label> Name <input name="name" value={form.name} onChange={handleChange} /> </label> <label> Email <input name="email" value={form.email} onChange={handleChange} /> </label> <label> City <input name="city" value={form.city} onChange={handleChange} /> </label> <p> {form.name} ({form.email}) lives in {form.city}. </p> </form> );}Line 4 keeps the whole form in one object. Each text box on lines 13, 16, and 19 shows its field with value and calls handleChange on every key you type, as the text box in Your first React app did. Each box also has a name that matches its field: "name", "email", or "city".
Line 7 does all the work. event.target is the box you typed in, so event.target.name is the field to change and event.target.value is what the box now holds. ...form copies every field, and [event.target.name]: event.target.value replaces one of them. The square brackets make a computed property name: JavaScript works out the field's name from the expression inside (the Object basics lesson covers them). Type Ben in Name and Goa in City: the sentence on line 22 says Ben (asha@example.com) lives in Goa.
The styles in the styles.css tab only stack the label above each box. Forms have a lesson of their own, Forms and inputs, with checkboxes, selects, and checking what people type.
Nested objects: copy every level you change
An object in state can hold other objects, like an order with a shipping address inside it. Spread copies one level: it copies each field's value, and when that value is an object, it copies the reference to it, not the object. This order keeps the previous order in a second piece of state, so the page can show "Before" and "Now". Click Ship to Pune:
import { useState } from "react"; export default function App() { const [order, setOrder] = useState({ customer: "Asha", shipping: { city: "Delhi", pin: "110001" }, }); const [previous, setPrevious] = useState(null); function shipToPune() { const next = { ...order }; next.shipping.city = "Pune"; next.shipping.pin = "411001"; setPrevious(order); setOrder(next); } return ( <> <p>Now: {order.customer}'s order ships to {order.shipping.city} {order.shipping.pin}</p> {previous && <p>Before: it shipped to {previous.shipping.city} {previous.shipping.pin}</p>} <button onClick={shipToPune}>Ship to Pune</button> </> );}Lines 4 to 7 keep an order with a shipping object inside it, and line 8 keeps the previous order, starting as null. shipToPune makes next with spread on line 11, so the page does update: React gets a new object on line 15. But lines 12 and 13 change next.shipping, and next.shipping is the very same object as order.shipping. So the old order changed too, and the page says Before: it shipped to Pune 411001. The Delhi address is gone from everywhere.
{ ...order } copies one level: customer, and the arrow in shipping. So line 12 changes the one shipping object both orders point at, and the old order says Pune too."Nested" is a way of speaking: the shipping object isn't really inside the order. The order's shipping field holds a reference to a separate object. The fix is to copy each level on the way to the field you change:
import { useState } from "react"; export default function App() { const [order, setOrder] = useState({ customer: "Asha", shipping: { city: "Delhi", pin: "110001" }, }); const [previous, setPrevious] = useState(null); function shipToPune() { const next = { ...order, shipping: { ...order.shipping, city: "Pune", pin: "411001" }, }; setPrevious(order); setOrder(next); } return ( <> <p>Now: {order.customer}'s order ships to {order.shipping.city} {order.shipping.pin}</p> {previous && <p>Before: it shipped to {previous.shipping.city} {previous.shipping.pin}</p>} <button onClick={shipToPune}>Ship to Pune</button> </> );}Lines 11 to 14 build a new order. Line 12 copies customer and the old shipping reference, and line 13 replaces shipping with a new object: ...order.shipping copies the old city and pin, and the new city and pin replace them. Click Ship to Pune: Before: it shipped to Delhi 110001. The old order kept its own shipping object.
You only copy the objects on the path to your change. customer is a string here, but if it were an object, the new order could share it with the old one: neither of them changes it. JavaScript's structuredClone(order) copies every level at once, which also works, but it copies the parts you didn't change too, so nothing is shared. The Immutability lesson compares these ways of copying.
Immer: write a change, get a copy
Copying every level by hand gets long when objects nest three or four levels deep. Immer is a small library that writes the copies for you. You call its produce function with the current object and a function, called the recipe. The recipe gets a draft: a stand-in for your object that you change with plain assignments, like draft.shipping.city = "Pune". The draft records each change, and when the recipe ends, produce builds a new object with those changes. The original object is never changed.
The live examples on this page load packages from the esm.sh service, so this one runs as it would in a project that ran npm install immer. Click Ship to Pune, look at the console, then click it again:
import { useState } from "react";import { produce } from "immer"; export default function App() { const [order, setOrder] = useState({ customer: { name: "Asha" }, shipping: { city: "Delhi", pin: "110001" }, }); function shipToPune() { const next = produce(order, (draft) => { draft.shipping.city = "Pune"; draft.shipping.pin = "411001"; }); console.log("Same order?", Object.is(order, next)); console.log("Same shipping?", Object.is(order.shipping, next.shipping)); console.log("Same customer?", Object.is(order.customer, next.customer)); setOrder(next); } return ( <> <p> {order.customer.name}'s order ships to {order.shipping.city} {order.shipping.pin} </p> <button onClick={shipToPune}>Ship to Pune</button> </> );}Line 2 imports produce. Lines 11 to 14 call it with order and a recipe that changes the draft's shipping city and pin. Lines 15 to 17 compare the old and new objects. The first click prints Same order? false, Same shipping? false, and Same customer? true: Immer made a new order and a new shipping object, the path to your change, and shared the customer object you didn't touch. That's the same copy you wrote by hand above. Line 18 hands React the new order, and the page says Pune.
The second click sets the city and pin to the values they already have, so nothing changes, and produce gives back the same object: all three lines print true, and React skips the render. Immer also freezes the objects it returns, with Object.freeze, so a later mutation of that state fails instead of quietly changing it.
How does the draft record changes? It's a Proxy: an object that runs Immer's code each time you read or write one of its fields. You don't need to know that to use it.
Each row is one job, done by hand with spread and with Immer:
| Job | By hand, with spread | With Immer's produce |
|---|---|---|
| Change one field | { ...order, quantity: 2 } | produce(order, (draft) => { draft.quantity = 2; }) |
| Change a nested field | { ...order, shipping: { ...order.shipping, city: "Pune" } } | produce(order, (draft) => { draft.shipping.city = "Pune"; }) |
| What you get back | A new object that you built yourself. | A new object that Immer built from your changes. Parts you didn't change are the same objects as before. |
| What happens to the old object | Nothing. It's untouched. | Nothing. It's untouched. |
Immer has a companion package, use-immer, with a useImmer hook. It works like useState, except that its update function takes a recipe. In a project, after npm install immer use-immer, you would write:
import { useImmer } from "use-immer";
export default function App() {
const [order, updateOrder] = useImmer({
customer: { name: "Asha" },
shipping: { city: "Delhi", pin: "110001" },
});
function shipToPune() {
updateOrder((draft) => {
draft.shipping.city = "Pune";
draft.shipping.pin = "411001";
});
}
return (
<>
<p>
{order.customer.name}'s order ships to {order.shipping.city} {order.shipping.pin}
</p>
<button onClick={shipToPune}>Ship to Pune</button>
</>
);
}This block has no live result. The copy of use-immer that esm.sh serves loads its own, second copy of React, and a hook from one copy of React can't run inside a component rendered by the other: the page stops with Cannot read properties of null (reading 'useState'). A project that installs the package with npm has only one React, and doesn't have this problem.
The hook itself is small, so here is the same component with a useImmer written for this lesson, in the useImmer.js tab. Click Ship to Pune:
import { useImmer } from "./useImmer.js"; export default function App() { const [order, updateOrder] = useImmer({ customer: { name: "Asha" }, shipping: { city: "Delhi", pin: "110001" }, }); function shipToPune() { updateOrder((draft) => { draft.shipping.city = "Pune"; draft.shipping.pin = "411001"; }); } return ( <> <p> {order.customer.name}'s order ships to {order.shipping.city} {order.shipping.pin} </p> <button onClick={shipToPune}>Ship to Pune</button> </> );}In App.jsx, line 1 imports the hook from the second file, and line 4 uses it like useState. updateOrder on lines 10 to 13 takes a recipe. In useImmer.js, line 5 keeps the value with useState, and line 8 passes React an updater function that runs produce on the current value. The real package does a little more, such as freezing the first value and accepting a plain value as well as a recipe.
Immer is optional. Spread copies work everywhere and need no package; Immer pays off when updates go several levels deep. Either way, the rule is the same: React gets a new object, and the old one stays as it was.
Undo on a settings page
Never changing old objects pays off in features like Undo. When each change makes a new settings object, every old one is still there, exactly as it was, so you can keep a list of them and go back. Click a few buttons, then Undo:
import { useState } from "react"; export default function App() { const [settings, setSettings] = useState({ theme: "Light", textSize: 16, emails: true }); const [history, setHistory] = useState([]); function change(next) { setHistory([...history, settings]); setSettings(next); } function undo() { setSettings(history.at(-1)); setHistory(history.slice(0, -1)); } return ( <> <p> Theme: {settings.theme}. Text size: {settings.textSize}px. Emails: {settings.emails ? "on" : "off"}. </p> <button onClick={() => change({ ...settings, theme: settings.theme === "Light" ? "Dark" : "Light" })}> Switch theme </button> <button onClick={() => change({ ...settings, textSize: settings.textSize + 2 })}>Bigger text</button> <button onClick={() => change({ ...settings, emails: !settings.emails })}>Emails on or off</button> <button onClick={undo} disabled={history.length === 0}> Undo </button> <p>Saved versions: {history.length}</p> </> );}Line 4 keeps the settings object, and line 5 keeps a list of earlier ones, starting empty. Every button calls change with a new object made by spread, on lines 22, 25, and 26. change adds the current settings to the end of the list on line 8, then sets the new ones on line 9. undo sets the last saved settings back on line 13, and line 14 removes it from the list. (Arrays in state follow the same rule as objects: lines 8 and 14 make new arrays instead of changing the old one. The next lesson covers arrays.)
Click Switch theme and Bigger text: Theme: Dark. Text size: 18px. with two saved versions. Click Undo twice to get back to Theme: Light. Text size: 16px. This only works because no button ever changed an old settings object. If one did, the list would hold the changed object, and Undo would put back the wrong settings.
setOrder({ ...order, quantity: 2 });order.quantity = 2; setOrder(order);const next = { ...order }; next.quantity = 2; setOrder(next);const next = { ...order }; next.shipping.city = "Pune"; setOrder(next);setOrder({ ...order, shipping: { ...order.shipping, city: "Pune" } });Object.assign(order, { quantity: 2 }); setOrder(order);setOrder(Object.assign({}, order, { quantity: 2 }));delete order.giftWrap; setOrder({ ...order });
Sort each click handler body: does it give React a new object and leave the old one alone, or does it change the object in state?
Common mix-ups
- "Changing a field of a state object updates the page." No. The object changes, but the page only changes when React renders, and only a set function with a new value asks it to.
- "Calling the set function after a change makes it work." No.
setOrder(order)hands React the same object.Object.isfinds no difference, so React skips the render. - "A set function merges the new fields into the old object." No. It replaces the whole value. Leave out
...profileand the other fields are gone. - "Spread copies the whole object, nested objects too." No. It copies one level. A nested object is shared by the copy and the original until you copy it too.
- "You may never change any object in a component." No. Changing an object you just made, before anything else has it, is local mutation, and it's fine. The rule is for objects already in state.
- "Immer changes your state in place." No. Your recipe changes a draft. Immer builds a new object from it and leaves the original alone.
React's lint rules can catch the first two. The eslint-plugin-react-hooks package has a rule, react-hooks/immutability, that marks lines like order.quantity = 2 on a value from useState.
Practice exercises
Each exercise shows its code's result under it, or when you press Edit & run. Change the code, and the result updates as you type. Try a guess first, then use the hints.
What does the page show after you click Like three times? Answer first, then press Edit & run and click to check.
import { useState } from "react";
export default function App() {
const [post, setPost] = useState({ title: "Masala chai recipe", likes: 0 });
function like() {
post.likes = post.likes + 1;
}
return (
<>
<h3>{post.title}</h3>
<p>Likes: {post.likes}</p>
<button onClick={like}>Like</button>
</>
);
}import { useState } from "react"; export default function App() { const [post, setPost] = useState({ title: "Masala chai recipe", likes: 0 }); function like() { post.likes = post.likes + 1; } return ( <> <h3>{post.title}</h3> <p>Likes: {post.likes}</p> <button onClick={like}>Like</button> </> );}Line 7 changes the object in state, and nothing calls setPost. React is never asked to render, so the page keeps saying Likes: 0, however many times you click. The object itself holds 3.
What does this code print?
const order = { item: "Green tea", quantity: 1 };
const next = { quantity: 5, ...order };
console.log(next.quantity);const order = { item: "Green tea", quantity: 1 };
const next = { ...order, quantity: 5 };
console.log(next.quantity);The spread comes last, so it copies the old quantity: 1 over the 5, and line 3 prints 1. To set the quantity to 5, put the spread first: { ...order, quantity: 5 }, which prints 5.
Change like so the count goes up on the page. What does it show after three clicks?
import { useState } from "react"; export default function App() { const [post, setPost] = useState({ title: "Masala chai recipe", likes: 0 }); function like() { post.likes = post.likes + 1; } return ( <> <h3>{post.title}</h3> <p>Likes: {post.likes}</p> <button onClick={like}>Like</button> </> );}import { useState } from "react"; export default function App() { const [post, setPost] = useState({ title: "Masala chai recipe", likes: 0 }); function like() { setPost({ ...post, likes: post.likes + 1 }); } return ( <> <h3>{post.title}</h3> <p>Likes: {post.likes}</p> <button onClick={like}>Like</button> </> );}Line 7 now hands React a new object with every field copied and likes one higher. Each click is a new object, so React renders: Likes: 1, 2, 3. The title stays because the spread copies it.
Type in the Name box: the city disappears. Fix handleChange. Then select the name, type Ben, and say what the sentence shows.
import { useState } from "react"; export default function App() { const [form, setForm] = useState({ name: "Asha", city: "Delhi" }); function handleChange(event) { setForm({ [event.target.name]: event.target.value }); } return ( <form> <label> Name <input name="name" value={form.name} onChange={handleChange} /> </label> <label> City <input name="city" value={form.city} onChange={handleChange} /> </label> <p> {form.name} lives in {form.city}. </p> </form> );}import { useState } from "react"; export default function App() { const [form, setForm] = useState({ name: "Asha", city: "Delhi" }); function handleChange(event) { setForm({ ...form, [event.target.name]: event.target.value }); } return ( <form> <label> Name <input name="name" value={form.name} onChange={handleChange} /> </label> <label> City <input name="city" value={form.city} onChange={handleChange} /> </label> <p> {form.name} lives in {form.city}. </p> </form> );}Without the spread, each key you type replaces the whole form with an object of one field, so city becomes undefined. React also warns in the console that the City box changed from controlled to uncontrolled: its value went from text to undefined. With ...form, the city is copied, and typing Ben gives Ben lives in Delhi.
Make Fix the pin change the shipping pin to "110002" without changing the old order. After one click, what does the Before line show?
import { useState } from "react"; export default function App() { const [order, setOrder] = useState({ customer: "Asha", shipping: { city: "Delhi", pin: "110001" }, }); const [previous, setPrevious] = useState(null); function fixPin() { setPrevious(order); // Give React a new order whose shipping pin is "110002". } return ( <> <p>Now: {order.shipping.city} {order.shipping.pin}</p> {previous && <p>Before: {previous.shipping.city} {previous.shipping.pin}</p>} <button onClick={fixPin}>Fix the pin</button> </> );}import { useState } from "react"; export default function App() { const [order, setOrder] = useState({ customer: "Asha", shipping: { city: "Delhi", pin: "110001" }, }); const [previous, setPrevious] = useState(null); function fixPin() { setPrevious(order); setOrder({ ...order, shipping: { ...order.shipping, pin: "110002" }, }); } return ( <> <p>Now: {order.shipping.city} {order.shipping.pin}</p> {previous && <p>Before: {previous.shipping.city} {previous.shipping.pin}</p>} <button onClick={fixPin}>Fix the pin</button> </> );}Lines 12 to 15 give React a new order whose shipping is a new object: the city is copied and the pin replaced. The old order keeps its own shipping object, so the page shows Now: Delhi 110002 and Before: Delhi 110001.
Write moveToPune so the user's city becomes Pune, by hand with spread. Then try it with Immer's produce. How many spreads does the version by hand need?
import { useState } from "react"; export default function App() { const [account, setAccount] = useState({ plan: "Free", user: { name: "Asha", address: { city: "Delhi", pin: "110001" }, }, }); function moveToPune() { // Give React a new account whose user.address.city is "Pune". } return ( <> <p> {account.user.name} ({account.plan}) lives in {account.user.address.city}. </p> <button onClick={moveToPune}>Move to Pune</button> </> );}import { useState } from "react"; export default function App() { const [account, setAccount] = useState({ plan: "Free", user: { name: "Asha", address: { city: "Delhi", pin: "110001" }, }, }); function moveToPune() { setAccount({ ...account, user: { ...account.user, address: { ...account.user.address, city: "Pune" }, }, }); } return ( <> <p> {account.user.name} ({account.plan}) lives in {account.user.address.city}. </p> <button onClick={moveToPune}>Move to Pune</button> </> );}Three objects lie on the path to the city, so the version by hand needs three spreads: ...account, ...account.user, and ...account.user.address. The page then says Asha (Free) lives in Pune. With Immer it's one line in a recipe, draft.user.address.city = "Pune", as in the second version below.
import { useState } from "react";import { produce } from "immer"; export default function App() { const [account, setAccount] = useState({ plan: "Free", user: { name: "Asha", address: { city: "Delhi", pin: "110001" }, }, }); function moveToPune() { setAccount( produce(account, (draft) => { draft.user.address.city = "Pune"; }), ); } return ( <> <p> {account.user.name} ({account.plan}) lives in {account.user.address.city}. </p> <button onClick={moveToPune}>Move to Pune</button> </> );}Line 2 imports produce, and lines 14 to 18 pass React the object it builds. Click Move to Pune: the same Asha (Free) lives in Pune. as the version by hand.
Check your understanding
For each question, ask two things: is there a new object, and was the old one changed? Press Edit & run on a question's code to check your answer.
Question 1 of 7A click handler runs
order.quantity = 2and nothing else. Why does the page keep its old number?Choose an answer to see the explanation.
Question 2 of 7After one click on Add one, what does this order show?
Read the code, then predictimport { useState } from "react"; export default function App() { const [order, setOrder] = useState({ item: "Green tea", quantity: 1 }); function addOne() { order.quantity = order.quantity + 1; setOrder(order); } return ( <> <p>{order.quantity} × {order.item}</p> <button onClick={addOne}>Add one</button> </> ); }Choose an answer to see the explanation.
Question 3 of 7How does React decide whether the value you pass to a set function is new?
Choose an answer to see the explanation.
Question 4 of 7What does this spread print?
Read the code, then predictconst profile = { name: "Asha", plan: "Free" }; const next = { plan: "Pro", ...profile }; console.log(next.plan);Choose an answer to see the explanation.
Question 5 of 7After one click on Move, what does the Name line show?
Read the code, then predictimport { useState } from "react"; export default function App() { const [profile, setProfile] = useState({ name: "Asha", city: "Delhi" }); return ( <> <p>Name: {profile.name}</p> <p>City: {profile.city}</p> <button onClick={() => setProfile({ city: "Pune" })}>Move</button> </> ); }Choose an answer to see the explanation.
Question 6 of 7What does this one-level copy print?
Read the code, then predictconst order = { customer: "Asha", shipping: { city: "Delhi" } }; const next = { ...order }; next.shipping.city = "Pune"; console.log(order.shipping.city);Choose an answer to see the explanation.
Question 7 of 7What does
produce(order, (draft) => { draft.shipping.city = "Pune"; })give back?Choose an answer to see the explanation.
Key takeaways
- Changing a field of an object in state is mutation. It doesn't ask React to render, so the page keeps showing the old value.
- React compares the new state with the old one using
Object.is. The same object, even with changed fields, means no render; a new object means a render. - Make a new object with spread,
{ ...order, quantity: 2 }, and put the spread first. A set function replaces the whole object; it doesn't merge. - One state object can hold a whole form;
[event.target.name]picks the field to change. - Spread copies one level. Copy every object on the path to the field you change, or nested objects stay shared.
- Immer's
produceturns changes on a draft into a new object, copies only what changed, and leaves the original alone.
Remember the one-liner.
Don't change the object in state: hand React a new one.
Coming next: Updating arrays in state. It applies the same rule to lists: adding, removing, replacing, inserting, and sorting items without changing the array in state.