Updating Objects in State

김동현·2026년 3월 15일

title: State에서 객체 업데이트하기

State는 객체를 포함한 모든 종류의 JavaScript 값을 담을 수 있어요. 하지만 React state에 있는 객체를 직접 변경해서는 안 돼요. 대신에, 객체를 업데이트하고 싶을 때는 새로운 객체를 생성하거나 (또는 기존 객체의 복사본을 만들어서), state가 그 복사본을 사용하도록 설정해야 해요.

  • React state에서 객체를 올바르게 업데이트하는 방법
  • 중첩된 객체를 변경(mutation)하지 않고 업데이트하는 방법
  • 불변성(immutability)이 무엇인지, 그리고 어떻게 이를 지켜야 하는지
  • Immer로 객체 복사 코드를 덜 반복적으로 만드는 방법

변경(mutation)이란 무엇인가요? {/whats-a-mutation/}

State에는 어떤 종류의 JavaScript 값이든 저장할 수 있어요.

const [x, setX] = useState(0);

지금까지 여러분은 숫자, 문자열, 그리고 불리언(boolean) 값을 다뤄왔어요. 이런 종류의 JavaScript 값들은 "불변(immutable)"이에요. 즉, 변경할 수 없거나 "읽기 전용"이라는 뜻이죠. 값을 교체하기 위해 리렌더링을 트리거할 수 있어요:

setX(5);

x state가 0에서 5로 바뀌었지만, 숫자 0 자체는 바뀌지 않았어요. JavaScript에서 숫자, 문자열, 불리언 같은 내장된 원시 값들은 변경할 수 없어요.

이제 state에 있는 객체를 생각해봅시다:

const [position, setPosition] = useState({ x: 0, y: 0 });

기술적으로, 객체 자체의 내용을 변경하는 것은 가능해요. 이것을 변경(mutation)이라고 부릅니다:

position.x = 5;

하지만, React state에 있는 객체들이 기술적으로는 변경 가능(mutable)하더라도, 여러분은 그것들을 숫자, 불리언, 문자열처럼 불변인 것처럼 취급해야 해요. 변경하는 대신에, 항상 교체해야 합니다.

State를 읽기 전용으로 취급하세요 {/treat-state-as-read-only/}

다시 말해서, state에 넣는 모든 JavaScript 객체를 읽기 전용으로 취급해야 해요.

이 예제는 현재 포인터 위치를 나타내는 객체를 state에 담고 있어요. 빨간 점은 미리보기 영역 위에 터치하거나 커서를 움직일 때 따라 움직여야 해요. 하지만 점은 초기 위치에 머물러 있어요:

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; }

문제는 이 코드 부분에 있어요.

onPointerMove={e => {
  position.x = e.clientX;
  position.y = e.clientY;
}}

이 코드는 이전 렌더링에서 position에 할당된 객체를 수정하고 있어요. 하지만 state 설정 함수를 사용하지 않으면, React는 객체가 변경되었다는 것을 알 수 없어요. 그래서 React는 아무 반응도 하지 않죠. 이미 식사를 마친 후에 주문을 바꾸려고 하는 것과 같아요. state를 변경하는 것이 어떤 경우에는 작동할 수 있지만, 권장하지 않아요. 렌더링에서 접근할 수 있는 state 값을 읽기 전용으로 취급해야 해요.

이 경우에 실제로 리렌더링을 트리거하려면, 새로운 객체를 생성하고 그것을 state 설정 함수에 전달하세요:

onPointerMove={e => {
  setPosition({
    x: e.clientX,
    y: e.clientY
  });
}}

setPosition을 사용하면, React에게 다음을 알려주는 거예요:

  • position을 이 새로운 객체로 교체하세요
  • 그리고 이 컴포넌트를 다시 렌더링하세요

미리보기 영역을 터치하거나 마우스를 올리면 이제 빨간 점이 포인터를 따라오는 것을 볼 수 있어요:

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)은 괜찮아요 {/local-mutation-is-fine/}

이런 코드는 문제가 돼요. state에 있는 기존 객체를 수정하기 때문이죠:

position.x = e.clientX;
position.y = e.clientY;

하지만 이런 코드는 완전히 괜찮아요. 방금 생성한 새로운 객체를 변경하는 것이기 때문이에요:

const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);

사실, 이것은 다음과 같이 쓰는 것과 완전히 동일해요:

setPosition({
  x: e.clientX,
  y: e.clientY
});

변경이 문제가 되는 것은 state에 이미 있는 기존 객체를 변경할 때뿐이에요. 방금 생성한 객체를 변경하는 것은 괜찮아요. 왜냐하면 아직 다른 코드가 그것을 참조하지 않기 때문이에요. 그것을 변경해도 그것에 의존하는 무언가에 실수로 영향을 주지 않아요. 이것을 "지역 변경(local mutation)"이라고 불러요. 렌더링 중에도 지역 변경을 할 수 있어요. 매우 편리하고 완전히 괜찮아요!

전개 문법으로 객체 복사하기 {/copying-objects-with-the-spread-syntax/}

이전 예제에서, position 객체는 항상 현재 커서 위치로부터 새롭게 생성되었어요. 하지만 종종, 여러분이 생성하는 새로운 객체의 일부로 기존 데이터를 포함하고 싶을 거예요. 예를 들어, 폼에서 하나의 필드만 업데이트하고 다른 모든 필드에 대해서는 이전 값을 유지하고 싶을 수 있어요.

이 입력 필드들은 작동하지 않아요. onChange 핸들러들이 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; }

예를 들어, 이 라인은 과거 렌더링의 state를 변경하고 있어요:

person.firstName = e.target.value;

여러분이 원하는 동작을 얻는 확실한 방법은 새로운 객체를 생성하고 setPerson에 전달하는 것이에요. 하지만 여기서는, 하나의 필드만 변경되었기 때문에 기존 데이터도 그 안에 복사하고 싶어요:

setPerson({
  firstName: e.target.value, // 입력에서 얻은 새로운 first name
  lastName: person.lastName,
  email: person.email
});

... 객체 전개(spread) 문법을 사용하면 모든 프로퍼티를 개별적으로 복사할 필요가 없어요.

setPerson({
  ...person, // 이전 필드들을 복사
  firstName: e.target.value // 하지만 이것은 덮어쓰기
});

이제 폼이 작동해요!

각 입력 필드에 대해 별도의 state 변수를 선언하지 않은 것을 주목하세요. 큰 폼의 경우, 모든 데이터를 객체에 그룹화해서 유지하는 것이 매우 편리해요--올바르게 업데이트하기만 한다면요!

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; }

... 전개 문법은 "얕다(shallow)"는 점을 주의하세요--한 레벨 깊이만 복사해요. 이것은 빠르지만, 중첩된 프로퍼티를 업데이트하고 싶다면 여러 번 사용해야 한다는 뜻이에요.

여러 필드에 하나의 이벤트 핸들러 사용하기 {/using-a-single-event-handler-for-multiple-fields/}

객체 정의 내에서 [] 중괄호를 사용하여 동적인 이름을 가진 프로퍼티를 지정할 수도 있어요. 여기 같은 예제인데, 세 개의 서로 다른 이벤트 핸들러 대신 하나의 이벤트 핸들러를 사용하고 있어요:

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; }

여기서 e.target.name<input> DOM 요소에 주어진 name 프로퍼티를 참조해요.

중첩된 객체 업데이트하기 {/updating-a-nested-object/}

다음과 같은 중첩된 객체 구조를 생각해보세요:

const [person, setPerson] = useState({
  name: 'Niki de Saint Phalle',
  artwork: {
    title: 'Blue Nana',
    city: 'Hamburg',
    image: 'https://i.imgur.com/Sd1AgUOm.jpg',
  }
});

person.artwork.city를 업데이트하고 싶다면, 변경으로 하는 방법은 명확해요:

person.artwork.city = 'New Delhi';

하지만 React에서는, state를 불변으로 취급해야 해요! city를 변경하기 위해서는, 먼저 새로운 artwork 객체를 (이전 것의 데이터로 미리 채워서) 생성하고, 그런 다음 새로운 artwork을 가리키는 새로운 person 객체를 생성해야 해요:

const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);

또는, 단일 함수 호출로 작성하면:

setPerson({
  ...person, // 다른 필드들 복사
  artwork: { // 하지만 artwork는 교체
    ...person.artwork, // 같은 것으로
    city: 'New Delhi' // 하지만 New Delhi에!
  }
});

이것은 좀 장황해지지만, 많은 경우에 잘 작동해요:

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/}

코드에서 이런 객체는 "중첩된" 것처럼 보여요:

let obj = {
  name: 'Niki de Saint Phalle',
  artwork: {
    title: 'Blue Nana',
    city: 'Hamburg',
    image: 'https://i.imgur.com/Sd1AgUOm.jpg',
  }
};

하지만, "중첩"은 객체가 어떻게 동작하는지 생각하는 부정확한 방법이에요. 코드가 실행될 때, "중첩된" 객체 같은 것은 없어요. 실제로는 두 개의 서로 다른 객체를 보고 있는 거예요:

let obj1 = {
  title: 'Blue Nana',
  city: 'Hamburg',
  image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

let obj2 = {
  name: 'Niki de Saint Phalle',
  artwork: obj1
};

obj1 객체는 obj2 "안에" 있지 않아요. 예를 들어, obj3obj1을 "가리킬" 수 있어요:

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
};

만약 obj3.artwork.city를 변경한다면, obj2.artwork.cityobj1.city 모두에 영향을 줄 거예요. 왜냐하면 obj3.artwork, obj2.artwork, 그리고 obj1은 같은 객체이기 때문이에요. 이것은 객체를 "중첩된" 것으로 생각할 때 이해하기 어려워요. 대신에, 그것들은 프로퍼티로 서로를 "가리키는" 별개의 객체들이에요.

Immer로 간결한 업데이트 로직 작성하기 {/write-concise-update-logic-with-immer/}

만약 여러분의 state가 깊게 중첩되어 있다면, 평평하게 만드는 것을 고려할 수 있어요. 하지만, state 구조를 변경하고 싶지 않다면, 중첩된 전개의 단축키를 선호할 수 있어요. Immer는 편리하지만 변경하는 문법을 사용하여 작성할 수 있게 해주고 복사본 생성을 대신 처리해주는 인기 있는 라이브러리예요. Immer를 사용하면, 작성하는 코드가 "규칙을 어기고" 객체를 변경하는 것처럼 보여요:

updatePerson(draft => {
  draft.artwork.city = 'Lagos';
});

하지만 일반적인 변경과 달리, 과거 state를 덮어쓰지 않아요!

Immer는 어떻게 작동하나요? {/how-does-immer-work/}

Immer가 제공하는 draftProxy라고 불리는 특별한 종류의 객체로, 여러분이 무엇을 하는지 "기록"해요. 그래서 원하는 만큼 자유롭게 변경할 수 있어요! 내부적으로, Immer는 draft의 어떤 부분이 변경되었는지 파악하고, 편집 내용을 포함하는 완전히 새로운 객체를 생성해요.

Immer를 사용해보려면:

  1. npm install use-immer를 실행하여 Immer를 의존성으로 추가하세요
  2. 그런 다음 import { useState } from 'react'import { useImmer } from 'use-immer'로 바꾸세요

다음은 위의 예제를 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; }

이벤트 핸들러들이 얼마나 더 간결해졌는지 주목하세요. 단일 컴포넌트에서 useStateuseImmer를 원하는 만큼 섞어서 사용할 수 있어요. Immer는 업데이트 핸들러를 간결하게 유지하는 좋은 방법이에요, 특히 state에 중첩이 있고 객체를 복사하는 것이 반복적인 코드로 이어질 때 말이에요.

몇 가지 이유가 있어요:

  • 디버깅: console.log를 사용하고 state를 변경하지 않으면, 과거 로그가 최근 state 변경에 의해 지워지지 않아요. 따라서 렌더링 사이에 state가 어떻게 변경되었는지 명확하게 볼 수 있어요.
  • 최적화: 일반적인 React 최적화 전략은 이전 props나 state가 다음 것과 같으면 작업을 건너뛰는 것에 의존해요. state를 절대 변경하지 않으면, 변경 사항이 있었는지 확인하는 것이 매우 빨라요. prevObj === obj라면, 그 안에서 아무것도 변경되지 않았다고 확신할 수 있어요.
  • 새로운 기능: 우리가 만들고 있는 새로운 React 기능들은 state가 스냅샷처럼 취급되는 것에 의존해요. 만약 과거 버전의 state를 변경하고 있다면, 새로운 기능을 사용하지 못하게 될 수 있어요.
  • 요구사항 변경: 실행 취소/다시 실행 구현하기, 변경 사항의 히스토리 보여주기, 또는 사용자가 폼을 이전 값으로 재설정할 수 있게 하기 같은 일부 애플리케이션 기능들은 아무것도 변경되지 않을 때 더 쉬워요. 왜냐하면 메모리에 state의 과거 복사본을 보관하고 적절할 때 재사용할 수 있기 때문이에요. 변경하는 접근 방식으로 시작하면, 이런 기능들은 나중에 추가하기 어려울 수 있어요.
  • 더 간단한 구현: React는 변경에 의존하지 않기 때문에, 객체로 특별한 작업을 할 필요가 없어요. 많은 "반응형" 솔루션들이 하는 것처럼 프로퍼티를 가로채거나, 항상 Proxy로 감싸거나, 초기화 시 다른 작업을 할 필요가 없어요. 이것이 또한 React가 아무리 큰 객체라도 추가적인 성능이나 정확성의 함정 없이 state에 넣을 수 있게 하는 이유예요.

실제로, React에서 state를 변경하는 것으로 종종 "잘 넘어갈" 수 있지만, 이 접근 방식을 염두에 두고 개발된 새로운 React 기능을 사용할 수 있도록 그렇게 하지 않기를 강력히 권장해요. 미래의 기여자들 그리고 어쩌면 미래의 여러분 자신도 고마워할 거예요!

  • React의 모든 state를 불변으로 취급하세요.
  • state에 객체를 저장할 때, 그것들을 변경하는 것은 렌더링을 트리거하지 않고 이전 렌더링 "스냅샷"의 state를 변경할 거예요.
  • 객체를 변경하는 대신, 그것의 새로운 버전을 생성하고, 그것으로 state를 설정하여 리렌더링을 트리거하세요.
  • {...obj, something: 'newValue'} 객체 전개 문법을 사용하여 객체의 복사본을 생성할 수 있어요.
  • 전개 문법은 얕아요: 한 레벨 깊이만 복사해요.
  • 중첩된 객체를 업데이트하려면, 업데이트하는 곳에서부터 위쪽까지 모든 경로의 복사본을 만들어야 해요.
  • 반복적인 복사 코드를 줄이려면, Immer를 사용하세요.

잘못된 state 업데이트 수정하기 {/fix-incorrect-state-updates/}

이 폼에는 몇 가지 버그가 있어요. 점수를 증가시키는 버튼을 몇 번 클릭해보세요. 증가하지 않는 것을 볼 수 있어요. 그런 다음 이름을 편집하면, 점수가 갑자기 변경 사항을 "따라잡는" 것을 볼 수 있어요. 마지막으로, 성을 편집하면 점수가 완전히 사라지는 것을 볼 수 있어요.

여러분의 과제는 이 모든 버그를 수정하는 것이에요. 수정하면서, 각각이 왜 발생하는지 설명하세요.

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; }

다음은 두 버그가 모두 수정된 버전이에요:

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; }

handlePlusClick의 문제는 player 객체를 변경했다는 거예요. 결과적으로, React는 리렌더링할 이유가 있다는 것을 알지 못했고, 화면의 점수를 업데이트하지 않았어요. 이것이 이름을 편집했을 때, state가 업데이트되어 리렌더링을 트리거했고 그것이 또한 화면의 점수를 업데이트한 이유예요.

handleLastNameChange의 문제는 기존 ...player 필드들을 새로운 객체로 복사하지 않았다는 거예요. 이것이 성을 편집한 후 점수가 사라진 이유예요.

변경 찾아서 수정하기 {/find-and-fix-the-mutation/}

정적 배경 위에 드래그 가능한 상자가 있어요. 선택 입력을 사용하여 상자의 색상을 변경할 수 있어요.

하지만 버그가 있어요. 상자를 먼저 움직인 다음 색상을 변경하면, 배경이 (움직이면 안 되는데!) 상자 위치로 "점프"할 거예요. 하지만 이것은 일어나면 안 돼요: Backgroundposition prop은 initialPosition으로 설정되어 있고, 이것은 { x: 0, y: 0 }이에요. 색상 변경 후에 왜 배경이 움직이나요?

버그를 찾아서 수정하세요.

예상치 못한 무언가가 변경되면, 변경이 있는 거예요. App.js에서 변경을 찾아서 수정하세요.

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; }

문제는 handleMove 안의 변경이었어요. 이것이 shape.position을 변경했지만, 그것은 initialPosition이 가리키는 것과 같은 객체예요. 이것이 도형과 배경이 모두 움직이는 이유예요. (변경이기 때문에, 관련 없는 업데이트--색상 변경--가 리렌더링을 트리거할 때까지 화면에 반영되지 않아요.)

수정은 handleMove에서 변경을 제거하고, 전개 문법을 사용하여 도형을 복사하는 거예요. +=는 변경이므로, 일반 + 연산을 사용하도록 다시 작성해야 해요.

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; }

Immer로 객체 업데이트하기 {/update-an-object-with-immer/}

이것은 이전 챌린지와 같은 버그가 있는 예제예요. 이번에는, Immer를 사용하여 변경을 수정하세요. 편의를 위해, useImmer가 이미 임포트되어 있으니, shape state 변수를 그것을 사용하도록 변경해야 해요.

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"
  }
}

이것은 Immer로 다시 작성된 솔루션이에요. 이벤트 핸들러들이 변경하는 방식으로 작성되었지만 버그가 발생하지 않는 것을 주목하세요. 이것은 내부적으로, Immer가 결코 기존 객체를 변경하지 않기 때문이에요.

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"
  }
}

사이트맵

모든 문서 페이지 개요

profile
프론트에_가까운_풀스택_개발자

0개의 댓글