Menu

리액트 state 배열과 객체 업데이트하기(불변성)

React state의 배열이나 객체를 바꾸려면 새로 만들어 setter에 넘깁니다. 항목 추가, 제거, 교체, 삽입, 정렬, 중첩 객체 업데이트, 그리고 state를 직접 변경하면 화면이 그대로인 이유를 배웁니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

React에서 state의 배열이나 객체를 업데이트하려면 새로 만들어 setter에 넘깁니다. 기존 값을 직접 바꾸지 마세요. 전개(...), map, filter, slice로 변경 사항을 반영한 사본을 만든 다음 setItems(copy)를 호출하세요.

[...items, { ... }]는 항목이 하나 더 있는 새 배열이고, filter는 제거한 항목이 없는 새 배열을 반환합니다. 둘 다 이전 배열은 그대로 둡니다.

직접 변경하면 렌더링되지 않는 이유

setter를 호출하면 React는 Object.is로 새 값과 이전 값을 비교합니다. 배열과 객체에서는 내용이 아니라 참조를 비교합니다. push로 배열을 바꾸고 같은 배열을 다시 넘기면 React는 같은 참조를 보고, 아무것도 바뀌지 않았다고 판단해 렌더링을 건너뜁니다.

"push"를 몇 번 클릭해 보세요. 콘솔에서는 배열이 늘어나지만 새 렌더링 줄은 나타나지 않고 텍스트는 a로 남아 있습니다. 그다음 "spread"를 클릭하면 렌더링이 한 번 일어나고, push한 항목이 모두 한꺼번에 나타납니다. 처음부터 배열 안에 있었기 때문입니다. 직접 변경은 오류를 던지지 않습니다. 다른 무언가가 렌더링을 일으킬 때까지 화면을 낡은 상태로 남겨 둘 뿐입니다. 그 뒤에 "push"를 한 번 더 클릭하면 이번에는 렌더링될 수도 있습니다. React가 항상 미리 작업을 건너뛸 수 있는 것은 아니어서, 직접 변경이 어떤 때는 보이고 어떤 때는 보이지 않습니다. 이 예측 불가능함이 state를 직접 변경하지 말아야 하는 이유입니다.

배열: 써야 할 메서드와 피해야 할 메서드

규칙은 간단합니다. 새 배열을 반환하는 메서드를 쓰고, 호출한 배열 자체를 바꾸는 메서드는 피하세요.

목적피할 것(직접 변경)쓸 것(새 배열 반환)
추가push, unshift[...arr, item], [item, ...arr]
제거pop, shift, splicefilter, slice
교체arr[i] = x, splicemap, with(i, x)
삽입splice[...arr.slice(0, i), x, ...arr.slice(i)], toSpliced
정렬, 뒤집기sort, reversetoSorted, toReversed, 또는 먼저 복사

toSorted, toReversed, toSpliced, with는 비교적 새로운 배열 메서드(ES2023)로, 2023년부터 현재의 모든 주요 브라우저에서 지원됩니다. 직접 변경하는 사촌 메서드와 같은 일을 사본에 대해 합니다.

map으로 항목 하나 업데이트하기

항목 하나를 바꾸려면 배열을 map하면서 원하는 항목에는 새 객체를, 나머지 항목에는 원래 객체를 반환하세요.

{ ...todo, done: !todo.done } 부분이 중요합니다. 흔한 버그는 배열은 복사하면서 그 안의 객체를 바꾸는 것입니다.

const next = [...todos];
next[0].done = true; // next is new, but next[0] is the same object as todos[0]
setTodos(next);

이 코드는 렌더링되므로 괜찮아 보이지만, 이제 이전 배열과 새 배열이 변경된 객체를 공유합니다. 이전 항목과 새 항목을 비교하는 모든 것(React.memo 자식, 항목에 의존하는 이펙트)은 차이를 보지 못합니다. 바꾸는 항목은 복사하세요.

인덱스 위치에 삽입하기

직접 변경하지 않는 insert는 없으므로, 배열을 둘로 자르고 새 항목을 두 조각 사이에 넣으세요. toSpliced(index, 0, item)은 같은 일을 한 번의 호출로 합니다.

인덱스를 0으로 설정하고 다시 삽입하면 새 C가 맨 앞에 들어갑니다. next 표현식을 letters.toSpliced(index, 0, 'C')로 바꿔도 결과는 같습니다.

정렬과 뒤집기

sort와 reverse는 배열을 반환하기도 하지만, 호출한 배열 자체를 바꿉니다. 그래서 setItems(items.sort())는 직접 변경 후 같은 참조로 설정하는 것이고, 이 페이지 처음에 본 버그입니다. 대신 사본을 정렬하세요.

[...scores].reverse()가 동작하는 이유는 전개가 먼저 사본을 만들고, reverse는 그 사본만 바꾸기 때문입니다.

객체와 중첩 객체 업데이트하기

객체도 같은 규칙을 따릅니다. 이전 객체를 새 객체에 전개하고 바뀌는 필드를 덮어쓰세요. 중첩 객체라면 필드까지 내려가는 모든 단계에서 전개하세요. 전개를 한 번만 하면 최상위만 복사되므로 안쪽 객체는 여전히 이전 객체입니다.

복사하는 각 단계는 새 객체가 되고, 건드리지 않은 단계는 모두 같은 객체로 남습니다. 그래서 전개는 비용이 적습니다. state에 긴 주문 목록도 들어 있다면 { ...user }는 목록 자체가 아니라 목록에 대한 참조를 복사합니다.

state의 객체가 서너 단계로 중첩되어 있고 계속 긴 전개 체인을 작성하고 있다면, state를 더 평평하게 만들 수 있다는 신호인 경우가 많습니다. 항목을 id로 조회하는 구조에 저장하거나, 큰 객체 하나를 몇 개의 useState 호출로 나누면 각 업데이트가 짧아집니다. 여러 이벤트가 하나의 구조를 바꾼다면 리듀서도 도움이 됩니다(useReducer 참고).

structuredClone과 전개

structuredClone(user)는 한 번의 호출로 깊은 복사를 만들므로, 그다음에는 사본을 자유롭게 바꿀 수 있습니다.

const next = structuredClone(user);
next.address.city = 'Oslo';
setUser(next);

올바르지만, 바꾸지 않은 부분까지 포함해 매번 객체 전체를 복사합니다. 큰 state에서는 시간이 들고, 모든 중첩 객체가 새 참조를 가지게 되므로 user.orders를 받는 메모이제이션된 자식은 주문이 바뀌지 않았는데도 다시 렌더링됩니다. structuredClone은 함수가 있으면 오류를 던지고, 클래스 인스턴스는 일반 객체로 돌아옵니다. 전개는 바꾸는 경로만 복사하므로 보통 이쪽을 선택합니다. structuredClone은 객체 대부분을 실제로 다시 쓰는 드문 업데이트에만 쓰세요.

Immer로 직접 변경하듯 작성하기

Immer는 직접 변경처럼 보이는 코드를 작성하게 해 주는 작은 라이브러리입니다. 초안을 건네주고, 무엇을 바꾸는지 기록한 뒤, 바뀐 부분만 복사된 새 불변 값을 만들어 냅니다. use-immer 패키지가 이것을 훅으로 감싸 줍니다.

import { useImmer } from 'use-immer';

function Profile() {
    const [user, updateUser] = useImmer({
        name: 'Ada',
        address: { city: 'London' },
    });

    function moveTo(city) {
        updateUser((draft) => {
            draft.address.city = city;
        });
    }
    // ...
}

React만 불러오는 이 페이지의 에디터에서는 Immer를 쓸 수 없습니다. state가 깊이 중첩되어 전개가 읽기 어려워질 때 추가할 만합니다. 평평한 state라면 일반 전개와 map으로 충분하며, 대부분의 React 코드에서 보게 되는 것도 이것입니다.

자주 묻는 질문

React state의 배열에 항목을 추가하려면 어떻게 하나요?

이전 항목과 새 항목을 담은 새 배열을 만들어 setter에 넘기세요: setItems([...items, newItem]). items.push(newItem)은 기존 배열을 바꾸고 React가 같은 참조를 보게 되므로 호출하지 마세요.

배열을 업데이트해도 컴포넌트가 다시 렌더링되지 않는 이유는 무엇인가요?

아마 배열을 직접 변경하고(push, splice, sort, arr[i] = x) 같은 배열을 다시 넘겼을 것입니다. React는 Object.is로 이전 값과 새 값을 비교하고, 같은 객체임을 확인하면 렌더링을 건너뜁니다. 대신 새 배열을 넘기세요.

React에서 배열 안의 객체 하나를 업데이트하려면 어떻게 하나요?

map을 사용해 바뀌는 항목에는 새 객체를, 나머지 항목에는 원래 항목을 반환하세요: setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t)).

React state의 중첩 객체는 어떻게 업데이트하나요?

바꿀 필드까지 가는 경로의 모든 단계를 복사하세요: setUser({ ...user, address: { ...user.address, city: 'Oslo' } }). 전개를 한 번만 하면 최상위만 복사되므로 안쪽 객체는 여전히 공유됩니다.

React state에는 structuredClone과 전개 연산자 중 무엇을 써야 하나요?

전개를 선호하세요. 바뀌는 부분만 복사하고 건드리지 않은 부분은 같은 객체로 유지하므로 React.memo와 비교에 도움이 됩니다. structuredClone은 매번 모든 것을 복사하므로 작성하기는 더 간단하지만, 큰 state에서는 느리고 바뀌지 않은 부분의 참조 동일성도 깨집니다.

Coddy 프로그래밍 언어 일러스트

Coddy로 코딩 배우기

시작하기