useState 업데이트가 즉시 반영되지 않는 이유

useState 업데이트가 즉시 반영되지 않는 이유

한눈에 보기

setState는 현재 실행 중인 함수의 변수를 바꾸는 대입문이 아니라 다음 렌더를 요청하는 함수다. 이벤트 핸들러는 자신이 생성된 렌더의 state 스냅샷을 본다. 같은 state를 연속 갱신하고 이전 결과에 의존한다면 값 대신 updater 함수를 전달해야 한다.

목차

setState 직후 이전 값이 보이는 상황

React를 처음 사용할 때 다음 로그가 예상과 다르게 느껴진다.

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(count);
  }

  return <button onClick={handleClick}>{count}</button>;
}

처음 클릭했을 때 화면은 곧 1로 바뀌지만 로그에는 0이 찍힌다. setCount가 비동기로 state 변수에 값을 대입한다고 설명하면 절반만 이해하게 된다.

더 정확한 설명은 다음과 같다.

  1. 현재 렌더에서 count0으로 고정되어 있다.
  2. handleClick도 이 렌더의 count === 0을 참조한다.
  3. setCount(1)은 React에 다음 렌더의 state를 요청한다.
  4. 현재 핸들러 안의 지역 변수 count는 바뀌지 않는다.
  5. 다음 렌더에서 React가 컴포넌트를 다시 호출하며 새로운 count를 준다.
대입이 아니라 렌더 요청

setCount(1)을 실행해도 현재 JavaScript 호출 스택의 count가 1로 변하지 않는다. 다음 컴포넌트 호출에서 새로운 스냅샷을 받는다.

state는 렌더마다 고정된 스냅샷이다

함수 컴포넌트는 호출될 때마다 props와 state를 사용해 JSX와 이벤트 핸들러를 만든다.

function OrderQuantity() {
  const [quantity, setQuantity] = useState(1);

  function handleAdd() {
    setQuantity(quantity + 1);
  }

  return (
    <button onClick={handleAdd}>
      주문 수량 {quantity}
    </button>
  );
}

개념적으로 첫 번째 렌더와 두 번째 렌더는 서로 다른 함수 실행이다.

// 첫 번째 렌더의 개념적 결과
const quantity = 1;
const handleAdd = () => setQuantity(1 + 1);

// 업데이트 후 두 번째 렌더의 개념적 결과
const quantity = 2;
const handleAdd = () => setQuantity(2 + 1);

두 핸들러는 모양은 같아도 서로 다른 렌더의 값을 닫아 둔 closure다. state는 React 내부에 보관되고, 컴포넌트가 호출될 때 해당 렌더가 사용할 값을 받는다고 생각하면 이해하기 쉽다.

이 모델은 이벤트 핸들러 안에서 값이 중간에 갑자기 바뀌지 않는다는 안정성을 준다.

function CheckoutButton({ productId }: Props) {
  const [quantity, setQuantity] = useState(1);

  async function handleCheckout() {
    setQuantity(quantity + 1);

    await submitOrder({
      productId,
      quantity,
    });
  }

  return <button onClick={handleCheckout}>주문</button>;
}

이 코드가 좋은 주문 로직이라는 뜻은 아니지만, 한 번 시작한 핸들러 안의 productIdquantity가 같은 렌더 스냅샷이라는 점은 예측 가능하다.

React가 업데이트를 모아서 처리하는 이유

React는 이벤트 핸들러의 코드가 끝나기 전에 매 setter마다 즉시 렌더와 commit을 수행하지 않고 여러 state update를 모아서 처리할 수 있다. 이를 batching이라고 부른다.

function CheckoutForm() {
  const [submitting, setSubmitting] = useState(false);
  const [message, setMessage] = useState("");

  function handleSubmit() {
    setSubmitting(true);
    setMessage("주문을 처리하고 있습니다.");
  }

  return (
    <>
      <button disabled={submitting} onClick={handleSubmit}>
        주문하기
      </button>
      <p>{message}</p>
    </>
  );
}

두 setter를 하나의 업데이트 흐름으로 처리하면 중간 상태인 “버튼은 비활성화됐지만 메시지는 아직 비어 있는 화면”을 불필요하게 보여 주지 않고 렌더 비용도 줄일 수 있다.

식당에서 주문 항목을 말할 때마다 주방이 즉시 한 접시씩 내보내기보다 주문을 끝까지 들은 뒤 한 번에 준비하는 것과 비슷하다.

sequenceDiagram
    participant H as Event Handler
    participant Q as Update Queue
    participant R as React Render
    H->>Q: setSubmitting(true)
    H->>Q: setMessage(...)
    H-->>Q: handler 종료
    Q->>R: queued updates 처리
    R->>R: 다음 UI 계산 및 commit

React 버전과 업데이트가 시작된 경계에 따라 batching 범위의 세부 동작은 달라질 수 있다. 실무 코드는 “setter 직후 DOM이 이미 바뀌었다”는 가정에 의존하지 않는 편이 안전하다.

값 업데이트와 함수형 업데이트의 차이

다음 버튼은 한 번 클릭하면 3이 증가할 것처럼 보인다.

function Counter() {
  const [count, setCount] = useState(0);

  function handlePlusThree() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }

  return <button onClick={handlePlusThree}>{count}</button>;
}

현재 핸들러에서 count는 계속 0이다. 세 줄은 모두 setCount(1)과 같다. 다음 state를 같은 값으로 교체하라는 요청이 세 번 들어간 셈이다.

이전 업데이트 결과를 이어서 계산하려면 updater 함수를 전달한다.

function handlePlusThree() {
  setCount((current) => current + 1);
  setCount((current) => current + 1);
  setCount((current) => current + 1);
}

React는 다음 렌더를 계산할 때 updater queue를 차례로 적용한다.

queued updater 입력 반환
current => current + 1 0 1
current => current + 1 1 2
current => current + 1 2 3

언제 함수형 업데이트를 써야 할까

같은 이벤트에서 이전 값에 의존하는 갱신을 여러 번 하거나, callback이 실행되는 시점의 최신 state를 기준으로 계산해야 할 때 사용한다.

setItems((currentItems) => [
  ...currentItems,
  newItem,
]);

다음 값이 현재 state와 무관하게 정해져 있다면 직접 값을 전달해도 된다.

setOpen(false);
setStatus("idle");

무조건 모든 setter를 함수형으로 바꾸는 것보다 “다음 값 계산이 이전 state에 의존하는가”를 기준으로 선택한다.

업데이트 큐는 어떤 순서로 계산될까

값 교체와 updater 함수를 섞으면 순서를 알아야 한다.

setCount(count + 5);
setCount((current) => current + 1);

현재 count가 0이라면 첫 번째 요청은 5로 교체하고, 두 번째 updater가 5를 받아 6을 만든다.

초기 state: 0
replace with 5  → 5
current + 1     → 6
최종 state: 6

순서를 반대로 하면 결과도 달라진다.

setCount((current) => current + 1);
setCount(count + 5);

첫 updater가 1을 만들지만 마지막 요청이 현재 렌더의 count + 5, 즉 5로 교체한다.

이런 코드는 읽기 어렵다. 하나의 이벤트에서 같은 state에 교체와 누적 갱신을 섞기보다 최종 의도를 하나의 updater로 표현하는 편이 낫다.

setCount((current) => normalizeCount(current + bonus));

Updater 함수는 순수해야 한다. 외부 API 호출이나 로그 적재 같은 부수 효과를 넣지 않는다.

setCount((current) => {
  analytics.track("count_changed");
  return current + 1;
});

개발 Strict Mode에서는 순수성을 확인하기 위해 updater가 추가 호출될 수 있다. 이벤트 추적은 클릭 핸들러에서, state 계산은 updater에서 수행한다.

function handleIncrement() {
  analytics.track("increment_clicked");
  setCount((current) => current + 1);
}

비동기 callback도 생성된 렌더를 기억한다

스냅샷 모델은 timeout이나 promise callback에서도 그대로 적용된다.

function DelayedAlert() {
  const [message, setMessage] = useState("");

  function handleShowLater() {
    setTimeout(() => {
      alert(message);
    }, 3000);
  }

  return (
    <>
      <input
        value={message}
        onChange={(event) => setMessage(event.target.value)}
      />
      <button onClick={handleShowLater}>3초 뒤 보기</button>
    </>
  );
}

버튼을 누른 시점의 handleShowLater는 그 렌더의 message를 닫아 둔다. 3초 동안 입력을 바꿔도 alert에는 버튼을 누를 때의 값이 나타난다. 이것은 state 업데이트가 늦게 반영된 것이 아니라 closure가 의도대로 스냅샷을 보존한 결과다.

이 특성은 비동기 요청 경쟁에서도 중요하다.

useEffect(() => {
  let ignored = false;

  async function load() {
    const result = await fetchProducts(query);

    if (!ignored) {
      setProducts(result);
    }
  }

  void load();

  return () => {
    ignored = true;
  };
}, [query]);

오래된 query 요청이 늦게 끝나 최신 결과를 덮지 않도록 cleanup에서 해당 effect 실행을 무효화한다. 가능하면 AbortController로 실제 네트워크 작업도 취소한다.

최신 값을 읽어야 한다는 요구를 다시 분류하기

“비동기 callback에서 항상 최신 state가 필요하다”는 요구는 목적에 따라 해법이 다르다.

다음 state를 최신 값에서 계산하려는 경우

함수형 업데이트를 쓴다.

socket.on("message", (message) => {
  setMessages((current) => [...current, message]);
});

messages를 callback에서 읽지 않으므로 stale closure와 effect 재연결 문제를 함께 피할 수 있다.

화면에 보여야 하는 최신 값인 경우

state를 사용한다. ref로 우회하면 값이 변해도 렌더가 일어나지 않는다.

외부 callback이 최신 값을 읽되 렌더는 원하지 않는 경우

ref가 적합할 수 있다.

function PlaybackTracker({ position }: { position: number }) {
  const latestPositionRef = useRef(position);

  useEffect(() => {
    latestPositionRef.current = position;
  }, [position]);

  useEffect(() => {
    const id = setInterval(() => {
      reportPlayback(latestPositionRef.current);
    }, 5000);

    return () => clearInterval(id);
  }, []);

  return null;
}

다만 ref는 React의 반응형 흐름을 벗어나는 escape hatch다. 렌더 결과가 ref 값에 의존하면 화면이 오래된 상태로 남을 수 있다. React 버전에 따라 Effect Event 같은 API로 반응형 부분과 최신 값 읽기를 분리할 수도 있다.

먼저 질문할 것

최신 값으로 다음 state를 계산하려는가, 화면을 갱신하려는가, 아니면 외부 callback이 읽기만 하려는가? 세 요구는 같은 해법을 쓰지 않는다.

객체와 배열 state를 안전하게 갱신하기

state 스냅샷은 객체와 배열도 불변처럼 다뤄야 한다. 기존 객체를 직접 수정해도 setter에 같은 참조를 넘기면 React가 변경을 인식하지 못할 수 있고, 이전 렌더 스냅샷도 오염된다.

type Profile = {
  name: string;
  notificationEnabled: boolean;
};

function ProfileForm() {
  const [profile, setProfile] = useState<Profile>({
    name: "",
    notificationEnabled: true,
  });

  function handleNameChange(name: string) {
    setProfile((current) => ({
      ...current,
      name,
    }));
  }
}

배열도 새 배열을 만든다.

setTodos((current) =>
  current.map((todo) =>
    todo.id === completedId
      ? { ...todo, completed: true }
      : todo,
  ),
);

중첩 객체가 깊다면 모든 state를 한 덩어리로 두기보다 변경 책임에 따라 나누거나 reducer를 고려한다.

type CartAction =
  | { type: "quantityChanged"; itemId: string; quantity: number }
  | { type: "itemRemoved"; itemId: string };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "quantityChanged":
      return updateQuantity(state, action.itemId, action.quantity);
    case "itemRemoved":
      return removeItem(state, action.itemId);
  }
}

Reducer도 현재 state와 action을 받아 다음 state를 계산하는 순수 함수여야 한다.

파생 가능한 값을 중복 state로 만들지 않기

이름과 성을 state로 갖고 있는데 전체 이름도 별도 state로 만들면 동기화해야 할 값이 늘어난다.

function NameForm() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  const [fullName, setFullName] = useState("");

  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  return <p>{fullName}</p>;
}

Effect가 실행되기 전 한 렌더 동안 fullName은 이전 값이고, 동기화를 위해 추가 렌더도 발생한다. 렌더 중 바로 계산한다.

const fullName = `${firstName} ${lastName}`.trim();

비싼 계산일 때만 측정 후 useMemo를 검토한다.

const visibleProducts = useMemo(
  () => filterProducts(products, query),
  [products, query],
);

서로 함께 바뀌어야 하는 state가 많고 잘못된 조합을 만들기 쉽다면 reducer로 상태 전이를 한곳에 모은다. 단순한 파생 값까지 reducer state에 저장할 필요는 없다.

DOM 반영 직후 작업이 필요한 예외

일반적으로 setter 직후 DOM이 갱신됐다고 가정하지 않는다.

function addMessage(message: Message) {
  setMessages((current) => [...current, message]);

  listRef.current?.scrollTo({
    top: listRef.current.scrollHeight,
  });
}

여기서는 아직 새 메시지가 DOM에 commit되지 않아 이전 scrollHeight를 읽을 수 있다. 보통 state 변화 후 layout 작업을 useLayoutEffectuseEffect에서 수행한다.

useLayoutEffect(() => {
  const list = listRef.current;
  if (!list) return;

  list.scrollTop = list.scrollHeight;
}, [messages.length]);

제3자 DOM API와 통합하며 현재 callback 안에서 동기 commit이 반드시 필요한 드문 경우 React DOM의 flushSync가 있다.

flushSync(() => {
  setMessages((current) => [...current, newMessage]);
});

listRef.current?.scrollTo({
  top: listRef.current.scrollHeight,
});

flushSync는 batching을 깨고 성능을 떨어뜨릴 수 있어 일반 state 코드의 기본 해법이 아니다. 먼저 effect 기반 동기화나 컴포넌트 구조로 해결할 수 있는지 확인한다.

테스트에서 주의할 점

setter를 호출한 직후 구현 내부 state를 읽는 대신 사용자가 관찰하는 결과를 기다린다.

it("증가 버튼을 누르면 수량이 하나 증가한다", async () => {
  const user = userEvent.setup();
  render(<QuantityButton />);

  await user.click(
    screen.getByRole("button", { name: "수량 1" }),
  );

  expect(
    screen.getByRole("button", { name: "수량 2" }),
  ).toBeInTheDocument();
});

비동기 요청은 완료 상태를 기다린다.

await user.click(screen.getByRole("button", { name: "주문하기" }));

expect(
  await screen.findByText("주문이 완료되었습니다."),
).toBeInTheDocument();

테스트가 state update를 감싸지 않았다는 act 경고를 무시하지 않는다. 사용자 이벤트 유틸리티와 findBy, waitFor를 사용해 실제 비동기 경계를 기다린다.

리뷰 체크리스트

  • setter 직후 같은 state가 바뀌었다고 가정하는가?
  • 이전 state에 의존하면서 직접 값을 전달하는가?
  • 배열이나 객체를 직접 변경하는가?
  • 파생 값을 Effect로 다시 state에 저장하는가?
  • 오래된 비동기 응답이 최신 결과를 덮을 수 있는가?
  • 최신 값 읽기를 위해 ref가 정말 필요한가?

정리

useState의 값은 렌더마다 고정된 스냅샷이다. setter는 현재 함수의 변수를 바꾸지 않고 다음 렌더에 적용할 업데이트를 큐에 넣는다. 그래서 setter 직후에는 이전 값이 보이고, 같은 이벤트의 여러 업데이트가 배칭될 수 있다.

state가 늦게 바뀌는 것이 아니라 현재 렌더의 값이 변하지 않는 것이다. 업데이트는 다음 렌더의 스냅샷을 만들기 위한 요청이다.

관련 노트와 참고 자료