[React 19] 공식문서 톺아보기 - 상호작용성 더하기
목차
1. 이벤트에 응답하기
JSX에 이벤트 핸들러를 추가할 수 있다. 이벤트 핸들러는 클릭, 마우스 호버, 폼 인풋 포커스 등 사용자 상호작용에 따라 유발되는 사용자 정의 함수이다.
이벤트 핸들러를 전달하는 방법
이벤트 핸들러 추가를 위해서는 먼저 함수를 정의하고 이를 적절한 JSX 태그에 prop 형태로 전달해야 한다.
- 버튼내에 함수를 선언
- 함수 로직을 구현
<button>
JSX에onClick={handleClick}
을 추가
export default function Button() {
function handleClick() {
alert('You clicked me!');
}
return <button onClick={handleClick}>Click me</button>;
}
export default function Button() {
function handleClick() {
alert('You clicked me!');
}
return <button onClick={handleClick}>Click me</button>;
}
이벤트 핸들러 함수 특징
- 컴포넌트 내부에서 정의한다.
- handle- 로 시작하고 이벤트 명을 붙인 함수명을 가진다.
다른 방법으로, 이벤트 핸들러를 JSX 내에서 '인라인으로' 정의할 수 있다.
// 인라인으로 함수를 정의
<button onClick={function handleClick() {
alert('You clicked me!');
}}>
// 인라인으로 함수를 정의
<button onClick={function handleClick() {
alert('You clicked me!');
}}>
// 화살표함수
<button onClick={() => {
alert('You clicked me!');
}}>
// 화살표함수
<button onClick={() => {
alert('You clicked me!');
}}>
호출이 아닌 ‘전달’이 되어야한다.
// good - 클릭시 실행
<button onClick={handleClick} />
// bad - 렌더링 된 시점에 실행
<button onClick={handleClick()} />
// good - 클릭시 실행
<button onClick={handleClick} />
// bad - 렌더링 된 시점에 실행
<button onClick={handleClick()} />
- 함수를 전달하여 사용자가 버튼을 클릭했을때만 함수를 호출하도록 한다.
- 호출한 방식으로 전달한 경우
- 렌더링 과정중에 클릭이 없었음에도 ()를 통해서 함수를 실행하도록 만든다.
- jsx는
{}
내에 자바스크립트가 즉시 실행되기 때문이다.
이벤트 핸들러 Props 명명하기
<button>
과 <div>
같은 빌트인 컴포넌트는 onClick
과 같은 브라우저 이벤트 이름만을 지원합니다. 그러나 사용자 정의 컴포넌트에서는 이벤트 핸들러 prop의 이름을 원하는 대로 명명할 수 있다.
사실 브라우저 이벤트 이름은 onclick인데 html 코드가 아니라 jsx라서 js 코드로 변환되고 예약어를 피하기 위해 onClick이 쓰인다.
관습적으로 이벤트 핸들러 prop의 이름은 on
으로 시작하여 대문자 영문으로 이어진다.
export default function App() {
return (
<Toolbar
onPlayMovie={() => alert('Playing!')}
onUploadImage={() => alert('Uploading!')}
/>
);
}
export default function App() {
return (
<Toolbar
onPlayMovie={() => alert('Playing!')}
onUploadImage={() => alert('Uploading!')}
/>
);
}
- 위와 같은 코드가 있을때 컴포넌트의 설계를 유연하게 만들기 위해 props의 이름은 어떤 동작을 수행해야 하는지 의미를 담고 있다. 어떻게 구현되는지에 대한 내용은 없는데, 이는 다른 방식으로도 동일한 동작을 트리거할 수 있게 만들 수 있는 유연성을 제공한다.
- 즉, 동작에 대한 네이밍을 통해 동일한 동작을 수행하면서 로직이 바뀌는 리팩토링이 진행되었을때 수정을 최소화해준다.
접근성을 위해 올바른 HTML 태그 사용하기
-
버튼을 구현할때
<div onClick={handleClick}>
대신<button onClick={handleClick}>
을 사용해야한다. 이는 키보드 내비게이션과 같은 빌트인 브라우저 동작을 활성화 해주기 때문이다. -
기본 브라우저 스타일링이 싫어서 링크나 다른 UI 요소처럼 보이도록 하고 싶다면 CSS를 통해 바꿔야한다.
이벤트 전파 (event propagation)
-
캡처링 단계 (Capturing Phase): 최상위 조상 요소에서 타겟 요소까지 내려오는 단계.
-
타겟 단계 (Target Phase): 이벤트가 실제 타겟 요소에 도달한 순간.
-
버블링 단계 (Bubbling Phase): 타겟 요소에서 최상위 조상 요소로 올라가는 단계.
<div
className='Toolbar'
onClick={() => {
alert('You clicked on the toolbar!');
}}
>
<button onClick={() => alert('Playing!')}>Play Movie</button>
</div>
<div
className='Toolbar'
onClick={() => {
alert('You clicked on the toolbar!');
}}
>
<button onClick={() => alert('Playing!')}>Play Movie</button>
</div>
-
버튼의
onClick
이 먼저 실행될 것이며 이후 부모인<div>
의onClick
이 뒤이어 실행된다. -
부여된 JSX 태그 내에서만 실행되는
onScroll
을 제외한 React 내의 모든 이벤트는 전파된다.
전파 멈추기
자식 컴포넌트에서 e.stopPropagation()
를 호출한다.
<div
className='Toolbar'
onClick={() => {
alert('You clicked on the toolbar!');
}}
>
<button
onClick={(e) => {
e.stopPropagation();
alert('Playing!');
}}
>
Play Movie
</button>
</div>
<div
className='Toolbar'
onClick={() => {
alert('You clicked on the toolbar!');
}}
>
<button
onClick={(e) => {
e.stopPropagation();
alert('Playing!');
}}
>
Play Movie
</button>
</div>
클릭 이벤트 기록을 위한 -Capture
-
이벤트 전파를 막더라도 onClickCapture는 실행된다.
-
클릭 뿐 아니라 -Capture 를 붙이면 실행된다.
-
일부 이벤트에 대해서만 가능하다
onClickCapture
onMouseDownCapture
onMouseUpCapture
onKeyDownCapture
onKeyUpCapture
onFocusCapture
onBlurCapture
-
이 이벤트는 캡처링 단계에서 실행되기에 이벤트 전파를 막더라도 실행된다.
<div
onClickCapture={() => {
alert('div-onClickCapture');
}}
onClick={() => {
alert('div-onClick');
}}
>
<button
onClick={(e) => {
e.stopPropagation();
alert('button-onClick_stopPropagation');
}}
>
이벤트 전파막기
</button>
<button onClick={() => alert('button-onClick')}>이벤트 전파허용</button>
</div>
<div
onClickCapture={() => {
alert('div-onClickCapture');
}}
onClick={() => {
alert('div-onClick');
}}
>
<button
onClick={(e) => {
e.stopPropagation();
alert('button-onClick_stopPropagation');
}}
>
이벤트 전파막기
</button>
<button onClick={() => alert('button-onClick')}>이벤트 전파허용</button>
</div>
- 이벤트 전파막기 버튼을 누른 경우 : div-onClickCapture → button-onClick_stopPropagation
- 이벤트 전파허용 버튼을 누른 경우 : div-onClickCapture → button-onClick → div-onClick
임의로 capture 옵션을 설정하기 - addEventListener를 이용
const div = document.querySelector('div');
const button = document.querySelector('button');
// div에 캡처링 단계 이벤트 리스너 추가
div.addEventListener(
'click',
() => {
console.log('Capture phase!');
},
true,
); // true는 캡처링 단계에서 실행되도록 설정
// button에 클릭 이벤트 리스너 추가
button.addEventListener('click', () => {
console.log('Button clicked!');
});
const div = document.querySelector('div');
const button = document.querySelector('button');
// div에 캡처링 단계 이벤트 리스너 추가
div.addEventListener(
'click',
() => {
console.log('Capture phase!');
},
true,
); // true는 캡처링 단계에서 실행되도록 설정
// button에 클릭 이벤트 리스너 추가
button.addEventListener('click', () => {
console.log('Button clicked!');
});
addEventListener에서 두번째 인자에 boolean 값을 넣게 되면 useCapture의 값으로 동작한다.
핸들러를 전달하기
<div
onClickCapture={() => {
alert('div-onClickCapture');
}}
onClick={() => {
alert('div-onClick');
}}
>
<button
onClick={(e) => {
e.stopPropagation();
alert('button-onClick_stopPropagation');
}}
>
이벤트 전파막기
</button>
<button onClick={() => alert('button-onClick')}>이벤트 전파허용</button>
</div>
<div
onClickCapture={() => {
alert('div-onClickCapture');
}}
onClick={() => {
alert('div-onClick');
}}
>
<button
onClick={(e) => {
e.stopPropagation();
alert('button-onClick_stopPropagation');
}}
>
이벤트 전파막기
</button>
<button onClick={() => alert('button-onClick')}>이벤트 전파허용</button>
</div>
- 부모에도 클릭이벤트, 자식도 클릭이벤트를 통해 파악하기 복잡한 로직을 구성하는 것보다
<div>
<button
onClick={(e) => {
e.stopPropagation();
alert('div-onClickCapture');
alert('button-onClick_stopPropagation'); // props
}}
>
이벤트 전파 막기
</button>
<button
onClick={(e) => {
e.stopPropagation();
alert('div-onClickCapture'); // props
alert('button-onClick');
alert('div-onClick'); // props
}}
>
이벤트 전파 허용
</button>
</div>
<div>
<button
onClick={(e) => {
e.stopPropagation();
alert('div-onClickCapture');
alert('button-onClick_stopPropagation'); // props
}}
>
이벤트 전파 막기
</button>
<button
onClick={(e) => {
e.stopPropagation();
alert('div-onClickCapture'); // props
alert('button-onClick');
alert('div-onClick'); // props
}}
>
이벤트 전파 허용
</button>
</div>
- 자식에게 onClick 이벤트를 전달하여 로직의 실행과정을 명시적으로 표현한다.
근데 관심사 분리를 위해서 로깅은 그냥 부모 컴포넌트에서 onClickCapture를 이용하는게 더 좋다고 생각한다.
기본동작 방지하기
// 제출시 페이지 리로드
<form onSubmit={() => alert('Submitting!')}>
<input />
<button>Send</button>
</form>
// 제출시 페이지 리로드
<form onSubmit={() => alert('Submitting!')}>
<input />
<button>Send</button>
</form>
<form
onSubmit={(e) => {
e.preventDefault(); // onSubmit의 기본동작인 페이지 리로드를 막아준다.
alert('Submitting!');
}}
>
<input />
<button>Send</button>
</form>
<form
onSubmit={(e) => {
e.preventDefault(); // onSubmit의 기본동작인 페이지 리로드를 막아준다.
alert('Submitting!');
}}
>
<input />
<button>Send</button>
</form>
- form 태그의 제출 이벤트는 페이지 전체를 리로드한다. 이때
e.preventDefault()
를 추가하여 리로드하는 기본 동작을 막는다.
2. State: 컴포넌트의 기억 저장소
-
React는 이런 종류의 컴포넌트별 메모리를 state라고 부른다.
-
컴포넌트가 렌더링 간에 어떤 정보를 기억해야할때 state 변수를 사용한다.
지역변수를 변경해도 값이 바뀌어 보이지 않는 이유
- 지역 변수는 렌더링 간에 유지되지 않는다. React는 이 컴포넌트를 두 번째로 렌더링할 때 지역 변수에 대한 변경 사항은 고려하지 않고 처음부터 렌더링한다.
- 지역 변수를 변경해도 렌더링을 일으키지 않습니다. React는 새로운 데이터로 컴포넌트를 다시 렌더링해야 한다는 것을 인식하지 못한다.
따라서 위 다음 두가지를 만족하는 useState를 사용한다.
- 렌더링 사이에 데이터를 유지합니다.
- React가 새로운 데이터로 컴포넌트를 렌더링하도록 유발합니다.
훅 hook
- 리액트에서 “use”로 시작하는 모든 함수를 훅이라고 한다.
- React가 오직 렌더링중일 때만 사용할 수 있는 특별한 함수이다.
- 훅은 최상위 수준 혹은 커스텀훅에서만 호출할 수 있다.
useState
const [something, setSomething]
과 같은 이름으로 지정하는 것이 규칙이다.
const [index, setIndex] = useState(0);
const [index, setIndex] = useState(0);
- index가 초기값 0으로 지정되며 컴포넌트가 렌더링된다.
- setIndex(index+1) 이 실행되면 렌더링을 유발하고 다음 렌더링까지는 현재값 0으로 index가 계산이 된다.
- 다음 렌더링에서는 [1, setIndex]를 반환하여 이 값으로 렌더링된다. (값이 먼저 변하고 렌더링이 이루어진다.)
리액트가 식별자 없이 어떤 state에 대한것인지 찾을수 있는 이유
- 훅의 규칙 (최상위 수준에서만 훅 호출)에 따라 훅은 항상 같은 순서로 호출되기 때문에 리액트는 호출순서에 의존하여 구분한다. 따라서 특별한 식별자 없이 호출순서가 식별자로 되어 구분한다.
state는 컴포넌트 인스턴스에 지역적이다.
- 동일한 컴포넌트를 두번 렌더링하면 각 컴포넌트의 state는 완전히 격리되며 서로 영향을 주지 않는다.
- 부모 컴포넌트가 자식 컴포넌트의 state에 대해 아무것도 알지 못하고 심지어 그것이 있는지도 모른다.
3. 렌더링 그리고 커밋
리액트의 화면 업데이트 과정입니다.
리액트는 3단계를 거쳐 화면을 업데이트합니다.
- 렌더링 트리거
- 컴포넌트 렌더링
- DOM에 커밋
1) 렌더링 트리거 - 초기 렌더링, state가 업데이트된 경우
렌더링이 발생하는 경우 1 - 초기 렌더링
import { createRoot } from 'react-dom/client';
import Image from './Image.js';
const root = createRoot(document.getElementById('root'));
root.render(<Image />);
import { createRoot } from 'react-dom/client';
import Image from './Image.js';
const root = createRoot(document.getElementById('root'));
root.render(<Image />);
- 대상 DOM 노드와 함께
createRoot
를 호출한 다음 해당 컴포넌트로render
메서드를 호출하면 초기 렌더링이 된다. - 프레임워크에서는 이러한 코드가 숨겨져있다.
렌더링이 발생하는 경우 2 - state가 업데이트된 경우
set
함수를 통해 상태를 업데이트하여 추가적인 렌더링을 트리거할 수 있다. 컴포넌트의 상태를 업데이트하면 자동으로 렌더링 대기열에 추가된다.
2) React 컴포넌트 렌더링
렌더링을 트리거한 후 React는 컴포넌트를 호출하여 화면에 표시할 내용을 파악한다. “렌더링”은 React에서 컴포넌트를 호출하는 것이다.
- 초기 렌더링에서 React는 루트 컴포넌트를 호출한다.
- 이후 렌더링에서 React는 state 업데이트가 일어나 렌더링을 트리거한 컴포넌트를 호출한다.
재귀적 단계: 업데이트된 컴포넌트가 다른 컴포넌트를 반환하면 React는 다음으로 해당 컴포넌트를 렌더링하고 해당 컴포넌트도 컴포넌트를 반환하면 반환된 컴포넌트를 다음에 렌더링하는 방식이다. 중첩된 컴포넌트가 더 이상 없을때까지 이 단계는 계속된다.
export default function Gallery() {
return (
<section>
<h1>Inspiring Sculptures</h1>
<Image />
<Image />
<Image />
</section>
);
}
function Image() {
return (
<img
src='https://i.imgur.com/ZF6s192.jpg'
alt="'Floralis Genérica' by Eduardo Catalano"
/>
);
}
export default function Gallery() {
return (
<section>
<h1>Inspiring Sculptures</h1>
<Image />
<Image />
<Image />
</section>
);
}
function Image() {
return (
<img
src='https://i.imgur.com/ZF6s192.jpg'
alt="'Floralis Genérica' by Eduardo Catalano"
/>
);
}
-
초기 렌더링 하는 동안 React는
<section>
,<h1>
그리고 3개의<img>
태그에 대한 DOM 노드를 생성한다. -
리렌더링하는 동안 React는 이전 렌더링 이후 변경된 속성을 계산한다. 다음 단계인 커밋 단계까지는 해당 정보로 아무런 작업도 수행하지 않는다.
-
업데이트된 컴포넌트가 트리에서 높은곳에 있는 경우 그 하위 컴포넌트는 모두 리렌더링 된다. 리액트는 이러한 성능 문제를 해결하기 위해
React.memo
,useMemo
,useCallback
등의 옵트인(개발자가 직접 선택적으로 적용해야 하는 방식) 방식의 최적화 방법을 제공한다.
3) React가 DOM에 변경사항을 커밋
컴포넌트를 렌더링(호출)한 후 리액트는 DOM을 수정한다.
- 초기 렌더링의 경우 React는
appendChild()
DOM API를 사용하여 생성한 모든 DOM 노드를 화면에 표시한다. - 리렌더링의 경우 React는 필요한 최소한의 작업(렌더링하는 동안 계산된 것)을 적용하여 DOM이 최신 렌더링 출력과 일치하도록 한다. 이때 React는 렌더링 간에 차이가 있는 경우에만 DOM 노드를 변경한다.
4) 브라우저 페인트
- 렌더링이 완료되고 React가 DOM을 업데이트한 후 브라우저는 화면을 다시 그린다. 이 단계를 “브라우저 렌더링”이라고 하지만 이 문서에서는 나머지 부분에서 혼동을 피하고자 “페인팅”이라고 표기했다.
실제 dom이 아닌 가상dom만 리렌더링하는건 괜찮을까?
가상 DOM에서 리렌더링이 발생하면, 리액트는 다음과 같은 작업을 수행한다.
- 컴포넌트 함수 실행:
- 컴포넌트 함수가 다시 호출되고, JSX가 반환된다.
- 이 과정에서 컴포넌트 내부의 로직(예: 계산, 조건문, 반복문 등)이 다시 실행된다.
- 가상 DOM 트리 생성:
- 컴포넌트 함수의 반환값(JSX)을 기반으로 새로운 가상 DOM 트리를 생성한다.
- 이전 가상 DOM과 비교 (Reconciliation):
- 새로운 가상 DOM 트리와 이전 가상 DOM 트리를 비교하여 변경된 부분을 찾는다.
- 이 과정은 트리의 모든 노드를 재귀적으로 탐색해야 하므로, 트리가 크면 클수록 비용이 증가한다.
- 변경된 부분만 실제 DOM에 반영:
- 변경된 부분이 없다면 실제 DOM은 업데이트 되지 않는다.
- 가상 DOM 트리를 생성하고 비교하는 과정에서 메모리를 사용한다.
- 빈번한 리렌더링은 메모리 사용량을 증가시키고, 가비지 컬렉션(Garbage Collection)의 빈도를 높일 수 있다.
- 따라서 가상 dom의 리렌더링도 비용이 많이 소모된다.
개발자 도구에서 리액트 dev tools 에서 보이는 리렌더링 프레임은 가상돔의 리렌더링을 보여준다.
memo
와useCallback
은 '가상 DOM'의 리렌더링을 최적화하는 도구이다. 하지만 이는 궁극적으로 실제 DOM 업데이트를 최소화하는 효과를 가져온다.
4. 스냅샷으로서의 State
state를 설정하면 렌더링이 동작합니다
<form
onSubmit={(e) => {
e.preventDefault();
setIsSent(true);
sendMessage(message);
}}
/>
<form
onSubmit={(e) => {
e.preventDefault();
setIsSent(true);
sendMessage(message);
}}
/>
제출버튼을 누른경우
e.preventDefault();
를 통해 form의 빌트인 기능인 새로고침이 동작하지 않는다.setIsSent(true)
가isSent
를true
로 설정하고 새로운 렌더링을 큐에 넣는다.- React는 새로운
isSent
값에 따라 컴포넌트를 다시 렌더링한다.
렌더링은 그 시점의 스냅샷을 찍습니다.
-
“렌더링” 이란 React가 컴포넌트, 즉 함수를 호출한다는 뜻이다. 해당 함수에서 반환하는 JSX는 시간상 UI의 스냅샷과 같다. prop, 이벤트 핸들러, 로컬 변수는 모두 렌더링 당시의 state를 사용해 계산된다.
-
UI “스냅샷”은 대화형이다. 여기에는 입력에 대한 응답으로 어떤 일이 일어날지 지정하는 이벤트 핸들러와 같은 로직이 포함된다.
-
React는 이 스냅샷과 일치하도록 화면을 업데이트하고 이벤트 핸들러를 연결한다. 결과적으로 버튼을 누르면 JSX의 클릭 핸들러가 발동된다.
리렌더링할때
- 리액트가 함수(컴포넌트)를 다시 호출한다.
- 함수가 새로운 jsx 스냅샷을 반환한다.
- 리액트가 반환한 스냅샷과 일치하도록 화면을 업데이트한다.
-
state는 실제로 함수 외부에 마치 선반에 있는 것처럼 React 자체에 “존재”한다.
-
React가 컴포넌트를 호출하면 특정 렌더링에 대한 state의 스냅샷을 제공한다.
-
컴포넌트는 해당 렌더링의 state 값을 사용해 계산된 새로운 props 세트와 이벤트 핸들러가 포함된 UI의 스냅샷을 JSX에 반환한다.
const [number, setNumber] = useState(0);
// const [0, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 1); // setNumber(0 + 1);
setNumber(number + 1); // setNumber(0 + 1);
setNumber(number + 1); // setNumber(0 + 1);
}}
>
+3
</button>
</>
);
const [number, setNumber] = useState(0);
// const [0, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 1); // setNumber(0 + 1);
setNumber(number + 1); // setNumber(0 + 1);
setNumber(number + 1); // setNumber(0 + 1);
}}
>
+3
</button>
</>
);
버튼을 눌렀을때 number 값이 1만 증가하는 이유
-
state를 변경하면 다음 렌더링에 대해서 변경됩니다.
-
즉 setNumber를 통해서 1이 증가된 새로운 number는 다음 렌더링에서야 적용이 된다.
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setTimeout(() => {
alert(number);
}, 3000);
}}>+5</button>
</>
)
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setTimeout(() => {
alert(number);
}, 3000);
}}>+5</button>
</>
)
-
위와 같은 경우도 실행될때의 number 값은 0이므로 3초뒤에 경고창에는 0이 뜬다.
-
경고창이 실행될 때는 state가 변경되었을 수 있지만, 사용자가 상호작용한 시점에 state 스냅샷에서는 0을 가리킨다.
-
state 변수의 값은 이벤트 핸들러의 코드가 비동기적이더라도 렌더링 내에서 절대 변경되지 않는다.
-
컴포넌트를 호출해 React가 UI의 “스냅샷을 찍을 때 고정”된 값이다.
5. state 업데이트 큐
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
-
위와 같이 세번 실행해도 같은 렌더링 안에서는 number값은 고정이기에 +3이 아닌 +1이 된다.
-
값이 바뀌지 않는 이유중 하나는 batching이다. 이 동작은 React 앱을 훨씬 빠르게 실행할 수 있게 해준다.
근본적인 원인은 클로저에 의한 값 고정이다.
-
React는 state 업데이트를 하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 기다립니다. 이 때문에 리렌더링은 모든
setNumber()
호출이 완료된 이후에만 일어난다. -
하지만 이는 이벤트 핸들러와 그 안에 있는 코드가 완료될 때까지 UI가 업데이트되지 않는다는 의미이다.
다음 렌더링 전에 동일한 state 변수를 여러 번 업데이트하기
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
-
setNumber(n => n + 1)
와 같이 이전 큐의 state를 기반으로 다음 state를 계산하는 함수를 전달할 수 있다. 이는 단순히 state 값을 대체하는 것이 아니라 React에 “state 값으로 무언가를 하라”고 지시하는 방법이다. -
여기서
n => n + 1
은 업데이터 함수(updater function)라고 부른다. 이를 state 설정자 함수에 전달 할 때,
- React는 이벤트 핸들러의 다른 코드가 모두 실행된 후에 이 함수가 처리되도록 큐에 넣는다.
- 다음 렌더링 중에 React는 큐를 순회하여 최종 업데이트된 state를 제공합니다.
- 다음 렌더링 중에
useState
를 호출하면 React는 큐를 순회합니다. - 이전
number
state는0
이었으므로 React는 이를 첫 번째 업데이터 함수에n
인수로 전달합니다. 그런 다음 React는 이전 업데이터 함수의 반환 값을 가져와서 다음 업데이터 함수에n
으로 전달하는 식으로 반복합니다.
- 다음 렌더링 중에
업데이터 함수를 쓰더라도 렌더링 도중에 state를 업데이트 하는게 아니라, 다음 렌더링에 있는 useState가 실행될때 큐에서 실행한다.
<button
onClick={() => {
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
console.log(number);
}}
>
+3
</button>
<button
onClick={() => {
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
console.log(number);
}}
>
+3
</button>
- 위와 같은 버튼이 있다고 할때 0→ 3으로 업데이트 되겠지만 콘솔에는 0이 찍힌다.
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>
setNumber(number + 5)
:number
는0
이므로setNumber(0 + 5)
이다. React는 큐에 “5
로 바꾸기” 를 추가한다.setNumber(n => n + 1)
:n => n + 1
는 업데이터 함수이다. React는 해당 함수를 큐에 추가합니다.- 업데이터 함수를 쓰지 않는
setState(5)
의 표현도 사실은setState(n => 5)
와 같이 동작하고 이것도 큐를 이용하여 업데이트한다.
- 업데이터 함수를 쓰지 않는
<button onClick={() => {
setNumber(number + 5); // 0 + 5
setNumber(n => n + 1); // 5 + 1
setNumber(number + 5); // 0 + 5
}}>
<button onClick={() => {
setNumber(number + 5); // 0 + 5
setNumber(n => n + 1); // 5 + 1
setNumber(number + 5); // 0 + 5
}}>
위와같은 경우에는 큐에 들어간 세개의 코드를 실행하면 결과는 5가 된다.
queued update | n | returns |
---|---|---|
”replace with 5 ” | 0 (unused) | 5 |
n => n + 1 | 5 | 5 + 1 = 6 |
”replace with 5 ” | 0 (unused) | 5 |
- 이벤트 핸들러가 완료되면 React는 리렌더링을 실행한다. 리렌더링하는 동안 React는 큐를 처리한다. 업데이터 함수는 렌더링 중에 실행되므로, 업데이터 함수는 순수해야 하며 결과만 반환해야 한다.
6. 객체 State 업데이트하기
state를 읽기 전용인 것처럼 다루세요
- 불변성은 변경할 수 없거나 “읽기 전용”을 의미한다.
const [position, setPosition] = useState({ x: 0, y: 0 });
position.x = 5;
const [position, setPosition] = useState({ x: 0, y: 0 });
position.x = 5;
setPosition({ x: 5, y: 0 });
setPosition({ x: 5, y: 0 });
-
위와 같이 직접 변경하는것이 가능은 하지만 직접변경하지 말고 setPosition를 이용하여 변경해야한다.
-
왜냐하면 값은 변경되지만 리액트에서 인지를 할수 없어 리렌더링이 발생하지 않기 때문이다.
지역변경 local mutation
방금 생성한 객체를 수정하는 것은 문제가 발생하지 않는다.
const newPosition = { x: 5, y: 0 };
setPosition(newPosition);
const newPosition = { x: 5, y: 0 };
setPosition(newPosition);
전개 문법 (spread syntax) 으로 객체 복사하기
객체의 특정 속성외에는 이전 값을 유지하고 싶을때 사용한다.
setPosition({ ...position, x: 5 });
setPosition({ ...position, x: 5 });
...
전개 문법은 얕은 복사이다. 이것은 참조만 복사되기 때문에 직접 변경하게 될 경우 원본도 같이 변경된다.
얕은 복사를 통해 새로운 객체를 만들고 그 객체를 직접수정하게 될경우 원본도 함께 수정된다.
const newUser = { ...user }; // 얕은 복사
newUser.profile.address = 'Busan'; // 중첩된 객체 수정과 함께 원본도 수정된다.
setUser(newUser);
const newUser = { ...user }; // 얕은 복사
newUser.profile.address = 'Busan'; // 중첩된 객체 수정과 함께 원본도 수정된다.
setUser(newUser);
중첩된 프로퍼티를 업데이트하고 싶다면 각 레벨마다 불변성을 유지하며 복사해야한다.
setUser({
...user, // 첫 번째 레벨 복사
profile: {
...user.profile, // 두 번째 레벨 복사
address: 'Busan', // 중첩된 프로퍼티 업데이트
},
});
setUser({
...user, // 첫 번째 레벨 복사
profile: {
...user.profile, // 두 번째 레벨 복사
address: 'Busan', // 중첩된 프로퍼티 업데이트
},
});
Immer 라이브러리
const [person, updatePerson] = useImmer({
name: 'Michel',
age: 33,
});
function updateName(name) {
updatePerson((draft) => {
draft.name = name;
});
}
const [person, updatePerson] = useImmer({
name: 'Michel',
age: 33,
});
function updateName(name) {
updatePerson((draft) => {
draft.name = name;
});
}
- useImmer를 통해 객체를 직접 변경하는 것처럼 사용할수도 있다.
- Immer는 내부적으로
draft
의 어느 부분이 변경되었는지 알아내어, 변경사항을 포함한 완전히 새로운 객체를 생성합니다.
리액트 공식문서에서 생각보다 Immer를 사용하도록 추천하는 것 같았다.
리액트가 state 직접 변경을 권장하지 않는 이유
- 리액트는 가상dom과 dom을 비교하여 변경사항을 확인할때 state가 절대로 변경되지 않는다면 작업이 매우 빨라진다.
- 새로운 기능들은 리액트 기능이 스냅샷처럼 다루어지는 것을 기반하여 개발된다.
- 취소,복원, 변경내역 조회 등의 기능을 구현하는데 더 쉽다.
7. 배열 State 업데이트하기
- 객체와 동일하게 직접 변경하지 말고 복사하는 방식으로 변경한다.
배열 전개 구문
setArtists(
// 아래의 새로운 배열로 state를 변경합니다.
[
...artists, // 기존 배열의 모든 항목에,
{ id: nextId++, name: name }, // 마지막에 새 항목을 추가합니다.
],
);
setArtists(
// 아래의 새로운 배열로 state를 변경합니다.
[
...artists, // 기존 배열의 모든 항목에,
{ id: nextId++, name: name }, // 마지막에 새 항목을 추가합니다.
],
);
- 객체와 비슷하게 바뀌는 원소에 대해서 배열전개 구문을 활용한다.
- 변경에 대해서 직접 변경하기보다는 배열전개를 통해 새로운 배열로 교체하는 방식을 사용한다.
setArtists(artists.filter((a) => a.id !== artist.id));
setArtists(artists.filter((a) => a.id !== artist.id));
-
항목제거 : filter를 이용하여 원본배열을 수정하지 않는다.
-
배열 변환이나 교체 : map을 사용한다.
// 항목 삽입하기
function handleClick() {
const insertAt = 1; // 모든 인덱스가 될 수 있습니다.
const nextArtists = [
// 삽입 지점 이전 항목
...artists.slice(0, insertAt),
// 새 항목
{ id: nextId++, name: name },
// 삽입 지점 이후 항목
...artists.slice(insertAt),
];
setArtists(nextArtists);
setName('');
}
// 항목 삽입하기
function handleClick() {
const insertAt = 1; // 모든 인덱스가 될 수 있습니다.
const nextArtists = [
// 삽입 지점 이전 항목
...artists.slice(0, insertAt),
// 새 항목
{ id: nextId++, name: name },
// 삽입 지점 이후 항목
...artists.slice(insertAt),
];
setArtists(nextArtists);
setName('');
}
// 순서변경
const nextList = [...list];
nextList.reverse();
setList(nextList);
// 순서변경
const nextList = [...list];
nextList.reverse();
setList(nextList);
얕은 복사 주의
const initialList = [
{ id: 0, title: 'Big Bellies' },
{ id: 1, title: 'Lunar Landscape' },
{ id: 2, title: 'Terracotta Army' },
];
const [list1, setList1] = useState(initialList);
const [list2, setList2] = useState(initialList);
const handleClick = () => {
const newList1 = [...list1]; // 얕은 복사
newList1[0].title = 'New Title'; // 객체 수정
setList1(newList1);
};
const initialList = [
{ id: 0, title: 'Big Bellies' },
{ id: 1, title: 'Lunar Landscape' },
{ id: 2, title: 'Terracotta Army' },
];
const [list1, setList1] = useState(initialList);
const [list2, setList2] = useState(initialList);
const handleClick = () => {
const newList1 = [...list1]; // 얕은 복사
newList1[0].title = 'New Title'; // 객체 수정
setList1(newList1);
};
newList1[0]
은list1[0]
과 동일한 객체를 참조한다.- 따라서,
list2[0]
도 함께 변경된다. newList1
배열 자체는 새로운 배열이지만 항목 자체는list1
,list2
와 동일하다.
setList1(
list1.map((el) => {
if (el.id === 0) {
// 변경된 *새* 객체를 만들어 반환합니다.
return { ...el, title: 'New Title' };
} else {
// 변경시키지 않고 반환합니다.
return el;
}
}),
);
setList1(
list1.map((el) => {
if (el.id === 0) {
// 변경된 *새* 객체를 만들어 반환합니다.
return { ...el, title: 'New Title' };
} else {
// 변경시키지 않고 반환합니다.
return el;
}
}),
);
- 위와 같이 변경하면 원본 객체의 수정을 막을수 있다.
- 하지만 위와 같은 경우에도 여전히 다른 원소에 대해서는 같은 객체를 가리키고 있다.
객체 배열에 대해서는 전개문법이 객체의 참조값을 복사하기 때문에 얕은 복사가 되어 복사본을 수정하더라도 원본이 수정된다.
하지만 원시값 배열에 대해서는 값 자체가 복사되어 얕은 복사라고 하더라도 독립된 복사본으로 생성된다.