기술 블로그
함수 컴포넌트는 렌더를 캡처한다 본문
Dan Abramov 의 How Are Function Components Different from Classes? 를 실제 버그에 대입해 본 기록
증상
어드민의 포트폴리오 수정 화면에 대해 QA 단계에서 이런 이슈가 올라왔다.
- 썸네일 이미지를 올리는 동안 타이틀을 고치면, 업로드가 끝나는 순간 타이틀이 고치기 전으로 돌아간다.
- 썸네일과 로고를 연달아 올리면, 먼저 끝난 이미지가 사라진다.
- 새 글 작성 화면에서는 일어나지 않는다.
처음엔 직접 해 봐도 재현되지 않았다. 사내망에서 작은 이미지는 0.2초면 올라가서, 그 사이에 뭔가를 입력할 틈이 없었다. DevTools Network 탭에 Latency 15000ms 커스텀 스로틀링을 걸자 바로 재현됐다. 단서는 "업로드가 진행되는 동안 입력한 것만 사라진다"는 점이었다.
코드
줄이면 이렇다.
// 수정 페이지
function EditPage() {
const [draft, setDraft] = useState<FormValue | null>(null);
const form = draft ?? serverForm; // 손대기 전엔 서버 값
const setValue = (patch: Partial<FormValue>) => {
setDraft({ ...form, ...patch }); // ← 문제의 한 줄
};
return <PortfolioForm value={form} setValue={setValue} />;
}
// 폼 컴포넌트
function PortfolioForm({ value, setValue }: PortfolioFormProps) {
const handleUpload = async (field: "thumbnail" | "logo", file: File) => {
const uploaded = await uploadFile(file); // 여기서 몇 초 기다린다
setValue({ [field]: toImage(uploaded) });
};
// ...
}
새 글 화면도 같은 폼을 쓰지만, setValue 가 setForm((prev) => ({ ...prev, ...patch })) 였다. 차이는 그것뿐이었다.
Dan Abramov 의 팔로우 버튼
Dan 의 글은 클래스와 함수 컴포넌트의 차이를 팔로우 버튼 하나로 보여준다. 누르면 3초 뒤 알림을 띄운다.
// 클래스
class ProfilePage extends React.Component {
showMessage = () => alert("Followed " + this.props.user);
handleClick = () => setTimeout(this.showMessage, 3000);
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
// 함수
function ProfilePage(props) {
const showMessage = () => alert("Followed " + props.user);
const handleClick = () => setTimeout(showMessage, 3000);
return <button onClick={handleClick}>Follow</button>;
}
Dan 의 프로필에서 팔로우를 누르고, 3초 안에 Sophie 의 프로필로 넘어가면 결과가 갈린다.
- 클래스:
Followed Sophie— 틀렸다. 팔로우한 건 Dan 이다. - 함수:
Followed Dan— 맞다.
원인은 this 다. React 는 렌더할 때마다 this.props 를 최신 값으로 바꿔 끼운다. 그래서 3초 뒤의 this.props.user 는 이미 Sophie 다. 반면 함수 컴포넌트의 props 는 그 렌더 호출에 넘어온 인자라서 바뀌지 않는다. 글의 결론은 한 줄이다.
Function components capture the rendered values.
(함수 컴포넌트는 렌더된 값을 캡처한다.)
글은 state 도 마찬가지라고 이어 간다. useState 로 읽은 값도 그 렌더의 값으로 고정된다.
우리 버그는 이 이야기의 뒷면이다
팔로우 예제와 업로드 버그를 나란히 놓으면 구조가 같다.
| 팔로우 예제 | 업로드 버그 |
|---|---|
| 버튼 클릭 | 파일 선택 |
setTimeout(…, 3000) |
await uploadFile(file) |
| 기다리는 동안 프로필 변경 | 기다리는 동안 타이틀 입력 |
3초 뒤 props.user 를 읽음 |
업로드 뒤 setValue 안의 form 을 읽음 |
| 클릭한 렌더의 값 → Dan | 파일을 고른 렌더의 값 → 입력 전 타이틀 |
차례로 따라가면 이렇다.
- 파일을 고르는 순간 화면에 있던 렌더(N회차)의
handleUpload가 실행된다. 그 안의setValue는 N회차에 받은 props 이고, 그setValue안의form은 N회차의 값이다. await에서 멈춘 사이 타이틀을 입력하면 N+1, N+2 회차 렌더가 일어난다. 새form과 새setValue가 생기지만, 이미 실행 중인handleUpload는 계속 N회차의setValue를 들고 있다.- 업로드가 끝나면 N회차의
setValue가setDraft({ ...N회차 form, thumbnail })를 실행한다. 폼 전체가 파일을 고르던 순간의 사진으로 덮이고, 그 사이 입력이 사라진다.
썸네일과 로고를 겹쳐 올린 경우도 같다. 두 업로드 모두 "업로드 전" 사진을 들고 있어서, 나중에 끝난 쪽이 먼저 끝난 이미지를 지운다.
여기서 반전이 있다. Dan 의 예제에서 캡처는 버그를 막아 준 기능이었다. "누구를 팔로우했나"는 클릭한 순간의 사실이기 때문이다. 우리 코드에서는 같은 캡처가 버그였다. "이미지를 폼에 붙인다"는 업로드가 끝난 순간의 폼에 해야 하는 일인데, 파일을 고른 순간의 폼을 기준으로 했다.
그러니 물어야 할 질문은 "캡처가 좋은가 나쁜가"가 아니다. 이 비동기 작업이 끝났을 때 필요한 건 시작한 순간의 값인가, 지금의 값인가?
원리: state 변수는 저장소의 "복사본"이다
왜 고정되는지는 useState 를 흉내 내 보면 보인다. 브라우저 콘솔에 붙여넣어 실행할 수 있다.
let store = "기존"; // React 가 안쪽에 들고 있는 진짜 state
function useState() {
const setState = (next) => {
store = typeof next === "function" ? next(store) : next;
// (실제 React 는 여기서 컴포넌트를 다시 호출한다 = 다시 렌더)
};
return [store, setState]; // 지금 store 의 값을 "복사해서" 돌려준다
}
function Page() {
const [form, setForm] = useState();
const setValue = (patch) => setForm(form + patch); // 복사본 기준
const setValueFixed = (patch) => setForm((prev) => prev + patch); // 적용 시점 기준
return { setValue, setValueFixed };
}
const held = Page(); // 파일 선택: 이 렌더의 함수를 업로드가 들고 감
Page().setValue(" 수정"); // 업로드 중 입력 → store = "기존 수정"
held.setValue(" +이미지"); // 업로드 완료 → store = "기존 +이미지" (입력 유실)
const [form] = useState() 는 저장소와 연결된 선이 아니다. 그 순간 저장소에 적힌 값을 옮겨 적은 것이다. setState 는 저장소를 고치고 다시 렌더를 요청할 뿐, 옛 렌더에서 이미 옮겨 적은 변수에는 손댈 수 없다. 다음 렌더가 새 변수에 다시 옮겨 적는다.
React 가 이렇게 만든 건 일관성 때문이다. 렌더 도중에 값이 바뀌면 화면 절반은 옛 값, 절반은 새 값으로 그려질 수 있다. 렌더마다 한 장의 사진을 쓰게 해서 그런 어긋남을 원천적으로 막았다. 대가는 오래 기다리는 핸들러에서 사진이 낡을 수 있다는 것이다.
해결: 업데이터 함수
// before
setDraft({ ...form, ...patch });
// after
setDraft((prev) => ({ ...(prev ?? serverForm), ...patch }));
업데이터 함수(prev => …)는 사진을 보지 않는다. React 가 적용하는 순간의 최신 state 를 prev 로 넘겨준다. 업로드를 기다리던 handleUpload 는 여전히 N회차의 setValue 를 부른다. 하지만 그 함수가 이제 "지금 폼에 이미지만 붙여 달라"고 요청하므로 결과가 맞다. (prev ?? serverForm 은 원래 규칙을 그대로 옮긴 것이다. 아직 손대지 않아 draft 가 null 이면 서버 값이 기준이다.)
같은 원리로 업로드 중 표시도 고쳤다. 원래는 칸 하나만 기억하는 state 였다.
const [uploading, setUploading] = useState<"thumbnail" | "logo" | null>(null);
썸네일을 올리는 중에 로고를 고르면 값이 "logo" 로 덮여 썸네일의 스피너와 잠금이 풀렸다. 먼저 끝난 쪽은 값을 null 로 지워 남은 쪽 스피너까지 껐다. 칸별로 나누고, 역시 업데이터 함수로 바꿨다.
const [uploading, setUploading] = useState({ thumbnail: false, logo: false });
setUploading((prev) => ({ ...prev, [field]: true })); // 시작
setUploading((prev) => ({ ...prev, [field]: false })); // 끝
언제 무엇을 쓰나
Dan 의 글이 제시하는 탈출구(ref)까지 합치면 이렇게 정리된다.
| 비동기 작업이 끝났을 때 필요한 것 | 예 | 방법 |
|---|---|---|
| 시작한 순간의 값 | 팔로우 알림, 클릭 당시 조건으로 보낸 요청의 결과 표시 | 그대로 둔다 (함수 컴포넌트의 기본 동작) |
| 지금 state 를 바탕으로 새 state 만들기 | 업로드 결과를 폼에 합치기, 카운터 증가 | 업데이터 함수 setX(prev => …) |
| 지금 값을 읽기만 하기 | 3초 뒤 최신 입력값으로 알림 | useRef 에 최신 값을 담고 ref.current 로 읽기 |
ref 방식은 Dan 의 글에 나오는 형태 그대로다.
const latestMessage = useRef("");
useEffect(() => {
latestMessage.current = message;
});
const showMessage = () => alert("You said: " + latestMessage.current);
테스트로 고정하기
느린 망에서만 드러나는 버그라 손으로는 다시 잡기 어렵다. 업로드를 테스트가 직접 끝내 줄 때까지 매달아 두는 식으로 재현했다.
const pending = new Map<string, (file: UploadedFile) => void>();
vi.mock("@/features/file/_hooks/useUploadFileMutation", () => ({
useUploadFileMutation: () => ({
mutateAsync: (file: File) =>
new Promise<UploadedFile>((resolve) => pending.set(file.name, resolve)),
}),
}));
it("업로드 중에 입력한 내용이 업로드 완료 후에도 남는다", async () => {
// [수정하기] → 썸네일 선택 → 타이틀 입력 → 업로드 완료
await user.upload(thumbnailInput, png("new-thumb.png"));
await user.type(titleInput, " 수정");
await act(async () => pending.get("new-thumb.png")?.(uploaded("new-thumb.png")));
expect(titleInput).toHaveValue("기존 타이틀 수정");
});
수정 전 코드로 돌리면 타이틀이 "기존 타이틀" 로 돌아가 실패하고, 수정 후에는 통과한다. 겹쳐 올리기 케이스도 같은 방식으로 만들었다.
정리
- 함수 컴포넌트의 이벤트 핸들러는 자기가 만들어진 렌더의 props·state 를 캡처한다.
await·setTimeout·.then()처럼 나중에 실행되는 코드도 마찬가지다. - 캡처는 대개 장점이다. 함정이 되는 건 오래 기다린 뒤 캡처한 값으로 state 를 덮어쓸 때다.
- 비동기 뒤에서 이전 state 를 바탕으로 새 state 를 만든다면
prev => …를 쓴다. 읽기만 한다면 ref 를 쓴다. - 로컬에서 재현이 안 되면 DevTools 커스텀 스로틀링으로 지연을 크게 줘 본다.
참고
- Dan Abramov, How Are Function Components Different from Classes?
- Dan Abramov, A Complete Guide to useEffect — "Each Render Has Its Own…" 절
- React 문서, 스냅샷으로서의 State
- React 문서, state 업데이트 큐
- MDN, 클로저
먼저, 렌더 한 번은 사진 한 장이다
컴포넌트는 함수다. 화면을 그릴 때마다 이 함수가 처음부터 끝까지 한 번 실행되고, 이걸 렌더라고 부른다.
function Counter() {
const [count, setCount] = useState(0); // 이번 실행에서 count 는 그냥 상수다
// ...
}
count 는 이번 렌더 동안 값이 바뀌지 않는 상수다. 사진을 찍으면 그 사진 속 모습이 바뀌지 않는 것과 같다.
setCount 는 이 상수를 고치지 않는다. React 에게 "다음엔 이 값으로 함수를 한 번 더 실행해 달라" 고 부탁할 뿐이다. 그러면 새 사진이 찍히고, 새 사진 속 count 는 새 값이다.
문제는 옛 사진에서 태어난 함수다. 이벤트 핸들러, 타이머 콜백, 리스너, await 뒤의 코드는 모두 자기가 태어난 렌더의 값을 들고 다닌다. 새 사진이 찍혀도 이 함수들은 옛 사진만 본다.
1. 연달아 setState 를 부른다
const addTwo = () => {
setCount(count + 1);
setCount(count + 1);
};
2가 늘어날 것 같지만 1만 늘어난다. 두 줄 모두 같은 사진 속 count 로 계산하기 때문이다. count 가 0이었다면 두 번 모두 "1로 해 달라" 고 부탁한 셈이다.
고치려면 업데이터 함수를 쓴다.
const addTwo = () => {
setCount((c) => c + 1);
setCount((c) => c + 1);
};
업데이터 함수는 React 가 그 시점의 최신 값을 인자로 넣어 준다. 그래서 첫 번째 호출 뒤의 값(1)이 두 번째 호출에 들어가고, 결과는 2가 된다.
2. setState 직후에 그 값으로 판단한다
const next = () => {
setStep(step + 1);
if (step === 4) submit();
};
4단계로 넘어가는 순간 제출하려는 코드인데, 실제로는 한 칸 늦게 제출된다. setStep 을 불러도 이번 렌더의 step 은 그대로이기 때문이다.
새 값이 바로 필요하면 변수에 먼저 담는다.
const next = () => {
const nextStep = step + 1;
setStep(nextStep);
if (nextStep === 4) submit();
};
3. useEffect 안에서 만든 setInterval
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
1초마다 1씩 늘어나길 기대하지만, 화면은 1에서 멈춘다. 인터벌 콜백은 첫 렌더에서 한 번 만들어졌고, 그 렌더의 count 는 0이다. 그래서 매초 "0 + 1 = 1 로 해 달라" 고 부탁한다.
여기서도 업데이터 함수가 답이다.
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 1000);
return () => clearInterval(id);
}, []);
4. 한 번 붙인 이벤트 리스너가 state 를 읽는다
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) setIsOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
모달을 열고 Escape 를 눌러도 닫히지 않는다. 리스너는 첫 렌더의 isOpen(false)만 알고 있기 때문이다.
3번과 다른 점은, 여기서는 값을 바꾸는 게 아니라 읽어서 판단한다는 것이다. 업데이터 함수는 값을 바꿀 때 쓰는 도구라서 이 경우엔 맞지 않는다. 방법은 두 가지다.
하나는 의존성 배열에 isOpen 을 넣는 것이다. 값이 바뀔 때마다 리스너를 떼고 새로 붙이므로, 리스너가 항상 최신 사진에서 태어난다.
useEffect(() => {
// ...
}, [isOpen]);
다른 하나는 ref 다. 리스너를 떼었다 붙이기 곤란하거나(리스너 안에 유지해야 할 지역 상태가 있는 경우 등) 판단에만 쓰는 값이라면 ref 가 낫다.
const isOpenRef = useRef(false);
useEffect(() => {
isOpenRef.current = isOpen;
}, [isOpen]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpenRef.current) setIsOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
ref 는 사진이 아니라 상자다. useRef 는 렌더가 몇 번 바뀌어도 같은 객체를 돌려준다. 리스너는 값이 아니라 이 상자를 붙잡고 있다가, 실행될 때마다 .current 를 열어 지금 들어 있는 값을 본다. 대신 상자 안을 바꿔도 리렌더는 일어나지 않는다. 그래서 화면에 보여야 하는 값은 state, 판단에만 쓰는 값은 ref 로 나눈다.
5. await 뒤에 옛 state 로 덮어쓴다
위에서 다룬 업로드 버그가 이 모양이다.
const onUpload = async (file: File) => {
const url = await uploadFile(file);
setForm({ ...form, thumbnail: url });
};
업로드가 몇 초 걸리는 동안 사용자가 제목을 고쳤다고 하자. 업로드가 끝나면 방금 고친 제목이 사라진다. form 은 업로드를 시작한 렌더의 값이고, 그걸 펼쳐서 통째로 덮어썼기 때문이다.
const onUpload = async (file: File) => {
const url = await uploadFile(file);
setForm((prev) => ({ ...prev, thumbnail: url }));
};
이전 state 를 바탕으로 새 state 를 만들 때는 업데이터 함수를 쓴다. 이 규칙 하나로 1, 3, 5번이 모두 해결된다.
6. 늦게 도착한 응답이 최신 화면을 덮는다
useEffect(() => {
fetchUser(userId).then((data) => setUser(data));
}, [userId]);
사용자 A 를 보다가 빠르게 B 로 바꿨는데, A 의 응답이 B 의 응답보다 늦게 왔다고 하자. A 렌더에서 만든 콜백이 그대로 실행되어 B 화면에 A 의 정보가 뜬다.
effect 의 정리 함수로 낡은 응답을 버린다.
useEffect(() => {
let ignore = false;
fetchUser(userId).then((data) => {
if (!ignore) setUser(data);
});
return () => {
ignore = true;
};
}, [userId]);
userId 가 바뀌면 이전 effect 의 정리 함수가 먼저 실행되고, 그 렌더에서 만든 콜백의 ignore 가 true 가 된다. React Query 같은 라이브러리를 쓰면 쿼리 키(['user', userId])별로 캐시가 나뉘어서 이 문제를 대신 막아 준다.
7. useCallback 의존성을 비워 둔다
const save = useCallback(() => {
api.save(form);
}, []);
save 를 아무리 눌러도 처음 값이 저장된다. useCallback 은 의존성이 바뀌지 않으면 처음 만든 함수를 계속 돌려주고, 그 함수는 첫 렌더의 form 을 붙잡고 있다.
의존성에 [form] 을 넣으면 된다. react-hooks/exhaustive-deps 린트 규칙을 켜 두면 이런 누락을 대부분 잡아 준다. useMemo 와 디바운스·스로틀 함수도 같은 이유로 자주 낡는다.
덤: 리렌더는 언제 일어나나
setState 는 리렌더를 일으킨다. 다만 부른 그 자리에서 바로 일어나지 않는다.
const onClick = () => {
setOpen(true); // 리렌더를 예약만 한다
setLoading(false); // 또 예약한다
console.log(open); // 아직 이번 렌더의 값
}; // 여기서 끝나면 리렌더가 한 번 일어난다
React 는 자바스크립트가 쉬지 않고 쭉 실행하는 구간 안에서 부른 setState 를 모아 두었다가, 그 구간이 끝나면 한 번에 다시 그린다. 구간이 끝나는 지점은 함수가 끝까지 실행될 때, await 를 만날 때, 타이머나 리스너 콜백 하나가 끝날 때다.
그리고 리렌더가 일어나도 이미 실행 중이던 옛 함수에는 새 값이 반영되지 않는다. await 로 기다리는 사이에 화면은 이미 새 값으로 다시 그려졌더라도, 이어서 실행되는 코드의 open 은 여전히 옛 값이다. 함수가 끝나야 반영되는 게 아니라, 그 함수에게는 영영 반영되지 않는다.
정리
코드가 "나중에" 실행되는지 먼저 본다. 타이머, 리스너, await 뒤, 응답 콜백이 여기에 해당한다. 그런 곳에서 state 를 쓰고 있다면 아래 중 무엇이 필요한지 확인한다.
| 하려는 것 | 쓰는 것 |
|---|---|
| 시작 시점의 값을 그대로 써도 된다 | 그대로 둔다 |
| 이전 state 를 바탕으로 새 state 를 만든다 | 업데이터 함수 prev => … |
| 최신 값을 읽어서 판단만 한다 | ref |
| 낡은 비동기 결과를 버린다 | effect 정리 함수 |
| 기억해 둔 함수가 최신 값을 써야 한다 | 의존성 배열을 채운다 |
함수 컴포넌트가 렌더를 캡처하는 건 버그가 아니라 설계다. 한 렌더 안에서는 모든 값이 하나의 스냅샷으로 맞물리기 때문에 화면이 어긋나지 않는다. 대가는 시간이 지난 뒤 실행되는 코드에서 값이 낡을 수 있다는 것이고, 위의 도구들은 그 대가를 치르는 방법들이다.
부록: 업데이터 함수는 왜 되는가
글 곳곳에서 "업데이터 함수를 쓰면 된다" 고 했다. 그냥 그렇게 정해져 있다고 외우기보다, React 안에서 실제로 어떻게 처리되는지 보면 납득이 된다. 아래 코드는 react-dom 19.2.4 개발 빌드에서 그대로 가져왔다.
업데이터 함수란
setState 에는 두 가지를 넘길 수 있다.
setCount(5); // 값: "5로 바꿔 줘"
setCount((c) => c + 1); // 업데이터 함수: "지금 값을 줄 테니 1 더한 걸로 바꿔 줘"
(c) => c + 1 처럼 "이전 값 → 다음 값" 을 계산하는 함수를 업데이터 함수(updater function)라고 부른다. 매개변수에는 React 가 처리하는 시점의 최신 state 가 들어오고, 반환값이 새 state 가 된다.
useState 안에는 분기가 하나 있다
useState 는 내부적으로 아래 리듀서를 쓰는 useReducer 다.
function basicStateReducer(state, action) {
return "function" === typeof action ? action(state) : action;
}
setState 에 넘긴 것(action)이 함수면 지금 state 를 넣어 실행하고, 값이면 그 값으로 교체한다. 값과 업데이터 함수의 차이는 이 한 줄에서 갈린다. 나머지는 이 리듀서가 언제, 어떤 state 로 불리느냐의 문제다.
setState 는 주문서를 대기열에 넣을 뿐이다
setCount 는 첫 렌더에서 이렇게 만들어진다.
dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
queue.dispatch = dispatch;
return [initialState.memoizedState, dispatch];
setCount 는 이 컴포넌트와 그 state 의 대기열(queue)에 묶인 함수이고, 이후 렌더에서도 같은 함수가 돌아온다. 그래서 오래된 클로저가 붙잡은 setCount 를 불러도 요청은 같은 대기열에 정상적으로 들어간다. 낡는 건 함께 붙잡은 state 값이지 setter 가 아니다.
setCount 를 부르면 넘긴 것이 업데이트 객체의 action 에 그대로 담겨 대기열에 쌓이고, 리렌더가 예약된다.
var update = { lane, action: action, hasEagerState: false, eagerState: null, next: null };
// ...
enqueueConcurrentHookUpdate(fiber, queue, update, lane); // 대기열에 추가
scheduleUpdateOnFiber(root, fiber, lane); // 리렌더 예약
여기서 결정적인 차이가 생긴다.
setCount(count + 1)은count + 1을 부르기 전에 계산한다.action에는 숫자 1 이 담기고, React 는 그 숫자가 어떤 식에서 나왔는지 모른다.setCount((c) => c + 1)은action에 함수가 담긴다. 계산은 아직 하지 않았다.
렌더 때 대기열을 차례로 적용한다
다음 렌더에서 React 는 대기열의 업데이트를 순서대로 꺼내 리듀서에 넣는다. 앞 업데이트의 결과가 다음 업데이트의 state 로 들어간다.
pendingQueue = update.hasEagerState
? update.eagerState
: reducer(pendingQueue, update.action);
1번 예제(count 가 0일 때 연달아 두 번 호출)를 대입하면 이렇다.
| 대기열 | 처리 과정 | 결과 |
|---|---|---|
[1, 1] |
reducer(0, 1) = 1 → reducer(1, 1) = 1 |
1 |
[f, f] (f = c => c + 1) |
f(0) = 1 → f(1) = 2 |
2 |
값은 앞 결과를 무시하고 덮어쓰고, 함수는 앞 결과를 받아 이어서 계산한다. 그래서 1이 되는 원인은 배칭이 아니라 캡처다. 배칭은 리렌더를 몇 번 할지만 정하고, 값을 따로 처리하더라도 "1로", "1로" 두 번이라 결과는 같다.
같은 값이면 리렌더하지 않는다
3번 예제에서 화면이 1에서 멈춘 데에는 하나가 더 있다. 대기열이 비어 있으면 React 는 렌더를 기다리지 않고 그 자리에서 리듀서를 미리 돌려 본다.
var currentState = queue.lastRenderedState, // 마지막으로 그린 실제 값
eagerState = alternate(currentState, action); // 리듀서를 지금 바로 실행
if (objectIs(eagerState, currentState)) // 결과가 같으면
return (enqueueUpdate$1(fiber, queue, update, 0), false); // 리렌더 예약 없이 끝
currentState 는 클로저 속 복사본이 아니라 React 가 가진 실제 최신 값이다. 인터벌에 대입하면 이렇다.
| 시점 | 실제 값 | 값 방식 (action = 1) |
업데이터 방식 (action = f) |
|---|---|---|---|
| 1초 | 0 | reducer(0, 1) = 1, 달라서 리렌더 |
f(0) = 1, 리렌더 |
| 2초 | 1 | reducer(1, 1) = 1, 같아서 리렌더 생략 |
f(1) = 2, 리렌더 |
| 3초 | 1 / 2 | 또 생략 | f(2) = 3, 리렌더 |
값 방식도 setter 는 제대로 불리고 React 도 실제 값(1)을 알고 있다. 하지만 주문서에 이미 "1" 이라고 적혀 와서 리듀서는 그 숫자로 교체할 수밖에 없다. 업데이터 방식은 주문서에 계산법이 적혀 와서 React 가 실제 값을 넣어 계산한다.
이 분기 때문에 생기는 것 두 가지
하나, 업데이터 함수는 순수해야 한다. 개발 모드(StrictMode)에서는 리듀서를 일부러 한 번 더 실행하고, 위의 미리 계산 경로에서도 한 번 더 실행될 수 있다.
shouldDoubleInvokeUserFnsInHooksDEV && reducer(pendingQueue, update.action);
그래서 업데이터 안에서 API 를 부르거나 로그를 남기면 여러 번 일어난다. 업데이터는 "지금 값 → 다음 값" 계산만 한다.
둘, state 에 함수 자체를 저장하려면 한 번 감싸야 한다.
setHandler(myFunction); // ✗ React 가 업데이터로 보고 myFunction(prev) 를 실행한다
setHandler(() => myFunction); // ✓ "myFunction 을 반환하는 업데이터" 라서 myFunction 이 저장된다
정리하면, setState 는 넘긴 것을 그대로 주문서에 적어 대기열에 넣는다. 처리할 때 typeof action === "function" 한 줄로 갈려서, 값은 덮어쓰고 함수는 실제 최신 state 를 받아 계산한다. 클로저 때문에 낡는 건 우리가 미리 계산해 넘긴 숫자이지, React 가 가진 state 가 아니다.
'프론트엔드 > Javascript' 카테고리의 다른 글
| [TIL] 객체에 정렬 곁들이기 (0) | 2023.11.09 |
|---|---|
| [TIL] 클로저 (0) | 2023.11.06 |
| [TIL] 깊은 복사 (0) | 2023.10.23 |
| [TIL]꼬리 재귀 (0) | 2023.10.13 |
| [TIL]동기와 비동기 (0) | 2023.10.13 |