React를 사용하면 JSX에 이벤트 핸들러를 추가할 수 있음. 이벤트 핸들러는 click, hover, focus 등과 같은 상호작용에 반응하여 트리거되는 자체 함수.
이벤트 핸들러는 일반적으로
이벤트 핸들러를 추가하려면
함수를 정의한 후 이를 적절한 JSX 태그에 프로퍼티로 전달해야 함.
export default function Button() {
function handleClick() {
alert('You clicked me!');
}
return (
<button onClick={handleClick}>
Click me
</button>
);
}
JSX에서 이벤트 핸들러를 inline으로 정의할 수도 있음.
<button onClick={function handleClick() {
alert('You clicked me!');
}}>
더 간결하게, 회살표 함수를 사용할 수도 있음.
<button onClick={() => {
alert('You clicked me!');
}}>
위 세 가지 방법은 동일하며, inline 이벤트 헨들러는 짧은 함수일 때 편리함.
주의!
이벤트 핸들러에 전달되는 함수는 '호출'이 아니라 '전달'되어야 함!
- 함수 '전달' ✅
<button onClick={handleClick}>
<button onClick={() => alert('...')}>
React는 전달받은 함수를 기억했다가 사용자가 버튼을 클릭할 때만 함수를 호출하도록 지시
- 함수 '호출' ❌
<button onClick={handleClick()}>
<button onClick={alert('...')}>
JSX { } 내부의 자바스크립트는 바로 실행되기 때문에, handleClick() 끝에 있는 ()은 클릭 없이도 렌더링 중에 즉시 함수를 실행함.
이벤트 핸들러는 컴포넌트 내부에서 선언되므로 컴포넌트의 프로퍼티에 접근할 수 있음.
function AlertButton({ message, children }) {
return (
<button onClick={() => alert(message)}>
{children}
</button>
);
}
export default function Toolbar() {
return (
<div>
<AlertButton message="Playing!">
Play Movie
</AlertButton>
<AlertButton message="Uploading!">
Upload Image
</AlertButton>
</div>
);
}
부모 컴포넌트가 자식의 이벤트 핸들러를 지정하고 싶은 경우, 이벤트 핸들러를 프로퍼티로 내려줌.
function Button({ onClick, children }) {
return (
<button onClick={onClick}>
{children}
</button>
);
}
function PlayButton({ movieName }) {
function handlePlayClick() {
alert(`Playing ${movieName}!`);
}
return (
<Button onClick={handlePlayClick}>
Play "{movieName}"
</Button>
);
}
function UploadButton() {
return (
<Button onClick={() => alert('Uploading!')}>
Upload Image
</Button>
);
}
export default function Toolbar() {
return (
<div>
<PlayButton movieName="Kiki's Delivery Service" />
<UploadButton />
</div>
);
}
디자인 시스템을 사용하는 경우, 버튼과 같은 컴포넌트에 스타일링은 포함하지만 동작을 지정하지 않는 것이 일반적임.
<button> 및 <div>와 같은 기본 제공 컴포넌트는 onClick과 같은 브라우저 이벤트 이름
만 지원하지만, 자체 컴포넌트는 이벤트 핸들러 프로퍼티의 이름을 원하는 방식으로 지정할 수 있음.
이벤트 핸들러 프로퍼티의 이름은 대문자가 뒤따르는 on으로 시작해야 함. (ex. onSmash)
function Button({ onSmash, children }) {
return (
<button onClick={onSmash}>
{children}
</button>
);
}
export default function App() {
return (
<div>
<Button onSmash={() => alert('Playing!')}>
Play Movie
</Button>
<Button onSmash={() => alert('Uploading!')}>
Upload Image
</Button>
</div>
);
}
컴포넌트가 여러 상호작용을 지원하는 경우 이벤트 핸들러 프로퍼티의 이름을 app-specific하게 지정할 수 있음.
export default function App() {
return (
<Toolbar
onPlayMovie={() => alert('Playing!')}
onUploadImage={() => alert('Uploading!')}
/>
);
}
function Toolbar({ onPlayMovie, onUploadImage }) {
return (
<div>
<Button onClick={onPlayMovie}>
Play Movie
</Button>
<Button onClick={onUploadImage}>
Upload Image
</Button>
</div>
);
}
function Button({ onClick, children }) {
return (
<button onClick={onClick}>
{children}
</button>
);
}
App 컴포넌트는 Toolbar 컴포넌트가 onPlayMovie 또는 onUploadImage로 어떤 작업을 수행할지(Toolbar의 세부 구현사항) 알 필요가 없음. Toolbar 컴포넌트는 onPlayMovie, onUploadImage를 button에 onClick 핸들러로 전달하지만, 키보드 단축키로도 트리거할 수 있습니다. 프로퍼티의 이름을 onPlayMovie, onUploadImage처럼 app-specific한 상호작용의 이름으로 지정하면 나중에 프로퍼티의 사용 방식을 유연하게 변경할 수 있음.
주의!
이벤트 핸들러에 적절한 HTML 태그를 사용해야 함!
클릭 이벤트 핸들러에<div>대신<button>태그를 사용하면 키보드 탐색과 같은 기본 브라우저 동작을 사용할 수 있음. 버튼의 기본 브라우저 스타일링잉 마음에 들지 않으면 CSS를 사용할 것! (참고: 접근성을 지키는 마크업 작성)
이벤트 핸들러는 모든 하위 컴포넌트의 이벤트도 포착함. 이를 이벤트가 트리 위로 'bubbles' 또는 'propagates' 된다고 하며, 이벤트가 발생한 위치에서 시작하여 트리를 따라 올라감.
export default function Toolbar() {
return (
<div className="Toolbar" onClick={() => {
alert('You clicked on the toolbar!');
}}>
<button onClick={() => alert('Playing!')}>
Play Movie
</button>
<button onClick={() => alert('Uploading!')}>
Upload Image
</button>
</div>
);
}
두 버튼 중 하나를 클릭하면 해당 버튼의 onClick이 먼저 실행되고 부모 <div>의 onClick이 실행되어 두 개의 메시지가 나타남. Toolbar 자체를 클릭하면 부모 <div>의 onClick만 실행됨.
주의!
onScroll을 제외한 모든 이벤트는 React에서 전파됨.
이벤트 핸들러는 이벤트 객체를 유일한 인수로 받고, 일반적으로 이를 "event"를 의미하는 e라고 표기함. 이 객체를 사용하여 이벤트에 대한 정보를 읽을 수 있음.
이 이벤트 객체를 사용하면 전파를 중지할 수도 있음. 이벤트가 부모 컴포넌트에 도달하지 못하도록 하려면 e.stopPropagation()을 호출해야 함.
function Button({ onClick, children }) {
return (
<button onClick={e => {
e.stopPropagation();
onClick();
}}>
{children}
</button>
);
}
export default function Toolbar() {
return (
<div className="Toolbar" onClick={() => {
alert('You clicked on the toolbar!');
}}>
<Button onClick={() => alert('Playing!')}>
Play Movie
</Button>
<Button onClick={() => alert('Uploading!')}>
Upload Image
</Button>
</div>
);
}
버튼을 클릭하면
<button>에 전달된 onClick 핸들러를 호출<div>의 onClick 핸들러는 실행되지 않음.Capture phase events
드물지만 하위 요소에서 전파가 중지된 경우에도 모든 이벤트를 포착해야 하는 경우가 있음. (ex. 전파 로직에 관계없이 모든 클릭을 애널리틱스에 기록하고자 할 경우) 이벤트 이름 끝에 Capture를 추가하면 이 작업을 수행할 수 있음.<div onClick={() => {alert('outer onClick')}} onClickCapture={() => {alert('outer onClickCapture')}}> <button onClick={(e) => {e.stopPropagation(); alert('inner onClick')}} onClickCapture={() => {alert('inner onClickCapture')}}/> </div> // 결과: outer onClickCapture -> inner onClickCapture -> inner onClick버튼을 클릭하면 이벤트는 세 단계로 전파됨:
1. 아래로 이동하며 모든 onClickCapture 핸들러를 호출
2. 클릭된 요소의 onClick 핸들러를 호출
3. (전파가 중지되지 않을 경우) 위쪽으로 이동하여 모든 onClick 핸들러를 호출
캡처 이벤트는 라우터나 분석과 같은 코드에서는 유용하지만 앱 구현에는 자주 사용되지 않음.
상위 컴포넌트에서 핸들러를 전달받는 방법으로 이벤트 전파를 대신할 수 있음.
function Button({ onClick, children }) {
return (
<button onClick={e => {
e.stopPropagation();
onClick();
}}>
{children}
</button>
);
}
이 방법은 이벤트 전파와 달리 자동으로 처리되지는 않지만, 하위 컴포넌트가 이벤트를 처리하는 동시에 상위 컴포넌트의 일부 추가 기능이 작동되도록 할 수 있음. 또, 이벤트 전파에 의존하여 실행되는 핸들러는 추적이 어려운 반면, 이 방법은 특정 이벤트의 결과로 실행되는 전체 코드 체인을 명확하게 이해하고 추적할 수 있음.
일부 브라우저 이벤트에는 기본 동작이 있음. 예를 들어, <form> 내부의 버튼을 클릭할 때 발생하는 submit 이벤트는 기본적으로 전체 페이지를 다시 로드함. 이러한 기본 동작을 막기 위해서는 e.preventDefault()를 호출해야 함.
export default function Signup() {
return (
<form onSubmit={e => {
e.preventDefault();
alert('Submitting!');
}}>
<input />
<button>Send</button>
</form>
);
}
이벤트 핸들러는 사이드 이펙트가 가장 많이 발생하는 곳!
렌더링 함수와 달리, 이벤트 핸들러는 순수할 필요가 없기 때문에 타이핑으로 입력값을 변경하거나 버튼을 눌러 목록을 변경하는 등 무언가를 변경하기에 적합함. 하지만 정보를 변경하기 위해서는 정보를 저장할 방법이 필요하고, React에서는 컴포넌트의 메모리인 state를 사용해 이 작업을 수행함.
컴포넌트는 상호작용의 결과로 화면에 보여지는 내용을 변경해야 하는 경우가 많음. 예를 들어, form에 타이핑을 하면 입력 필드가 업데이트되어야 하고, 이미지 캐러셀에서 '다음' 버튼을 누르면 표시되는 이미지가 바뀌어야하고, '구매' 버튼을 누르면 상품이 장바구니에 담겨야 함. 컴포넌트는 현재 입력값, 현재 이미지, 장바구니 등을 '기억'해야하고, React에서는 이러한 컴포넌트별 메모리를 state라고 부름.
import { sculptureList } from './data.js';
export default function Gallery() {
let index = 0;
function handleClick() {
index = index + 1;
}
let sculpture = sculptureList[index];
return (
<>
<button onClick={handleClick}>
Next
</button>
<h2>
<i>{sculpture.name} </i>
by {sculpture.artist}
</h2>
<h3>
({index + 1} of {sculptureList.length})
</h3>
<img
src={sculpture.url}
alt={sculpture.alt}
/>
<p>
{sculpture.description}
</p>
</>
);
}
handleClick 이벤트 핸들러가 로컬 변수 index를 업데이트하고 있지만, 다음 이유 때문에 아무 일도 발생하지 않는 것 처럼 보임:
컴포넌트를 새 데이터로 업데이트하려면:
useState Hook은 다음 두 가지를 제공함:
import { useState } from 'react';
import { sculptureList } from './data.js';
export default function Gallery() {
const [index, setIndex] = useState(0);
function handleClick() {
setIndex(index + 1);
}
let sculpture = sculptureList[index];
return (
<>
<button onClick={handleClick}>
Next
</button>
<h2>
<i>{sculpture.name} </i>
by {sculpture.artist}
</h2>
<h3>
({index + 1} of {sculptureList.length})
</h3>
<img
src={sculpture.url}
alt={sculpture.alt}
/>
<p>
{sculpture.description}
</p>
</>
);
}
index는 state 변수, setIndex는 setter 함수
여기서 [] 구문을 array destructuring이라고 하며 배열에서 값을 읽을 수 있게 해줌. useState가 반환하는 배열에는 항상 두 개의 항목이 있음.
React에서 useState처럼 "use"로 시작하는 함수를 Hook이라고 함. Hook은 React가 렌더링하는 동안에만 사용할 수 있는 특수 함수로, 이를 통해 다양한 React 기능을 "연결"할 수 있음.
주의!
Hook은 컴포넌트의 최상위 레벨 또는 커스텀 Hook에서만 호출할 수 있음. 조건문, 반복문, 중첩 함수 내부에서는 Hook을 호출할 수 없음.습니다. 조건, 루프 또는 기타 중첩된 함수 내부에서는 Hook을 호출할 수 없음.
Hook은 함수이지만 "컴포넌트의 요구사항에 대한 무조건적인 선언"으로 생각해도 좋음. 파일 상단에서 모듈을 "import"하는 것처럼, 컴포넌트 상단에서 React 기능을 "use"하는 것!
useState를 호출하는 것은, React에게 해당 컴포넌트가 무언가를 기억하기를 원한다고 React에 말하는 것!
useState의 유일한 인수는 상태 변수의 초기 값
컴포넌트가 렌더링될 때마다 useState는 두 개의 값을 포함하는 배열을 반환:
참고
이 쌍의 이름은const [something, setSomething]과 같이 정하는 것이 일반적. 원하는 대로 이름을 지정할 수 있지만, 규칙을 따르면 프로젝트에 관계없이 이해가 쉬워짐.
const [index, setIndex] = useState(0);
하나의 컴포넌트에 원하는 만큼 많은 type의 state 변수를 가질 수 있음
import { useState } from 'react';
import { sculptureList } from './data.js';
export default function Gallery() {
const [index, setIndex] = useState(0);
const [showMore, setShowMore] = useState(false);
function handleNextClick() {
setIndex(index + 1);
}
function handleMoreClick() {
setShowMore(!showMore);
}
let sculpture = sculptureList[index];
return (
<>
<button onClick={handleNextClick}>
Next
</button>
<h2>
<i>{sculpture.name} </i>
by {sculpture.artist}
</h2>
<h3>
({index + 1} of {sculptureList.length})
</h3>
<button onClick={handleMoreClick}>
{showMore ? 'Hide' : 'Show'} details
</button>
{showMore && <p>{sculpture.description}</p>}
<img
src={sculpture.url}
alt={sculpture.alt}
/>
</>
);
}
위 예제의 index와 showMore처럼 상태가 서로 관련이 없는 경우 여러 개의 state 변수를 사용하는 것이 좋지만, 두 개의 상태 변수를 자주 함께 변경하는 경우 두 변수를 하나로 결합하는 것이 더 쉬울 수 있음. 예를 들어 필드가 많은 form이 있는 경우 필드별로 상태 변수를 사용하는 것보다 객체를 보유하는 단일 상태 변수를 사용하는 것이 더 편리함. (팁: 상태 구조 선택)
DEEP DIVE: How does React know which state to return?
useState는 어떤 state 변수를 참조하는지에 대한 정보를 받지 않음. 전달되는 '식별자'가 없는데, 어떤 상태 변수를 반환할지 어떻게 알까?
Hook은 간결한 구문을 구현하기 위해 동일한 컴포넌트의 모든 렌더링에서 동일한 호출 순서에 의존. '최상위 레벨에서만 Hook을 호출'하는 위의 규칙을 따르면 Hook은 항상 같은 순서로 호출되고, 린터 플러그인은 대부분의 실수를 잡아냄.
내부적으로 React는 모든 컴포넌트에 대해 state 쌍의 배열을 보유하고, 렌더링 전에 0으로 설정된 인덱스를 유지.useState를 호출할 때마다 React는 다음 상태 쌍을 제공하고 인덱스를 증가시킴. (참고: React hooks: not magic, just arrays)let componentHooks = []; let currentHookIndex = 0; // How useState works inside React (simplified). function useState(initialState) { let pair = componentHooks[currentHookIndex]; if (pair) { // This is not the first render, // so the state pair already exists. // Return it and prepare for next Hook call. currentHookIndex++; return pair; } // This is the first time we're rendering, // so create a state pair and store it. pair = [initialState, setState]; function setState(nextState) { // When the user requests a state change, // put the new value into the pair. pair[0] = nextState; updateDOM(); } // Store the pair for future renders // and prepare for the next Hook call. componentHooks[currentHookIndex] = pair; currentHookIndex++; return pair; }
동일한 컴포넌트를 두 번 렌더링하면 각 사본은 완전히 분리된 state를 가짐! 그 중 하나를 변경해도 다른 컴포넌트에는 영향을 미치지 않음.
모듈 상단에 선언하는 일반 변수와 달리 state는 특정 함수 호출이나 코드의 특정 위치에 연결되지 않지만 화면의 컴포넌트 인스턴스에 독립적임.
프로퍼티와 달리 state는 그것을 선언하는 컴포넌트에서만 사용할 수 있는 완전한 비공개 데이터이며, 부모 컴포넌트는 이를 변경할 수 없음. 따라서 다른 컴포넌트에 영향을 주지 않고 상태를 추가하거나 제거할 수 있음.
두 컴포넌트 인스턴스의 상태를 동기화하려면? React에서의 올바른 방법은 자식 컴포넌트에서 state를 제거하고 가장 가까운 공유 부모 컴포넌트에 추가하는 것! (참고: 상태 공유하기)
컴포넌트가 화면에 표시되기 전에 React에서 렌더링 과정을 거쳐야 함.
[비유]
컴포넌트: 주방에서 재료로 맛있는 요리를 만드는 요리사
React: 고객의 요청을 접수하고 주문을 가져오는 웨이터
UI를 요청하고 제공하는 이 과정은 세 단계로 이루어짐:

컴포넌트가 렌더링되는 두 가지 이유:
앱이 시작되면 초기 렌더링을 트리거해야함. ReactDOM의 createRoot()에 타겟 DOM 요소를 전달하여 생성된 ReactDOM 루트(root)의 render 메서드를 사용해 렌더링.
import Image from './Image.js';
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'))
root.render(<Image />);
컴포넌트가 처음 렌더링된 후, setter 함수로 상태를 업데이트하여 추가 렌더링을 트리거할 수 있음. 컴포넌트의 상태를 업데이트하면 렌더링이 자동으로 대기열에 추가됨. (레스토랑에서 손님이 첫 주문을 한 후 갈증이나 배고픔의 상태에 따라 차, 디저트 등 다양한 음식을 주문하는 것처럼!)

렌더링을 트리거한 후 React는 컴포넌트를 호출하여 화면에 표시할 내용을 파악. "렌더링"은 React가 컴포넌트를 호출하는 것.
업데이트된 컴포넌트가 다른 컴포넌트를 반환하면 React는 다음으로 그 컴포넌트를 렌더링하고, 그 컴포넌트 역시 무언가를 반환하면 다음으로 그 컴포넌트를 렌더링하는 식으로 재귀적인 프로세스를 수행. 이 프로세스는 중첩된 컴포넌트가 더 이상 존재하지 않고 React가 화면에 표시해야 할 내용을 정확히 파악할 때까지 계속됨.
주의!
렌더링은 항상 순수한 계산이어야 함:
- 동일 입력, 동일 출력! 동일한 입력이 주어지면 컴포넌트는 항상 동일한 JSX를 반환해야 함. (토마토가 들어간 샐러드를 주문했는데 양파가 들어간 샐러드가 나오면 안됨!).
- 컴포넌트는 자기 일에만 신경씀! 렌더링 전에 존재했던 객체나 변수를 변경하지 않아야 함. (하나의 주문이 다른 사람의 주문을 변경해서는 안 됨!)
그렇지 않으면 코드베이스가 복잡해지면서 혼란스러운 버그와 예측할 수 없는 동작이 발생할 수 있음. "Strict Mode"에서 개발하면 React는 각 컴포넌트의 함수를 두 번 호출하므로 순수하지 않은 함수로 인한 실수를 발견하는 데 도움이 될 수 있음.
DEEP DIVE: Optimizing performance
업데이트된 컴포넌트가 트리에서 매우 높은 위치에 있는 경우 업데이트된 컴포넌트 내에 중첩된 모든 컴포넌트를 렌더링하는 기본 동작은 성능을 저하시킴. 성능 문제가 발생하는 경우 성능 섹션에 설명된 몇 가지 방식으로 해결할 수 있지만, 조급하게 최적화하지 말 것!
컴포넌트를 렌더링(호출)한 후 React는 DOM을 수정함.
appendChild() DOM API를 사용해 생성한 모든 DOM 노드를 화면에 배치React는 렌더링 간에 차이가 있는 경우에만 DOM 노드를 변경함. 예를 들어, 부모로부터 매초마다 다른 props를 전달받아 다시 렌더링하는 Clock 컴포넌트의 에 텍스트를 입력해도 컴포넌트가 다시 렌더링될 때 텍스트는 사라지지 않음!
export default function Clock({ time }) {
return (
<>
<h1>{time}</h1>
<input />
</>
);
}
이 마지막 단계에서 React는 <input>이 JSX에서 지난번과 같은 위치에 표시된다는 것을 알기 때문에 <input>이나 그 값을 건드리지 않고, <h1>의 내용만 새로운 시간으로 업데이트함.
렌더링이 완료되고 React가 DOM을 업데이트하면 브라우저는 화면을 다시 칠함. 이 프로세스를 "브라우저 렌더링"이라고 부르지만 문서 전체에서 혼동을 피하기 위해 "페인팅"이라고 부를 것.

state 변수는 읽고 쓸 수 있는 일반 JavaScript 변수처럼 보일 수 있지만, state는 스냅샷처럼 동작함. state 변수를 설정해도 이미 가지고 있는 state 변수가 변경되는 것이 아니라 재렌더링이 트리거됨.
클릭과 같은 사용자 이벤트에 반응하여 사용자 인터페이스가 직접 변경된다고 생각할 수 있지만, 인터페이스가 이벤트에 반응하려면 state가 업데이트되어 React에 렌더링을 요청해야함.
import { useState } from 'react';
export default function Form() {
const [isSent, setIsSent] = useState(false);
const [message, setMessage] = useState('Hi!');
if (isSent) {
return <h1>Your message is on its way!</h1>
}
return (
<form onSubmit={(e) => {
e.preventDefault();
setIsSent(true);
sendMessage(message);
}}>
<textarea
placeholder="Message"
value={message}
onChange={e => setMessage(e.target.value)}
/>
<button type="submit">Send</button>
</form>
);
}
function sendMessage(message) {
// ...
}
버튼을 클릭하면:
onSubmit 이벤트 핸들러 실행setIsSent(true)는 isSent를 true로 설정하고 새 렌더링을 대기열에 추가'렌더링'이란 React가 컴포넌트, 즉 함수를 호출하는 것. 해당 함수에서 반환하는 JSX는 '시간에 따른 UI 스냅샷'과 같음. 프롭, 이벤트 핸들러, 로컬 변수는 모두 렌더링 당시의 state를 사용해 계산됨.
사진이나 동영상 프레임과 달리 'UI 스냅샷'은 대화형! 입력에 대한 응답으로 어떤 일이 일어날지 결정하는 이벤트 핸들러와 같은 로직이 포함됨. React는 이 스냅샷에 맞춰 화면을 업데이트하고 이벤트 핸들러를 연결. 결과적으로 버튼을 누르면 JSX에서 클릭 핸들러가 트리거됨.
React가 컴포넌트를 다시 렌더링할 때:

컴포넌트의 메모리로서, state는 함수가 반환된 후 사라지는 일반 변수와 다름. state는 실제로 함수 외부에 마치 선반에 있는 것처럼 React 자체에 "존재". React가 컴포넌트를 호출하면 특정 렌더링에 대한 상태의 스냅샷을 제공. 컴포넌트는 해당 렌더링의 상태 값을 사용해 계산된 새로운 프로퍼티 세트와 이벤트 핸들러가 포함된 UI의 스냅샷을 JSX에 반환!
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>
</>
)
}
클릭당 한 번만 숫자가 증가함!
상태를 설정하면 다음 렌더링에 대해서만 변경됨. 첫 번째 렌더링에서 number는 0이었, 해당 렌더링의 onClick 핸들러에서 setNumber(number + 1)가 호출된 후에도 number의 값은 여전히 0!
버튼의 클릭 핸들러가 React에 지시하는 작업:
setNumber(number + 1): 숫자는 0이므로 setNumber(0 + 1).setNumber(number + 1): 숫자는 0이므로 setNumber(0 + 1).setNumber(number + 1): 숫자는 0이므로 setNumber(0 + 1).setNumber(number + 1)를 세 번 호출했지만, 이 렌더링의 이벤트 핸들러에서 number는 항상 0이므로 상태를 1로 세 번 설정한 것. 이것이 이벤트 핸들러가 완료된 후 React가 컴포넌트를 3이 아닌 1로 다시 렌더링하는 이유!
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
alert(number);
}}>+5</button>
</>
)
}
// result: 0 -> 10 -> 15
setTimeout으로 delay를 준다면?
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setTimeout(() => {
alert(number);
}, 3000);
}}>+5</button>
</>
)
}
// result: 0 -> 10 -> 15
최초에 버튼을 클릭한 시점에 alert에 전달된 number state의 '스냅샷'은 0!
alert가 실행될 때 React에 저장된 상태는 변경되었을 수 있지만, 사용자가 상호작용한 시점의 상태 스냅샷을 사용하여 예약됨!
상태 변수의 값은 이벤트 핸들러의 코드가 비동기적이더라도 렌더링 내에서 절대 변경되지 않음. 해당 렌더링의 onClick 내에서 number의 값은 setNumber(number + 5)가 호출된 후에도 계속 0으로 유지. 이 값은 컴포넌트를 호출해 React가 UI의 스냅샷을 '가져올' 때 '고정'된 값.
다음은 이벤트 핸들러가 타이밍 실수를 줄이는 방법을 보여주는 예.
import { useState } from 'react';
export default function Form() {
const [to, setTo] = useState('Alice');
const [message, setMessage] = useState('Hello');
function handleSubmit(e) {
e.preventDefault();
setTimeout(() => {
alert(`You said ${message} to ${to}`);
}, 5000);
}
return (
<form onSubmit={handleSubmit}>
<label>
To:{' '}
<select
value={to}
onChange={e => setTo(e.target.value)}>
<option value="Alice">Alice</option>
<option value="Bob">Bob</option>
</select>
</label>
<textarea
placeholder="Message"
value={message}
onChange={e => setMessage(e.target.value)}
/>
<button type="submit">Send</button>
</form>
);
}
'Send' 버튼을 눌러 Alice에게 "Hello"를 보내고, 5초 지연이 끝나기 전에 "To" 필드의 값을 "Bob"으로 변경하면?
결과: 'You said Hello to Alice'
React는 하나의 렌더링 이벤트 핸들러 내에서 상태 값을 '고정'으로 유지하므로, 코드가 실행되는 동안 상태가 변경되었는지 걱정할 필요 없음. 하지만 다시 렌더링하기 전에 최신 상태를 읽고 싶다면? 다음 챕터에서 다룰 상태 업데이터 함수 사용!
상태 변수를 설정하면 다른 렌더링이 대기열에 추가됨. 하지만 다음 렌더링을 대기열에 넣기 전에 값에 대해 여러 연산을 수행하고 싶다면? 이를 위해서 React가 상태 업데이트를 일괄 처리하는 방법을 이해하는 것이 도움이 됨.
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>
</>
)
}
버튼을 한 번 클릭할 때 number는 1씩 증가!
React는 상태 업데이트를 처리하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 기다림. 이것이 바로 모든 setNumber() 호출 이후에만 리렌더링이 일어나는 이유!
식당에서 주문을 받는 웨이터는 첫 번째 요리가 나오자마자 주방으로 달려가지 않고, 주문이 끝날 때까지 기다렸다가 주문을 변경하고 테이블에 있는 다른 사람의 주문도 받음.

이렇게 하면 너무 많은 리렌더링을 트리거하지 않고도 여러 컴포넌트에서 여러 상태 변수를 업데이트할 수 있음. '일괄 처리'라고도 하는 이 동작은 React 앱을 훨씬 빠르게 실행할 수 있게 해줌. 또한 일부 변수만 업데이트된 혼란스러운 '반쯤 완성된' 렌더링을 처리하지 않아도 됨. 하지만 이는 이벤트 핸들러와 그 안에 있는 코드가 완료될 때까지 UI가 업데이트되지 않는다는 의미이기도 함.
React는 클릭과 같은 여러 의도적인 이벤트에 대해서는 일괄 처리하지 않으며, 각 클릭은 개별적으로 처리됨. React는 일반적으로 안전한 경우에만 일괄 처리를 수행하므로 안심할 것! 예를 들어 첫 번째 버튼 클릭으로 양식이 비활성화되면 두 번째 클릭으로 양식이 다시 제출되지 않도록 보장함.
흔한 상황은 아니지만, 다음 렌더링 전에 동일한 상태 변수를 여러 번 업데이트하고 싶다면 setNumber(number + 1)처럼 다음 상태 값을 전달하는 대신, setNumber(n => n + 1)처럼 대기열의 이전 상태를 기반으로 다음 상태를 계산하는 함수를 전달할 수 있음. 이는 단순히 값을 바꾸는 것이 아니라 React에게 '상태 값으로 무언가를 하라'고 지시하는 방법.
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
alert(number)
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
alert(number)
}}>+3</button>
</>
)
}
버튼을 한 번 클릭할 때 number는 3씩 증가!
alert: 0, 0 -> 3, 3 -> 6, 6 ...
여기서 n => n + 1을 업데이터 함수라고 하고, 이를 state setter에게 전달하면:
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
위 코드를 포함하는 이벤트 핸들러를 실행하는 동안 React는:
setNumber(n => n + 1): n => n + 1은 함수. React는 이를 큐에 추가.
setNumber(n => n + 1): n => n + 1은 함수. React는 이를 큐에 추가.
setNumber(n => n + 1): n => n + 1은 함수. React는 이를 큐에 추가.
다음 렌더링 중에 useState를 호출하면 React는 큐에 쌓인 요청을 모두 실행. number의 이전 상태는 0이었으므로 React는 이를 첫 번째 업데이터 함수의 인수 n에 전달. 그런 다음 React는 이전 업데이터 함수의 반환값을 가져와서 다음 업데이터에 n으로 전달...

React는 3을 최종 결과로 저장하고, useState에서 반환. 이것이 위 예제에서 "+3"을 클릭하면 값이 3씩 올바르게 증가하는 이유!
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>Increase the number</button>
</>
)
}
이벤트 핸들러를 실행하는 동안 React는:
setNumber(number + 5): number가 0이므로 setNumber(0 + 5). React는 대기열에 "5로 변경"을 추가.setNumber(n => n + 1): n => n + 1은 업데이터 함수. React는 해당 함수를 대기열에 추가.다음 렌더링 중에 useState를 호출하면 React는 큐에 쌓인 요청을 모두 실행.

React는 3을 최종 결과로 저장하고, useState에서 반환.
참고!
setState(5)는setState(n => 5)처럼 작동하지만 n이 사용되지 않을 뿐!
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42);
}}>Increase the number</button>
</>
)
}
이벤트 핸들러를 실행하는 동안 React는:
setNumber(number + 5): number가 0이므로 setNumber(0 + 5). React는 대기열에 "5로 변경"을 추가.setNumber(n => n + 1): n => n + 1은 업데이터 함수. React는 해당 함수를 대기열에 추가.setNumber(42): React는 대기열에 "42로 변경"을 추가.
React는 42를 최종 결과로 저장하고, useState에서 반환.
[요약]
setter 함수에:
이벤트 핸들러가 완료되면 React는 다시 렌더링을 트리거. 다시 렌더링하는 동안 React는 대기열을 처리.
업데이터 함수는 렌더링 중에 실행되므로 업데이터 함수는 순수해야 하고, 결과만 반환해야 함. 업데이트 함수 내부에서 상태를 변경하거나, 사이드 이펙트 코드를 실행해서는 안됨. Strict Mode에서 React는 각 업데이터 함수를 두 번 실행(두 번째 결과는 버림)하여 실수를 찾을 수 있도록 도와줌.
업데이터 함수의 인수 이름은 해당 상태 변수의 첫 글자로 지정하는 것이 일반적
setEnabled(e => !e);
setLastName(ln => ln.reverse());
setFriendCount(fc => fc * 2);
좀 더 자세한 코드를 선호하는 경우, 또 다른 일반적인 규칙은 setEnabled(enabled => !enabled)와 같이 전체 상태 변수 이름을 반복하거나 setEnabled(prevEnabled => !prevEnabled)와 같은 접두사를 사용하는 것.
상태는 객체를 포함한 모든 종류의 자바스크립트 값을 저장할 수 있음. 하지만 React state에 있는 객체를 직접 변경해서는 안됨. 대신 객체를 업데이트하려면 새 객체를 생성하거나 기존 객체의 복사본을 만들어 사용하도록 state를 설정해야 함.
const [x, setX] = useState(0);
setX(5);
number, string, boolean과 같은 기본 타입 값은 '불변(immutable)', 즉 변경할 수 없거나 '읽기 전용'인 값. 재렌더링을 트리거하여 새로운 값으로 '대체(replacement)'할 수 있음. x 상태가 0에서 5로 변경되었지만 숫자 0 자체는 변경되지 않았음.
const [position, setPosition] = useState({ x: 0, y: 0 });
position.x = 9;
JavaScript 객체는 '변경(mutation)'할 수 있음. 하지만 React의 state로 설정된 객체 값은 기술적으로 변경 가능하더라도 number, string, boolean처럼 '불변(immutablee)'값으로 취급해야 함. 그러므로 '변경(mutation)' 대신, 항상 새로운 값으로 '대체(replacement)'해야 함.
즉, 상태로 설정된 JavaScript 객체는 읽기 전용(read only)으로 취급해야 함.
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',
}}>
<span style={{
position: 'absolute',
backgroundColor: 'red',
borderRadius: '50%',
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}} />
</span>
);
}
이 예제는 현재 마우스 포인터 위치를 나타내는 객체를 상태로 관리함. 빨간색 점은 마우스 포인터와 함께 이동되어야하지만 처음 위치에서 움직이지 않음.
원인: 상태 업데이트 함수를 사용하지 않으면 React는 객체가 변경된 것을 알 수 없음. 따라서 React는 아무런 응답도 하지 않는 것! 상태 변경이 때때로 작동할 수 있다 하더라도 React에서 권장되는 방법은 아님. 렌더링 중에 접근할 수 있는 상태 값은 읽기 전용으로 취급해야 함!
<div
onPointerMove={e => {
// 새로운 상태 값으로 설정할 position 객체 갱성
const newPosition = { x: e.clientX, y: e.clientY };
// 컴포넌트 리-렌더링 트리거(요청)
setPosition(newPosition);
}}
>
{/* ... */}
</div>```
해결: 실제로 재렌더링을 트리거하려면 **새 객체를 만든 후 상태 업데이트 함수에 전달**해야함.
### Copying objects with the spread syntax
이전 예제에서 `position` 객체는 항상 현재 커서 위치에서 새로 만들어짐. 그러나 기존 데이터를 새로 만드는 객체의 일부로 포함시키고 싶다면, [전개 구문(...)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#spread_in_object_literals)를 사용할 수 있음.
```jsx
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>
</>
);
}
규모가 큰 form의 경우 각 입력 필드에 대해 별도의 state 변수를 선언하는 것 보다, 모든 데이터를 객체에 그룹화하여 보관하는 것이 훨씬 편리!
단, 전개 구문 “얕은 복사”를 수행함. 얕은 복사는 속도가 빠르지만, 중첩된 속성을 업데이트 하려면 2번 이상 전개 구문을 사용해야 함.
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의 값을 업데이트하고싶다면,
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!
}
});
처럼 코드를 작성해야함.
state가 깊게 중첩되어 있는 경우 평면화(flatten) 할 수 있음. 하지만 상태 구조를 변경하고 싶지 않다면 중첩 전개구문 대신 Immer 라이브러리를 사용할 수도 있음. Immer는 '변경 가능'한 구문을 사용하면 사본 생성을 자동으로 처리해줌. Immer를 사용하면 코드가 "규칙을 깨고" 객체를 변경하는 것처럼 보임.
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
하지만 Immer는 일반 뮤테이션과 달리, 이전 state를 수정하지 않고 새로운 상태 값으로 '대체(replacement)'함!
자바스크립트에서는 배열을 변경할 수 있지만 state에 저장할 때는 변경할 수 없는 것으로 취급해야 함. 객체와 마찬가지로 state에 저장된 배열을 업데이트하려면 새 배열을 생성하거나 기존 배열의 복사본을 만들어 사용하도록 state를 설정해야 함.
자바스크립트에서 배열은 또 다른 종류의 객체일 뿐! 객체와 마찬가지로 React의 state 배열은 읽기 전용으로 취급해야 함. 즉, arr[0] = 'bird'와 같이 배열 내부의 항목을 재할당해서는 안 되며, push() 및 pop()과 같이 배열을 변경하는 메서드도 사용해서는 안 됨.
대신 배열을 업데이트할 때마다 state setter 함수에 새 배열을 전달해야 함. 이렇게 하려면 filter() 및 map()과 같은 비변환(non-mutating) 메서드를 호출하여 state의 원래 배열로부터 새 배열고, state를 새 배열로 업데이트 해야함.
또는, 두 종류의 메서드들을 모두 사용할 수 있는 Immer를 사용하는 방법도 있음.
주의!
slice와splice는 이름이 비슷하지만 매우 다름.
slice는 배열 또는 배열의 일부를 복사splice는 배열을 변경(항목을 삽입하거나 삭제).
React에서는 state 객체나 배열을 변경하지 않아야 하므로slice를 훨씬 더 자주 사용하게 될 것!
push()는 배열을 변경함 ❌
import { useState } from 'react';
let nextId = 0;
export default function List() {
const [name, setName] = useState('');
const [artists, setArtists] = useState([]);
return (
<>
<h1>Inspiring sculptors:</h1>
<input
value={name}
onChange={e => setName(e.target.value)}
/>
<button onClick={() => {
artists.push({
id: nextId++,
name: name,
});
}}>Add</button>
<ul>
{artists.map(artist => (
<li key={artist.id}>{artist.name}</li>
))}
</ul>
</>
);
}
대신, 기존 항목과 끝에 새 항목을 포함하는 새 배열을 생성할 수 있음. 가장 쉬운 방법은 ... 배열 스프레드 구문을 사용하는 것.
import { useState } from 'react';
let nextId = 0;
export default function List() {
const [name, setName] = useState('');
const [artists, setArtists] = useState([]);
return (
<>
<h1>Inspiring sculptors:</h1>
<input
value={name}
onChange={e => setName(e.target.value)}
/>
<button onClick={() => {
setArtists([
...artists,
{ id: nextId++, name: name }
]);
}}>Add</button>
<ul>
{artists.map(artist => (
<li key={artist.id}>{artist.name}</li>
))}
</ul>
</>
);
}
배열 스프레드 구문을 사용하면 원본 배열의 복사본 앞에 항목을 추가할 수도 있음.
setArtists([
{ id: nextId++, name: name },
...artists // Put old items at the end
]);
이런 식으로 스프레드는 배열의 끝에 항목을 추가하는 push()와 배열의 시작 부분에 항목을 추가하는 unshift()의 기능을 모두 수행할 수 있음.
배열에서 항목을 제거하는 가장 쉬운 방법은 필터링! 즉, 해당 항목을 포함하지 않는 새 배열을 생성. 이렇게 하려면 filter() 메서드를 사용하면 됨.
import { useState } from 'react';
let initialArtists = [
{ id: 0, name: 'Marta Colvin Andrade' },
{ id: 1, name: 'Lamidi Olonade Fakeye'},
{ id: 2, name: 'Louise Nevelson'},
];
export default function List() {
const [artists, setArtists] = useState(
initialArtists
);
return (
<>
<h1>Inspiring sculptors:</h1>
<ul>
{artists.map(artist => (
<li key={artist.id}>
{artist.name}{' '}
<button onClick={() => {
setArtists(
artists.filter(a =>
a.id !== artist.id
)
);
}}>
Delete
</button>
</li>
))}
</ul>
</>
);
}
여기서 artists.filter(a => a.id !== artist.id)는 "artist.id와 다른 ID를 가진 아티스트로 구성된 배열을 생성한다"는 의미. 즉, 각 아티스트의 "Delete" 버튼을 누르면 원본 배열에서 해당 아티스트를 필터링한 새로운 배열로 다시 렌더링하도록 요청. filter()는 원본 배열을 수정하지 않음!
배열의 일부 또는 모든 항목을 변경하려면 map()을 사용하여 새 배열을 만들 수 있음. map에 전달할 함수는 데이터 또는 인덱스(또는 둘 다)에 따라 각 항목에 대해 수행할 작업을 결정.
import { useState } from 'react';
let initialShapes = [
{ id: 0, type: 'circle', x: 50, y: 100 },
{ id: 1, type: 'square', x: 150, y: 100 },
{ id: 2, type: 'circle', x: 250, y: 100 },
];
export default function ShapeEditor() {
const [shapes, setShapes] = useState(
initialShapes
);
function handleClick() {
const nextShapes = shapes.map(shape => {
if (shape.type === 'square') {
// No change
return shape;
} else {
// Return a new circle 50px below
return {
...shape,
y: shape.y + 50,
};
}
});
// Re-render with the new array
setShapes(nextShapes);
}
return (
<>
<button onClick={handleClick}>
Move circles down!
</button>
{shapes.map(shape => (
<div
key={shape.id}
style={{
background: 'purple',
position: 'absolute',
left: shape.x,
top: shape.y,
borderRadius:
shape.type === 'circle'
? '50%' : '',
width: 20,
height: 20,
}} />
))}
</>
);
}
위 예에서 state 배열인 shapes는 두 개의 원과 하나의 정사각형의 좌표를 포함. 버튼을 누르면 map()을 사용해 새로운 데이터 배열을 생성하고, 원만 50픽셀 아래로 이동함.
배열에서 하나 이상의 항목을 바꾸고 싶은 경우, arr[0] = 'bird'와 같은 할당은 원래 배열을 변경하는 것이므로 대신 map을 사용하는 것이 좋음.
map 호출 내에서 두 번째 인수로 항목의 인덱스를 받게 되는데, 이를 사용하여 원래 항목(첫 번째 인수)을 반환할지 바뀐 다른 항목을 반환할지 결정.
import { useState } from 'react';
let initialCounters = [
0, 0, 0
];
export default function CounterList() {
const [counters, setCounters] = useState(
initialCounters
);
function handleIncrementClick(index) {
const nextCounters = counters.map((c, i) => {
if (i === index) {
// Increment the clicked counter
return c + 1;
} else {
// The rest haven't changed
return c;
}
});
setCounters(nextCounters);
}
return (
<ul>
{counters.map((counter, i) => (
<li key={i}>
{counter}
<button onClick={() => {
handleIncrementClick(i);
}}>+1</button>
</li>
))}
</ul>
);
}
시작도 끝도 아닌 특정 위치에 항목을 삽입하고 싶은 경우 ... 배열 스프레드 구문을 slice() 메서드와 함께 사용할 수 있음. slice() 메서드를 사용하면 배열의 "슬라이스"를 잘라낼 수 있음. 항목을 배열의 중간에 삽입하려면 삽입 지점 앞에 슬라이스를 펼치고, 새 항목을 삽입하고, 남은 원래 배열을 펼치는 새로운 배열을 만들면 됨.
import { useState } from 'react';
let nextId = 3;
const initialArtists = [
{ id: 0, name: 'Marta Colvin Andrade' },
{ id: 1, name: 'Lamidi Olonade Fakeye'},
{ id: 2, name: 'Louise Nevelson'},
];
export default function List() {
const [name, setName] = useState('');
const [artists, setArtists] = useState(
initialArtists
);
function handleClick() {
const insertAt = 1; // Could be any index
const nextArtists = [
// Items before the insertion point:
...artists.slice(0, insertAt),
// New item:
{ id: nextId++, name: name },
// Items after the insertion point:
...artists.slice(insertAt)
];
setArtists(nextArtists);
setName('');
}
return (
<>
<h1>Inspiring sculptors:</h1>
<input
value={name}
onChange={e => setName(e.target.value)}
/>
<button onClick={handleClick}>
Insert
</button>
<ul>
{artists.map(artist => (
<li key={artist.id}>{artist.name}</li>
))}
</ul>
</>
);
}
반전, 정렬과 같이 스프레드 구문과 map(), filter()와 같은 비변환 메서드만으로는 할 수 없는 일도 있음. 자바스크립트의 reverse() 와 sort() 메서드는 원래 배열을 변경하므로 직접 사용할 수 없음.
대신, 배열을 먼저 복사한 후 복사한 배열을 변경할 수 있음.
import { useState } from 'react';
let nextId = 3;
const initialList = [
{ id: 0, title: 'Big Bellies' },
{ id: 1, title: 'Lunar Landscape' },
{ id: 2, title: 'Terracotta Army' },
];
export default function List() {
const [list, setList] = useState(initialList);
function handleClick() {
const nextList = [...list];
nextList.reverse();
setList(nextList);
}
return (
<>
<button onClick={handleClick}>
Reverse
</button>
<ul>
{list.map(artwork => (
<li key={artwork.id}>{artwork.title}</li>
))}
</ul>
</>
);
}
스프레드 구문을 사용하여 먼저 원본 배열의 복사본을 만들고, 복사된 배열에 reverse() 또는 sort()와 같은 메서드를 사용하거나 nextList[0] = "something"으로 개별 항목을 할당할 수도 있음.
하지만 배열을 복사하더라도 그 안에 있는 항목을 직접 변경할 수는 없음. 복사는 얕은 수준이기 때문에, 새 배열은 원본 배열과 동일한 항목들로 구성되어 있음. 따라서 복사된 배열 내부의 객체를 수정하면 기존 상태를 변경하는 것!
const nextList = [...list];
nextList[0].seen = true; // Problem: mutates list[0]
setList(nextList);
nextList와 list는 서로 다른 두 배열이지만, nextList[0]과 list[0]은 같은 객체를 가리킴. 따라서 nextList[0].seen을 변경하면 list[0].seen도 변경되고, 이는 상태 변이이므로 피해야 함! 중첩된 JavaScript 객체를 업데이트할 때와 비슷한 방법으로 이 문제를 해결할 수 있는데, 변경하려는 개별 항목을 변경하는 대신 복사하는 것.
객체는 실제로 배열의 "내부"에 위치하지 않음. 코드에서는 "내부"에 있는 것처럼 보일 수 있지만 배열의 각 객체는 배열이 "가리키는" 별도의 값. 그렇기 때문에 list[0]과 같이 중첩된 필드를 변경할 때 주의해야 함.
중첩된 상태를 업데이트할 때는 업데이트하려는 지점부터 최상위 수준까지 복사본을 만들어야 함.
import { useState } from 'react';
let nextId = 3;
const initialList = [
{ id: 0, title: 'Big Bellies', seen: false },
{ id: 1, title: 'Lunar Landscape', seen: false },
{ id: 2, title: 'Terracotta Army', seen: true },
];
export default function BucketList() {
const [myList, setMyList] = useState(initialList);
const [yourList, setYourList] = useState(
initialList
);
function handleToggleMyList(artworkId, nextSeen) {
const myNextList = [...myList];
const artwork = myNextList.find(
a => a.id === artworkId
);
artwork.seen = nextSeen;
setMyList(myNextList);
}
function handleToggleYourList(artworkId, nextSeen) {
const yourNextList = [...yourList];
const artwork = yourNextList.find(
a => a.id === artworkId
);
artwork.seen = nextSeen;
setYourList(yourNextList);
}
return (
<>
<h1>Art Bucket List</h1>
<h2>My list of art to see:</h2>
<ItemList
artworks={myList}
onToggle={handleToggleMyList} />
<h2>Your list of art to see:</h2>
<ItemList
artworks={yourList}
onToggle={handleToggleYourList} />
</>
);
}
function ItemList({ artworks, onToggle }) {
return (
<ul>
{artworks.map(artwork => (
<li key={artwork.id}>
<label>
<input
type="checkbox"
checked={artwork.seen}
onChange={e => {
onToggle(
artwork.id,
e.target.checked
);
}}
/>
{artwork.title}
</label>
</li>
))}
</ul>
);
}
위 예에서는 두 개의 개별 아트웍 목록의 초기 상태가 동일. 두 목록은 분리되어야 하지만 mutation으로 인해 상태가 실수로 공유되어 한 목록의 상자를 선택하면 다른 목록에 영향을 미침.
myNextList 배열 자체는 새 배열이지만 항목 자체는 원래 myList 배열과 동일. 따라서 artwork.seen을 변경하면 원본 아트웍 항목이 변경됨. 해당 아트웍 항목이 yourList에도 있으므로 버그가 발생. 이와 같은 버그는 생각하기 어려울 수 있지만, 다행히도 상태가 변하지 않도록 하면 사라짐!
import { useState } from 'react';
let nextId = 3;
const initialList = [
{ id: 0, title: 'Big Bellies', seen: false },
{ id: 1, title: 'Lunar Landscape', seen: false },
{ id: 2, title: 'Terracotta Army', seen: true },
];
export default function BucketList() {
const [myList, setMyList] = useState(initialList);
const [yourList, setYourList] = useState(
initialList
);
function handleToggleMyList(artworkId, nextSeen) {
setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// Create a *new* object with changes
return { ...artwork, seen: nextSeen };
} else {
// No changes
return artwork;
}
}));
}
function handleToggleYourList(artworkId, nextSeen) {
setYourList(yourList.map(artwork => {
if (artwork.id === artworkId) {
// Create a *new* object with changes
return { ...artwork, seen: nextSeen };
} else {
// No changes
return artwork;
}
}));
}
return (
<>
<h1>Art Bucket List</h1>
<h2>My list of art to see:</h2>
<ItemList
artworks={myList}
onToggle={handleToggleMyList} />
<h2>Your list of art to see:</h2>
<ItemList
artworks={yourList}
onToggle={handleToggleYourList} />
</>
);
}
function ItemList({ artworks, onToggle }) {
return (
<ul>
{artworks.map(artwork => (
<li key={artwork.id}>
<label>
<input
type="checkbox"
checked={artwork.seen}
onChange={e => {
onToggle(
artwork.id,
e.target.checked
);
}}
/>
{artwork.title}
</label>
</li>
))}
</ul>
);
}
map()을 사용하여 mutation 없이 이전 항목을 업데이트된 버전으로 대체할 수 있음.
중첩된 배열을 변이 없이 업데이트하는 작업은 객체와 마찬가지로 다소 반복적일 수 있음.