새로운 리액트 공식문서가 만들어져서, 아예 공식문서를 통으로 공부해보기로 하였다.
지금 적은 내용들은 공식 문서를 그대로 적은게 아니라 필자가 몰랐거나 중요하다고 생각되어지는 것들만 적은 것이다.
Adding Interactivity
React에서 시간에 따라 변화하는 데이터를 state라 한다. 이번 챕터에선 state를 컴포넌트에 추가하고 업데이트 하는 것을 배운다.
Responding to Events
jsx에 이벤트 핸들러를 추가하기 위해선 jsx의 prop으로 함수를 전달해야 한다. 이때 함수는 미리 정의하거나 인라인으로 prop안에서 정의할 수 있다.
주의!
이벤트 핸들러에는 함수를 호출하는 것이 아니라 전달해야한다. jsx의
{}
안에 있는 코드는 즉시 실행되기 때문이다. 따라서 이벤트 핸들러에서 함수를 호출하면 매 렌더링시에 함수를 실행한다.
passing a function (correct) calling a function (incorrect) <button onClick={handleClick}/>
<button onClick={handleClick()}/>
주의!
onScroll을 제외한 모든 이벤트는 bubbling된다.
Stopping Propagation
이벤트 핸들러는 오직 이벤트 객체 하나만 인자로 받는다.
자세히 알아보기
Capture 이벤트 핸들러로 이벤트 캡쳐링시에 이벤트를 실행할 수 있다. 이벤트 캡쳐링은 실제 이벤트보다 먼저 일어나므로 자식 컴포넌트에서 stopPropagation을 해도 이벤트 캡쳐링은 실행된다.
<div
onClickCapture={() => {
/* this runs first */
}}
>
<button onClick={(e) => e.stopPropagation()} />
<button onClick={(e) => e.stopPropagation()} />
</div>
(1장에서의 설명처럼) 이벤트 핸들러는 side effects를 위한 최적의 장소다.
렌더링 함수와 달리 이벤트 핸들러는 순수
하지 않아도 된다. 따라서 state 등을 변화시키기 좋다.
State: A Component’s Memory
컴포넌트는 인터랙션의 결과로 변화해야 한다. 이때 컴포넌트가 인터랙션의 결과를 기억하는 메모리를 state라 한다.
일반 변수(regular variable)로 충분하지 않을 때
버튼을 누르면 카운트가 증가하는 코드이다.
export default function App() {
let count = 0;
function handleClick() {
count = count + 1;
}
return (
<>
<button onClick={handleClick}>count is : {count}</button>
</>
);
}
(리액트가 아니라면) 평범한 코드이지만, 이 코드는 전혀 작동하지 않는다. 그 이유는 아래 두 가지이다.
- 로컬 변수는 렌더링시마다 초기화된다. 따라서 렌더링이 진행될 때마다 count는 0이 된다. 즉, 로컬 변수의 변화가 유지 되지 못한다.
- 리액트는 로컬 변수의 변화는 알아차리지 못한다. 따라서 로컬 변수는 리액트의 렌더링을 트리거하지 못한다.
위 두 가지 문제를 해결하기 위해선 아래와 같은 해결책이 필요하다.
- 렌더링시에도 데이터가 유지 되어야한다.
- 리액트의 렌더링을 트리거해야 한다.(리렌더링 해야 한다.)
useState
훅이 위의 두 가지 역할을 한다.
- state value를 제공한다. state value는 렌더링시에도 그 값이 유지된다.
- state setter function을 제공한다. state setter function은 state value를 업데이트하고 리액트의 렌더링을 트리거한다.
훅(Hook)과 친해지기
리액트에서 use
로 시작하는 함수들을 훅이라 한다.
Hook은 렌더링시에만 사용가능한 특수한 함수다.
주의!
import문과 비슷하게 Hook은 컴포넌트나 커스텀 훅의 최상위에서 사용되어야한다. 훅을 컨디션, 루프, 중첩함수 등에서 사용하면 안된다.
State 해부
useState
는 state value와 state setter function을 반환한다.
const [count, setCount] = useState(0);
const handleClick = () => setCount(count + 1);
컴포넌트가 렌더링 될 때마다 useState는 두 가지 값을 리턴한다.
- state value (count) 유지 하고 싶은 데이터
- state setter function (setCount) state value를 업데이트하고 리액트의 렌더링을 트리거하는 함수
실제 렌더링시에는 아래와 같이 동작한다.
- 초기값을 0으로 설정했기 때문에 count는 0이 된다.
[0, setCount]
- 버튼을 클릭하면 setCount(count + 1)이 실행된다.
count가 0 이었으므로 이는 setCount(1)과 같다.
따라서 리액트는 count를 1로 기억하고 리렌더링을 한다.
[1, setCount]
- 리렌더링이 완료되면 초기값에 상관없이 count를 1이라고 기억한다. 따라서 count는 1이 된다.
리액트는 어떻게 state를 기억하는가?
useState는 초기값과 다른 값이 들어가도 특별한 액션 없이 그 상태를 계속 유지한다. 즉, 식별자 없이도 state를 기억한다는 것이다. 이것이 가능한 이유는 아래와 같다.
내부적으로 리액트의 모든 컴포넌트는 state 쌍으로 된 하나의 배열을 가지고 있다. 이 배열은 컴포넌트가 렌더링 될 때마다 변한 값을 유지한다. 따라서 다른 식별자가 없어도 state는 렌더링이 되어도 그 값을 유지할 수 있는 것이다. 이때 state는 간결한 구조를 유지하기 위해 매 렌더링마다 동일한 실행 순서에 의존한다. 식별자 대신 state의 순서를 이용하는 것이다. Hook 을 항상 최상위에서 실행한다는 규칙을 지킨다면, Hook은 항상 같은 순서로 작동되기 때문에 문제없이 작동한다.
💡 개인적인 생각으로, 리액트의 컴포넌트는 state를 클로저 변수로 가지는 내부 함수라는 생각이 든다.
State는 독립적이고 프라이빗하다.
State는 컴포넌트에 대해 독립적이다. 즉, 똑같은 컴포넌트를 두개 만들어도 컴포넌트가 가지는 state는 완전히 개별 state이다.
Props와 달리 state는 오직 state를 선언한 컴포넌트 내부에서만 관리할 수 있다.
Render and Commit
컴포넌트가 실제로 화면에 나오려면 리액트 렌더링이 먼저 일어나야 한다. 이런 렌더링 과정을 알아보자.
리액트를 식당의 종업원이라고 생각해보자. 먼저 손님에게 주문을 받고, 주문을 주방에 전달하고, 음식을 손님에게 전달한다.
이 과정이 리액트에서는 아래와 같다.
- 렌더링 트리거-(손님 주문 주방에 전달)
- 컴포넌트 렌더링-(주방에서 음식 만들기)
- DOM에 커밋-(음식을 손님에게 전달)
Step 1: 렌더링 트리거
컴포넌트가 렌더링되는 이유는 2가지가 있다.
- 컴포넌트의 첫 번째 렌더링
- 컴포넌트(혹은 그 조상)의 state가 업데이트 되었을 때
첫 번째 렌더링
리액트 app이 실행되면 첫 번째 렌더링을 진행해야한다. 첫 번째 렌더링은 createRoot를 타겟 DOM에 실행한 뒤 render 메서드를 실행하면 된다.
import Image from "./Image.js";
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root"));
root.render(<Image />);
state가 업데이트 되었을 때
첫 번째 렌더링이 일어난 후에는 set function
으로 state를 업데이트 하여 렌더링을 일으킬수 있다.
state가 업데이트되면 관련 렌더링이 queue에 들어가게 된다.
Step 2: 컴포넌트 렌더링
렌더링이 트리거되면 리액트는 무엇을 화면에 띄워야 할지 계산해야한다.
따라서 리액트는 렌더링을 위해 함수를 호출한다.
즉, 렌더링
은 함수 호출
이다.
- 첫 번째 렌더링시에, 리액트는 루트 컴포넌트를 호출한다.
- state가 업데이트 되었을 때, 리액트는 업데이트된 컴포넌트를 호출한다.
이런 과정은 방식은 재귀적으로 일어난다. 만약 리렌더링된 컴포넌트가 다른 컴포넌트를 호출한다면, 리액트는 그 컴포넌트를 호출한다. 그 컴포넌트가 다시 다른 컴포넌트를 호출한다면, 리액트는 그 컴포넌트를 호출한다. 리액트는 이런 과정을 더 이상 호출할 컴포넌트가 없을 때까지 반복한다.
주의!
렌더링은 순수한 함수 계산이어야 한다.
만약 그렇지 않으면 예상치 못한 버그가 발생하고 코드의 복잡도가 올라갈 수 있다.
리액트의 Strict Mode
는 렌더링시 함수를 2번 호출하면서 순수하지 못한 컴포넌트를 찾아낸다.
Step 3: 리액트 커밋이 DOM을 변경
리액트는 컴포넌트를 렌더링하고 나면 DOM을 수정한다.
- 첫 번째 렌더링 시, 리액트는
appendChild()
를 사용하여 모든 DOM 노드를 화면에 띄운다. - 리렌더링시, 리액트는 최소한의 연산을 사용하여 DOM을 최신 렌더링 결과와 일치하게 만든다.
리액트는 렌더링 간 차이가 있는 DOM 노드만 변경한다. 리액트는 렝더링 이전 동일한 위치에 동일한 내용을 가진 DOM을 변경하지 않는다.
Epilogue: 브라우저 페인트
리액트가 DOM을 변경하면 브라우저는 repaint 작업을 한다. 이는 브라우저의 리렌더링으로 알려져있으나, 리액트 문서에선 혼동을 피하기 위해 페인팅이라는 단어를 사용한다.
State as a Snapshot
State는 JS의 일반적인 변수처럼 보인다. 하지만, state는 snapshot처럼 행동한다. State의 변화는 이미 가지고 있던 state의 값을 변화시키는 게 아니라, 리렌더링을 트리거한다.
State를 업데이트하여 렌더링 트리거하기
일반적으론 유저가 수행한 동작이 UI를 업데이트한다. 하지만 리액트는 이와 달리 state를 업데이트하면 UI를 업데이트한다.
렌더링은 한 순간의 함수 리턴이다.
렌더링
은 리액트가 컴포넌트 -> 함수를 호출한다는 것을 의미한다.
컴포넌트에서 리턴하는 JSX는 한 순간의 스냅샷이다.
그 순간에 컴포넌트다 가진 props, event handlers, local variables
역시 렌더링 되는 순간의 state를 이용해 계산한다.
그러나 사진과 달리 React의 스냅샷은 인터랙티브하다. React의 스냅샷은 event handler와 같은 로직이 포함되기 때문이다.
컴포넌트가 리렌더링 될 때 3단계가 이루어진다.
- 리액트는 컴포넌트를 호출한다.
- 컴포넌트는 JSX 스냅샷을 리턴한다.
- 리액트는 JSX 스냅샷을 DOM에 커밋한다.
컴포넌트의 메모리로서, state는 JS의 일반적인 변수와 달리 리렌더링시에도 사라지지 않는다. State는 사실 컴포넌트 밖에 존재하기 때문이다. State는 컴포넌트 밖 선반 같은 공간에 존재하다가 리렌더링시에 컴포넌트에게 전달된다.
이를 이해하기 위한 예제를 살펴보자. 아래의 예제는 setNumber(number + 1)이 3번이나 있으므로, number가 3이 증가할 것이라고 생각할 수도 있다.
const [number, setNumber] = useState(0);
return (
<button
onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}
>
+3
</button>
);
하지만 위 예제에서 number는 오직 1만 증가한다.
state value는 오직 다음 렌더링시에만 변화된다.
첫번째 렌더링시 number는 0이다.
따라서 setNumber(number + 1)
은 setNumber(0 + 1)
과 같다.
이는 setNumber(number + 1)
을 아무리 많이 실행해도 똑같다.
위 예제에서 버튼을 누를 때 벌어지는 일은 아래와 같다.
setNumber(number + 1)
:number
는0
이고, 따라서setNumber(0 + 1)
.
- 리액트는 다음 렌더링시
number
를 1로 바꿀 준비를 한다.setNumber(number + 1)
:number
는0
이고, 따라서setNumber(0 + 1)
.
- 리액트는 다음 렌더링시
number
를 1로 바꿀 준비를 한다.setNumber(number + 1)
:number
는0
이고, 따라서setNumber(0 + 1)
.
- 리액트는 다음 렌더링시
number
를 1로 바꿀 준비를 한다.
따라서 setNumber(number + 1)
을 아무리 실행해도 number
는 1만 증가하는 것이다.
시간 경과에 따른 state
그렇다면 timer를 넣어 event가 나중에 실행되도록 하면 어떨까?
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
setTimeout(() => {
alert(number);
}, 3000);
}}
>
+5
</button>
</>
);
setTimeout에 alert가 들어있으니 해당 alert는 리렌더링 후에 실행되지 않을까? 만약 리렌더링 전에 alert가 실행되면 0을, 리렌더링 후에 실행되면 5를 출력할 것이다.
해당 alert는 3초 후에 0을 출력한다.
이는 alert안의 함수가 number
가 0일 때의 스냅샷이기 때문이다.
즉, 시간이 지나도 해당 snapshot, 렌더링에 포함된 state는 변하지 않는다.
Queueing a Series of State Updates
State의 업데이트는 다른 렌더링을 대기열에 추가(queue)한다. 그러나 다음 렌더가 대기열에 추가되기 전에 여러 작업을 수행하고 싶은 경우도 있다. 이번 장에선 리액트가 state 업데이트를 어떻게 batch 하는지 알아본다.
리액트는 state를 업데이트 하기전에 이벤트 핸들러의 모든 코드를 수행한다.
웨이터가 손님의 모든 주문을 받은 후 주방에 전달하는 것처럼, 리액트 역시 이벤트 핸들러 안의 모든 코드를 실행한 후 state를 업데이트한다. 웨이터는 주문을 하나 받을 때마다 주방에 뛰어가서 전달하지 않는다. 대신, 테이블에 있는 모든 손님들의 주문을 받은 후 한 번에 전달한다.
이 덕에 리액트는 수 많은 state가 업데이트 되어도 리렌더링이 많이 일어나지 않는다.
대신에 리액트는 이벤트 핸들러에 있는 모든 코드가 마무리 될 때까지 업데이트 되지않다.
이런 행동을 batching
이라고 한다.
이는 컴포넌트가 절반만 업데이트 되는 등의 현상을 방지하기도 한다.
렌더링이 진행되기 전에 같은 state를 여러번 업데이트 하기
일반적이진 않으나, 렌더링이 진행되기 전에 같은 state를 여러번 업데이트 하고 싶을 수 있다. 이럴 땐 다음 state를 계산하는 함수(updater function)를 전달하면 된다. 함수를 전달하면 state를 대체하는게 아니라 state를 계산을 하라고 리액트에게 말하는 것이다.
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
}}
>
+3
</button>
</>
);
버튼을 클릭하면 아래 3가지 동작이 일어날 것이다.
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
리액트는 state 대신 함수가 들어왔으니 함수를 대기열에 추가한다. n의 초기값이 0이었으므로 아래와 같이 실행된다.
queued update n returns n => n + 1
0 0 + 1 = 1 n => n + 1
1 1 + 1 = 2 n => n + 1
2 2 + 1 = 3
State 대체와 업데이트시 액션
다음 예제를 살펴보자.
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
setNumber((n) => n + 1);
}}
>
Increase the number
</button>
</>
);
위와 같이 state를 대체하는 경우와 updater 함수를 같이 사용한 상황에선 state가 어떻게 될까?
queued update n returns replace with 5
0 5 n => n + 1
5 5 + 1 = 6
결국 순서대로 실행되므로, state는 6이 된다.
Updating Objects in State
State는 객체를 포함한 JS의 모든 값을 가질 수 있다. 그러나 객체를 사용할 경우엔 객체를 수정하는 것이 아니라 새로운 객체를 만들어야 한다.
Mutation?
object의 contents 자체를 바꾸는 것을 mutation이라 한다. (그와 반대로 primitive value는 값을 바꿀 수 없으므로 replace라고 한다.)
const [position, setPosition] = useState({ x: 0, y: 0 });
position.x = 5;
위 동작은 기술적으로 전혀 문제가 없다. 하지만 리액트에선 객체를 마치 primitive value처럼 취급해야만 한다.
State를 read-only로 취급하기
객체를 primitive value로 취급한다는 것은 객체를 read-only로 취급한다는 것과 같다.
아래 예제를 살펴보자.
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>
);
<div>
태그에 onPointerMove 이벤트 핸들러가 있지만, 위 예제는 정상적으로 작동하지 않는다.
이는 onPointerMove 이벤트 때문이다.
onPointerMove={(e) => {
position.x = e.clientX;
position.y = e.clientY;
}}
위 이벤트는 가장 최근 렌더링 시 만들어진 position
state를 변경시킬 뿐이다.
따라서 리액트는 해당 객체가 변경되었는지 알 수 없다.
따라서 아래와 같이 이벤트를 변경해야한다.
onPointerMove={(e) => {
setPosition({
x: e.clientX,
y: e.clientY,
});
}}
위 예제는 리액트에게 2가지를 전달한다.
position
state를 새로운 객체로 대체할 것.- 리렌더링을 할 것.
Nested object는 사실 nesting 되어 있는 것이 아니라 다른 object를 pointing 하고 있는 것이다.
아래 두 예제는 사실 같은 예제이다.
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 obj = {
name: "Niki de Saint Phalle",
artwork: obj1,
};
Updfating Arrays in State
javescript의 array는 특별한 종류의 object이다. 따라서, object와 동일하게 mutate를 하지 말고 불변성을 지키면서 array를 사용해야 한다.