(번역) State에서 객체 업데이트하기 - NEW 리액트 공식문서

hongregii·2023년 3월 6일
0

State는 모든 종류의 JS 값을 담을 수 있다. 당연히 객체 포함! 그러나 state에서 직접적으로 객체를 바꾸면 안된다. 그렇게 하지 말고, 새로운 객체를 만들어서 (아니면 기존 객체를 copy) 그 사본으로 state를 설정해라!

이 문서에서는..

  • state를 업데이트하는 올바른 방법
  • 변형 mutation 없이 nested update 하는법
  • 불변성 immutability
  • Immer 로 객체 사본 덜 만들기

를 알아보자.

변형 mutation ?

state에는 아무 JS 값을 넣을 수 있는데...

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

여태까지는 number, string, boolean 들만 다뤘다. 이런 JS 값들은 "Immutable" 하다. "읽기 전용"이다. - 바꿀 수 없다는 뜻.
값을 바꾸려면 리렌더링을 해야 함.

setX(5);

x state는 0 에서 5로 바뀌었지만, 사실 숫자 0 이 바뀐 것은 아니다. number, string, boolean 같은 내장 type들은 바꿀 수가 없음.

이제 state에 객체 object 를 넣을 때를 보자.

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

엄밀히 말하면, 객체 그 자체 의 내용을 바꿀 수는 있다. 이것이 바로 mutation.

position.x = 5;

그러나, 리액트 state의 객체들이 엄밀히 mutable 함에도 불구하고, immutable 한 것처럼 다뤄야 한다!! 절대 mutate 직접 바꾸지 말고, replace 해라.

state는 읽기 전용으로 다루자

다시 말해, state에 담는 모든 JS 객체는 읽기 전용으로 다루자는 말이다.

아래 예시는 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>
  );
}

문제는 바로 요기 :

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

이 코드는 이전 렌더링 에서 position state에 할당된 객체를 바꾼다. 그러나 setState()를 사용하지 않으면 리액트는 그 객체가 바뀌었는지 알 수가 없고, 결론적으로 리액트는 암것도 안함. 음식을 다 먹고 나서 주문을 바꾸려고 하는 것과 같다. 가끔 state를 mutate 하는 시도가 작동할 때가 있지만, 리액트는 권장하지 않는다. 렌더 안에서 state는 읽기 전용으로만 접근하는 게 옳다.

리렌더링을 불러오려면,

새로운 객체를 만들고 setState 인자로 넘겨줄것.

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

setPosition에서, 리액트에게 이렇게 말해주는 것임 :

  • position state를 이 새로운 객체로 replace 해줘
  • 그리고 나서 컴포넌트 리렌더링 해줘

이제는 빨간 점이 움직일 것이다

~~새로운 코드는 생략~~

... 구문으로 객체 shallow copy 뜨기

이전 예제에서, position 객체는 현재 커서 위치로부터 항상 새롭게 만들어진다. 그러나 새로 만드려는 객체에서 이미 존재하는 데이터를 포함하고 싶을 때가 자주 있을 것임. 예를 들어, form에서 다른 필드는 이전 값들을 가지고 가고, 딱 한 필드 만 업데이트 하고 싶다면?

아래처럼 하면 작동하지 않는다. onChange 핸들러가 상태를 mutate 하기 때문.

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

(실제로 이게 리액트 엘리먼트는 아니지만, ui만이라도 렌더링 해보면 아래와 같다..)

First name:
Last name:
Email:

Barbara Hepworth (bhepworth@sculpture.com)

세 이벤트 핸들러 중 하나만 예를 들면,
이 코드는 이전 렌더링의 state를 바꾼다.

person.firstName = e.target.value;

정확한 방법은 새로운 객체를 만들고 setPerson 에 인자로 넘기는 것. 그러나 이렇게 객체의 일부 바꾸고 싶은 경우는 기존 데이터를 복사해서 넣어줘야 함 :

setPerson({
  firstName: e.target.value, // 새로운 인풋값. keyDown 이벤트마다 업데이트 되겠지
  lastName: person.lastName,
  email: person.email
});

이렇게 다 쓰는것 보다 ...전개 구문 spread operator 사용해서 모든 프로퍼티를 복사할 필요가 없게끔 편하게 사용할 수 있음.

setPerson({
  ...person, // 기존 객체 복사
  firstname : e.target.value // 이 부분만 override
});

이제 잘 작동한다!

... spread operator는 "shallow" 얕은 복사라는 것을 기억하자. 이렇게 하면 빠르다. 그러나 nest 된 프로퍼티를 업데이트하고 싶을 때 여러번 사용해야 한다는 말이기도 하다.

실제로는 이렇게 사용한다...

이벤트 핸들러 하나로 여러 필드 업데이트하기

객체를 선언할 때 [] 를 사용하면 동적인 name을 특정할 수 있다.
위와 같은 예시를 하나의 이벤트 핸들러만 사용한다면...

function handleChange(e) {
    setPerson({
      ...person,
     [e.target.name]: e.target.value
    });
  }

이 이벤트 핸들러를 모든 inputonChange에 넣어주면 된다.

함수에서 [e.target.name]input DOM 요소의 name 프로퍼티를 뜻함.

Nested Object 업데이트하기

이런 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를 업데이트하고 싶을 때, mutation을 사용하면 이렇게 될 것이다.

person.artwork.city = 'New Delhi';

아까 설명했듯, 리액트에서 state는 immutable 하다!
city를 바꾸려면, 먼저 새로운 artwork 객체를 만들어야 한다 (이전 데이터가 들어있어야 함).
그리고 나서 새로운 artwork 객체를 가리키는 새로운 person 객체를 만들자 :

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

한번에 쓰고 싶으면 :

setPerson({
  ...person, // 기존 객체 shallow copy
  artwork : { // artwork 만 replace 바꾸자!
    ...person.artwork, // 기존 artwork shallow copy
    city : 'New Delhi' // 얘만 업데이트
  }
});

복잡해 보여도 잘 작동함.

JS Nested Object 다시보기

객체는 엄밀히 말하면 nest 되지 않는다.

이 객체는 코드로 보기에 "nested" 된것처럼 보이지만:

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

객체의 작동을 설명할 때 'nesting'은 정확한 표현은 아님.
사실 두 개의 서로 다른 객체를 보고 있는 것임:

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

let obj2 = {
  name: 'Niki de Saint Phalle',
  artwork: obj1 // obj1을 point 하고 있다
};

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

obj1, obj2.artwork, obj3.artwork는 사실 다 같은 객체임. 그래서 obj3.artwork.city를 바꾸면 obj2.artwork.cityobj1.city가 전부 바뀐다.

결론 : 객체는 nested 된 것이라기 보다는, 각 객체가 있고 서로를 프로퍼티로 "가리키고" 있는 것이다.

Immer 사용하기

state가 깊게 nested 돼 있다면, 펴는 게 좋다. state의 자료 구조를 역으로 바꾸면 nesting을 피할 수 있다. 하지만, 구조를 바꾸고 싶지 않다면, nested spread 하는 더 빠른 지름길을 선호할 수 있다. Immer는 mutate syntax로 편리하게 작성하게 해 주지만, 내부 코드에서는 복사본을 만들어준다.

updatePerson(draft => {
  draft.artwork.city = 'Lagos'; // mutate 아닙니다 Immer 문법임니다
});

mutate 와 달리, 이전 state를 overwrite 하지 않는다!

Immer, 어떻게 작동하나?

Immer 가 제공하는 draft 라는 놈은 Proxy 라는 특별한 객체다. Proxy는 개발자가 거기다 무엇을 하는지 '기록'하는데, 이 때문에 마음껏 state를 mutate 해도 괜찮은 것이다! 코드 아래에서 Immer는 draft의 어느 부분이 바뀌었는지를 찾아내고, 편집을 반영한 완전히 새로운 객체를 생산한다.

Immer를 시도해보자 :
1. npm install use-immer 의존성 추가.
2. import { useState } from 'react' import { useImmer } from 'use-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}
      />
    </>
  );
}

이벤트 핸들러들을 보시라. 즉시 mutate 해도 Immer가 알아서 shallow copy를 떠준다고 한다.

한 컴포넌트 안에서 useStateuseImmer를 얼마든지 섞어 써도 좋다. nested object state가 많아서 코드가 계속 반복될 때 쓰면 좋다.

profile
잡식성 누렁이 개발자

0개의 댓글