React 17. Updating Objects in State

뚜루미·2024년 3월 23일

React

목록 보기
17/39
post-thumbnail

상태는 객체를 포함한 모든 종류의 JavaScript 값을 보유할 수 있습니다. 하지만 React 상태에 있는 객체를 직접 변경해서는 안 됩니다. 대신, 객체를 업데이트하려면 새 객체를 생성하거나 기존 객체의 복사본을 만든 다음 해당 복사본을 사용하도록 상태를 설정해야 합니다.

What’s a mutation?

모든 종류의 JavaScript 값을 상태에 저장할 수 있습니다.

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

지금까지 숫자, 문자열, 부울을 사용해 작업해 왔습니다. 이러한 종류의 JavaScript 값은 “Immutable”입니다. 즉, 변경할 수 없거나 “읽기 전용”을 의미합니다. 값을 대체하기 위해 렌더링을 트리거할 수 있습니다.

setX(5);

x 의 상태가 0 에서 5 로 변경되었지만 숫자 0 자체는 변경되지 않았습니다. JavaScript에서는 숫자, 문자열, 부울과 같은 기본 제공 기본 값을 변경할 수 없습니다.

이제 상태에 있는 객체를 고려할 것입니다.

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

position.x = 5;

기술적으로는 객체 자체의 내용을 변경하는 것이 가능합니다. 이를 mutation이라고 합니다.

그러나 React 상태의 객체는 기술적으로 변경 가능하지만 숫자, 부울, 문자열처럼 변경 불가능한 것처럼 처리해야 합니다. 이를 변경하는 대신 항상 교체해야 합니다.

Treat state as read-only

즉, 상태에 넣은 모든 JavaScript 객체를 읽기 전용으로 처리해야 합니다.

이 예제는 현재 포인터 위치를 나타내는 상태의 객체를 보유합니다. 미리보기 영역 위로 커서를 터치하거나 이동할 때 빨간색 점이 이동해야 합니다. 그러나 점은 초기 위치에 유지됩니다.

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

문제는 이 코드에 있습니다.

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

이 코드는 이전 렌더링에서 할당된 position 객체를 수정합니다. 그러나 상태 설정 기능을 사용하지 않으면 React는 객체가 변경되었는지 전혀 알 수 없습니다. 따라서 React는 이에 대한 응답으로 아무것도 하지 않습니다. 이는 이미 식사를 마친 후에 순서를 바꾸려는 것과 같습니다. 상태 변경이 어떤 경우에는 작동할 수 있지만 권장하지는 않습니다. 렌더링에서 접근할 수 있는 상태 값을 읽기 전용으로 처리해야 합니다.

이 경우 실제로 리렌더링을 실행하려면, 새 객체를 만들어 상태 설정 함수에 전달해야 합니다.

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

setPosition 을 사용하면 React에게 다음과 같이 전달합니다.

  1. position이 새로운 객체로 교체
  2. 그리고 이 컴포넌트를 리렌더링

미리보기 영역을 터치하거나 마우스오버할 때 빨간색 점이 포인터를 어떻게 따라가는지 확인할 수 있습니다.

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

Local mutation is fine

이와 같은 코드는 상태의 기존 객체를 수정하기 때문에 문제가 됩니다.

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

mutation은 이미 상태에 있는 기존 객체를 변경할 때만 문제가 됩니다. 방금 생성한 객체를 변경하는 것은 아직 다른 코드가 참조하지 않기 때문에 괜찮습니다. 이를 변경한다고 해서 이에 의존하는 항목에 실수로 영향을 미치는 일은 없습니다. 이를 “Local mutation”이라고 합니다. 렌더링하는 동안 Local mutation을 수행할 수 도 있습니다.

Copying objects with the spread syntax

이전 예에서 position 객체는 항상 현재 커서 위치에서 새로 생성됩니다. 그러나 생성 중인 새 객체의 일부로 기존 데이터를 포함하려는 경우가 많습니다. 예를 들어 양식에서 하나의 필드만 업데이트하고 다른 모든 필드에 대해서는 이전 값을 유지하려고 할 수 있습니다.

onChange 핸들러가 상태를 변경하기 때문에 이러한 입력 필드는 작동하지 않습니다.

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

예를 들어 다음 줄은 과거 렌더링의 상태를 변경합니다.

person.firstName = e.target.value;

원하는 동작을 얻는 안정적인 방법은 새 객체를 만들어 setPerson 에 전달하는 것입니다. 하지만, 여기에서는 필드 중 하나만 변경되었으므로 기존데이터로 복사하려고 합니다.

setPerson({
  firstName: e.target.value, // New first name from the input
  lastName: person.lastName,
  email: person.email
});

모든 속성을 별도로 복사할 필요가 없도록 객체 확산 구문을 사용할 수 있습니다.

setPerson({
  ...person, // Copy the old fields
  firstName: e.target.value // But override this one
});

각 입력 필드에 대해 별도의 상태 변수를 선언하지 않은 방법에 주목하세요. 큰 양식의 경우 모든 데이터를 하나의 객체로 그룹화하는 것은 올바르게 업데이트하기만 하면 매우 편리합니다.

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

스프레그 구문은 은 얉은 복사라는 점을 유의하세요. 즉, 한 수준 깊이의 항목만 복사합니다. 이렇게하면 속도가 빨라지지만 중첩된 속성을 업데이트하려면 해당 속성을 두 번 이상 사용해야 한다는 뜻입니다.

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

여기서는 e.target.name 은 DOM 요소에 부여된 이름 속성 <input> 을 뜻합니다.

Updating a nested object

다음과 같은 중첩된 객체 구조를 고려해보겠습니다.

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

person.artwork.city 를 업데이트하기 위해서는 다음과 같은 mutation을 사용하야 수행하는 방법이 명확합니다.

하지만 React에서는 상태를 immutable으로 취급합니다. city 를 변경하려면 새 artwork 객체(이전 객체의 데이터로 미리 채워져 있음)를 생성한 다음 새 객체를 가리키는 새 person 객체를 생성해야 합니다.

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

혹은 단일 함수 호출로 작성할 수 있습니다.

setPerson({
  ...person, // Copy other fields
  artwork: { // but replace the artwork
    ...person.artwork, // with the same one
    city: 'New Delhi' // but in 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}
      />
    </>
  );
}

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 객체 내부에 있지 않습니다. 예로, obj3 객체는 obj1 를 가리킬 수 있습니다.

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.artworkobj2.artwork 그리고 obj1 은 모두 같은 객체이기 때문입니다. 이것이 객체가 중첩된 것으로 생각하면 이러한 점을 확인하기 어렵습니다. 대신, 이들은 속성을 통해 서로를 가리키는 별도의 객체입니다.

Write concise update logic with Immer

상태가 깊게 중첩된 경우 평면화를 고려할 수 있습니다. 그러나 상태 구조를 변경하고 싶지 않다면 중첩 스프레드에 대한 지름길을 선호할 수 있습니다. Immer 는 편리하지만 변경 가능한 구문을 사용하여 작성하고 복사본을 생성해 주는 인기 있는 라이브러리입니다. Immer를 사용하면 작성하는 코드는 "규칙을 어기고" 객체를 변경하는 것처럼 보입니다.

하지만 일반 mutation와는 달리 과거 상태를 덮어 쓰지 않습니다.

How does Immer work?

Immer가 제공하는 draft 라는 특별한 타입의 객체는 Proxy라고 불리우며 그것으로 무엇을 하는지를 기록합니다. 이것이 원하는 만큼 객체를 자유롭게 변경할 수 있는 이유입니다. 내부적으로는 Immer는 draft의 변경된 부분을 파악하고, 수정 내용이 포함된 완전히 새로운 객체를 생성합니다.

Immer를 사용하기 위한 단계는 다음과 같습니다.

  1. Immer를 dependency에 추가하기 위해 npm install use-immer 를 수행합니다.
  2. import { useState } from ‘react’import { useState } from ‘use-immer’ 로 수정합니다.

아래 예는 Immer로 변환된 위의 예입니다.

// package.json
{
  "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"
  },
  "devDependencies": {}
}

// App.js
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}
      />
    </>
  );
}

이벤트 핸들러가 얼마나 더 간결해졌는지 확인 할 수 있습니다. useStateuseImmer 를 원하는 만큼 하나의 구성 요소에서 혼합하고 일치시킬 수 있습니다. Immer는 특히, 상태에 중첩이 존대하고 객체를 복사하면 반복적인 코드가 발생하는 경우업데이트 핸들러를 간결하게 유지하는 좋은 방법입니다.

몇 가지 이유가 있습니다 :

  • 디버깅 : console.log 를 사용하고 state를 변경하지 않으면, 최신 상태 변경으로 인해 과거 로그가 손상되지 않습니다. 따라서 렌더링 간에 상태가 어떻게 변경되었는지 명확하게 확인할 수 있습니다
  • 최적화 : 일반적인 React의 최적화 전략은 이전 props 또는 상태가 다음 props 또는 상태와 동일한 경우 작업 건너뛰는 것에 의존합니다. 상태를 변경하지 않으면 변경 사항이 있는지 확인하는 것이 매우 빠릅니다. prevObj === obj 이면 내부에는 아무것도 변경되지 않았음을 확신할 수 있습니다.
  • 새로운 기능 : 새로운 React 기능은 스냅샷처럼 처리되는 상태에 의존합니다. 이전 버전의 상태를 변경한는 경우 새로운 기능을 사용하지 못할 수도 있습니다.
  • 요구 사항 변경 : 실행 취소/다시 실행 구현, 변경 내역 표시, 사용자가 이전 값으로 재설정하도록 허용하는 등 일부 어플리케이션 기능은 변경된 사항이 없을 때 더 쉽게 수행할 수 있습니다. 이는 과거 상태 복사본을 메모리에 보관하고 필요할 때 재사용할 수 있기 때문입니다. 가변적 접근 방식으로 시작하면 나중에 이와 같은 기능을 추가히 어려울 수 있습니다.
  • 더 간단한 구현 : React는 변형에 의존하지 않기 때문에 객체에 대한 특별한 작업을 수행할 필요가 없습니다. 많은 “반응형” 솔루션처럼 속성을 하이재킹하거나 항상 프록시로 래핑하거나 초기화 시 다른 작업을 수행할 필요가 없습니다. 이것이 바로 React를 사용하면 추가 성능이나 정확성 문제 없이 아무리 큰 객체라도 상태에 넣을 수 있는 이유이기도 합니다.

0개의 댓글