일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 타입 단언
- JavaScript
- type assertion
- useCallback
- CS
- 프로세스
- task queue
- TypeScript
- Microtask Queue
- Compound Component
- 명시적 타입 변환
- 주니어개발자
- 좋은 PR
- Event Loop
- AJIT
- Sparkplug
- React.memo
- Dockerfile
- Custom Hook
- prettier-plugin-tailwindcss
- prettier
- docker
- Render Queue
- linux 배포판
- 암묵적 타입 변환
- react
- Headless 컴포넌트
- useLayoutEffect
- useMemo
- useEffect
- Today
- Total
구리
[React] React Hook 파헤쳐보기 - useState 본문
React Hook에 대해 공부하며 정리한 글입니다. 피드백은 언제나 환영입니다.
useState
서론
Hook은 함수형 컴포넌트가 상태를 사용하거나 클래스형 컴포넌트의 생명주기 메서드를 대체하는 등의 다양한 작업을 하기 위해 추가되었다.
Hook은 클래스형 컴포넌트에서만 가능했던 state, ref 등의 리액트의 핵심적인 기능을 함수에서도 가능하게 만들었고, 클래스형 컴포넌트보다 간결하게 작성할 수 있다는 장점이 있다.
그러면 useState Hook에 대해 알아보자.
본론
useState란?
const [state, setState] = useState(initialState)
컴포넌트는 상호 작용의 결과로 화면의 내용을 변경해야 하는 경우가 많다. 폼에 입력하면 입력 필드가 업데이트되어야 하고, 이미지 캐러셀에서 “다음”을 클릭할 때 표시되는 이미지가 변경되어야 한다. 컴포넌트는 현재 입력값, 현재 이미지와 같은 것을 “기억”해야 한다. 이런 컴포넌트별 메모리를 state
라고 하며 useState
는 컴포넌트에서 state를 추가할 수 있는 Hook이다.
여기서 initialState
는 좀 특별한 점이 있다. initialState
는 어떤 유형의 값이든 지정할 수 있지만 함수의 경우 초기 렌더링 이후 무시된다.
만약 아래처럼, 함수 반환값을 초기값으로 설정했다면 모든 렌더링에서 해당 함수를 호출하게 된다. 함수가 무거운 연산을 한다면 메모리 낭비로 이어질 수 있다.
function TodoList() {
const [todos, setTodos] = useState(createInitialTodos());
// ...
하지만 아래처럼 함수 그자체를 전달하는 경우, React는 초기화 중에만 함수를 호출한다. 따라서 리렌더링이 되어도 연산 비용을 줄일 수 있다.
function TodoList() {
const [todos, setTodos] = useState(createInitialTodos);
// ...
이처럼 초기값에 함수를 넘기는 것을 게으른 초기화(lazy initailization
)이라고 하며 웹 스토리지에 대한 접근, 배열에 대한 접근 등 무거운 연산을 포함해 실행 비용이 많이 드는 경우에 사용하는 것이 좋다.
함수형 컴포넌트에서 변경된 state를 어떻게 기억할까?
클래스형 컴포넌트는 render()
메서드를 통해 상태 변경을 감지할 수 있지만 함수형 컴포넌트는 렌더링 발생시 함수 자체가 다시 호출되기에 이전 상태를 기억하고 있어야 한다. useState
는 클로저를 통해 이를 해결한다.
아래 JsConf에서의 예시를 보면 초기값인 initialValue
을 Parameter로 받아 hooks
의 요소에 저장한다. 이제 hooks
의 각 요소는 함수 내부의 state
, setState
로만 참조가 가능하다. 클로저를 통해 함수가 선언될 당시 값을 기억하여 사용하는 원리다.
그리고 React에선 배열과 인덱스를 통해 여러 개의 useState를 독립된 상태로 관리할 수 있게된다.
const MyReact = (() => {
let hooks = [];
let idx = 0;
function useState(initialValue) {
let state = hooks[idx] || initialValue;
let _idx = idx;
let setState = newValue => {
hooks[_idx] = newValue;
};
idx++; // 다음 useState 를 위해
return [state, setState];
}
function render(Component) {
idx = 0; // 렌더마다 모든 useState를 순회하도록
let C = Component();
C.render();
return C;
}
return { useState, render };
})();
function Component() {
const [count, setCount] = MyReact.useState(0);
const [text, setText] = MyReact.useState('Hello');
return {
render: () => {
console.log({ count, text });
},
click: () => {
setCount(count + 1);
},
type: input => {
setText(input);
},
};
}
var App = MyReact.render(Component);
App.click();
App = MyReact.render(Component); // { count: 1, text: 'Hello' }
App.type('Bye!');
App = MyReact.render(Component); // { count: 1, text: 'Bye!'}
state를 변경하려면 const가 아니라 let을 사용해야 하지 않을까?
state를 변경하기 위해선 set
함수가 아닌 state
를 직접 변경시키는 것도 가능하지 않을까?
아래 코드를 실행하면 콘솔창에서는 val state
가 변경되는 것을 확인할 수 있지만 화면에서는 여전히 1을 보여주고 있다.
import { useState } from 'react';
export default function Test() {
let [val, setVal] = useState(1);
return (
<div>
<button
onClick={() => {
val = val + 1;
console.log(val);
}}
>
change it
</button>
<p> {val}</p>
</div>
);
}
이는 state 상태 변경을 React가 감지하지 못했기 때문이다. set
함수는 state를 다른 값으로 업데이트하고 리렌더링을 촉발하게 된다. (자세한건 React 동작 원리를 알아야 하지만 글이 길어질 것 같아 다른 글에서 다루도록 할 것이다.)
따라서 set 함수를 사용한 경우에만 state가 변경되고 리렌더링을 통해 함수형 컴포넌트가 재실행되어 useState를 통해 변경된 state를 받아 화면에 보여주게 되는 것이다.
즉, const로 선언함으로 state 변수를 직접 수정하는 것을 방지하고, setState를 사용하게 하기 위함이 const로 선언되는 이유라고 할 수 있겠다.
조건문, 반복문에서 hook을 호출하면 안되는 이유
위에서도 봤듯이 Hook은 순서대로 배열에 저장된다. 만약 최상위 레벨이 아닌 조건문이나, 반복문, 중첩 함수에서 Hook을 사용한다면 맨 처음 함수가 실행될 때 저장되었던 순서와 맞지 않게 된다. 따라서 최초에 저장되었던 Hook의 상태 테이블에서 다른 상태 값을 참조하게 되는 버그를 유발할 수 있다. Hook의 상태 테이블은 useState 내부가 아닌 외부 상태를 참조하고 있기 때문이다.
오직 React 함수 내에서 Hook을 호출해야 하는 이유
Hook은 React 함수 컴포넌트가 상태를 가질 수 있게 제공하는 기능이다. 따라서 React 함수가 아닌, 일반 함수는 Hook을 저장할 수도, 위치 값을 알 수도 없다. 클래스 컴포넌트는 상태가 변경될 때 인스턴스를 새롭게 만들지 않고, render 메서드를 통해 상태가 업데이트된다. 따라서 Hook의 호출 시점을 만들 수 없으므로 Hook을 사용할 수 없다. (클래스 컴포넌트에서는 훅을 사용할 이유가 없습니다
set 함수를 한 함수 내에서 여러번 실행하면 리렌더링이 여러번 이뤄질까?
결론적으로는 그렇지 않으며 그 이유를 알아보자.
React는 state를 업데이트를 하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 기다린다. 클릭과 같은 여러 의도적인 이벤트에 대해 batch를 수행하지 않으며, 각 클릭은 개별적으로 처리된다.
이벤트 핸들러가 완료되면 React는 리렌더링을 실행한다. 리렌더링하는 동안 React는 큐를 처리한다.
참고로 React는 여러 번의 state update 작업을 Queue에 몰아넣고 일정 주기마다 Queue에 등록된 작업을 순차적으로 일괄 시행하면서 불필요한 리렌더링을 방지한다.
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount(c => c + 1); // Does not re-render yet
setFlag(f => !f); // Does not re-render yet
// React will only re-render once at the end (that's batching!)
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
여기서 state 업데이트 작업을 모아 일괄 처리하는 방식을 batching이라고 한다.
참고로 React 18 버전과 이전 버전의 batch 처리 방식은 약간 다르다.
React 버전별 Batching
18 버전 이전에도 batching이 가능했지만 오직 React 이벤트 핸들러 내의 상태 업데이트만 batching을 수행했었다.
promise, setTimeout, 네이티브 이벤트 핸들러 또는 다른 이벤트들은 React에서 기본적으로 batching을 수행하지 않았다.
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
fetchSomething().then(() => {
// React 17 and earlier does NOT batch these because
// they run *after* the event in a callback, not *during* it
setCount(c => c + 1); // Causes a re-render
setFlag(f => !f); // Causes a re-render
});
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
하지만 React 18 버전 이후부터는 단순히 이벤트 핸들러 내부 뿐만이 아니라 Promise나 setTimeout, Native Event Handler 같은 작업에 대해서도 Batching 작업을 자동으로 수행하게 해주었다.
React 18 에서 제공하는 ReactDOM.createRoot
메서드를 기반으로 렌더링을 진행할 경우 모든 state update 작업은 자동으로 Batching 처리된다. 이 기능을 Automatic Batching 이라고 한다.
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
fetchSomething().then(() => {
// React 18 and later DOES batch these:
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
});
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
만약 batching을 사용하고 싶지 않다면 react-dom 라이브러리에 추가된 ReactDOM.flushSync()
메서드는 Auto Batching 을 무시하고 즉시 DOM을 렌더링해준다.
React 에서는 공식적으로 해당 메서드의 사용을 추천하진 않으며 (de-opt case), 필요한 상황이 있을 경우에만 사용할 것을 강조했다.
import { flushSync } from 'react-dom'; // Note: react-dom, not react
function handleClick() {
flushSync(() => {
setCounter(c => c + 1);
});
// React has updated the DOM by now
flushSync(() => {
setFlag(f => !f);
});
// React has updated the DOM by now
}
결론
- useState는 상호작용의 결과를 기억하고 보여주기 위해 사용한다.
- useState는 클로저를 통해 함수형 컴포넌트에서도 state를 기억하고 관리할 수 있게 된다.
- React는 batch를 통해 set 함수를 한 함수 내에서 여러번 실행할 경우 불필요한 리렌더링을 방지한다.
참고 자료
'React' 카테고리의 다른 글
[React] React 성능 개선의 여정 (144ms에서 61ms까지) (1) | 2024.11.24 |
---|---|
[React] React Hook 파헤쳐보기 - useEffect (2) | 2024.11.08 |
React 동작원리(Virtual DOM과 Fiber) (1) | 2024.11.03 |
[React] 변경에 유연한 Headless 컴포넌트 만들기 (0) | 2024.01.16 |
[Webpack] 모듈 번들러, 그리고 ESBuild를 통한 빌드 속도 개선 (1) (0) | 2023.09.21 |