기술 블로그

함수 컴포넌트는 렌더를 캡처한다 본문

프론트엔드/Javascript

함수 컴포넌트는 렌더를 캡처한다

jaegwan 2026. 10. 8. 14:29
반응형

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 파일을 고른 렌더의 값 → 입력 전 타이틀

차례로 따라가면 이렇다.

  1. 파일을 고르는 순간 화면에 있던 렌더(N회차)의 handleUpload 가 실행된다. 그 안의 setValue 는 N회차에 받은 props 이고, 그 setValue 안의 form 은 N회차의 값이다.
  2. await 에서 멈춘 사이 타이틀을 입력하면 N+1, N+2 회차 렌더가 일어난다. 새 form 과 새 setValue 가 생기지만, 이미 실행 중인 handleUpload 는 계속 N회차의 setValue 를 들고 있다.
  3. 업로드가 끝나면 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 커스텀 스로틀링으로 지연을 크게 줘 본다.

참고


먼저, 렌더 한 번은 사진 한 장이다

컴포넌트는 함수다. 화면을 그릴 때마다 이 함수가 처음부터 끝까지 한 번 실행되고, 이걸 렌더라고 부른다.

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
Comments