<Demopeu/>

useState

useState

referencereactuseStatehook공식 문서

이번 기회에 견고한 React 아키텍쳐를 위해서 useState에 대해 정리해보려고 한다.

📌 useState란?

공식문서에 따르면 useState는 React 함수 컴포넌트에서 state를 추가할 수 있게 해주는 Hook이다.

const [state, setState] = useState(initialState);

useState는 두 개의 값을 반환한다.

  • state: 현재 상태 값
  • setState: 상태를 업데이트하는 함수

여기서 전달하는 initialState는 초기 렌더링에만 사용되고 이후 렌더링에서는 무시된다.

initialState

함수를 initialState로 전달하면 이를 초기화 함수로 취급한다. 이 함수는 순수해야하고 인수를 받지 않아야하고 반드시 어떤 값을 반환해야한다.

📌 useState: 렌더링과 스냅샷

정확한 코드는 공개되지 않았지만, 멘탈 모델로 설명할 수 있다. 결론부터 이야기 하자면, 실제로는 컴포넌트 바깥에 존재하는 배열커서를 활용한 클로저 기술에 가깝다.

동작 방식의 핵심은 React가 내부적으로 훅이 호출된 순서를 기억한다는 점이다.

Rules of Hooks가 존재하는 절대적인 이유

React 공식 문서에서 최상위(Top-level)에서만 훅을 호출해야 하며, 조건문(if), 반복문(for), 중첩 함수 안에서 훅을 호출하면 안 된다고 강제하는 이유가 바로 이 '순서' 때문이다.

렌더링마다 훅이 호출되는 순서나 개수가 달라지면, React 내부의 커서가 꼬이면서 완전히 엉뚱한 상태 값을 읽어오는 대참사가 발생한다.

상태는 변수가 아니라 '스냅샷'이다

상태 업데이트에서 가장 많이 오해하는 것은 let 마냥 변수처럼 동작한다고 생각한다는 것이다. React는 그 자리에서 값이 즉시 변하는 것이 아니라 해당 렌더링에 대한 스냅샷으로 고정되어 있다.

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

function handleClick() {
  setCount(count + 1); // React에게 다음 렌더링에 count를 1로 만들라고 "예약"함
  console.log(count);  // 🚨 1이 아니라 여전히 현재 스냅샷인 0이 출력됨!
}

즉, setState는 상태를 즉시 변경하는 것이 아니라, React에게 새로운 상태 값으로 이 컴포넌트를 다시 실행(Re-render) 하도록 요청하는 트리거다.

flushSync

React 18 이전에는 flushSync를 사용하여 상태 업데이트를 동기적으로 처리할 수 있었다. React 18부터는 Automatic Batching이 기본적으로 활성화되어 있어 flushSync가 필요하지 않다. 하지만 드물게 DOM에 접근하기 위해 React가 화면을 더 일찍 업데이트하도록 하고 싶을 때 flushSync를 사용할 수 있다. 하지만 React 팀에서는 이를 권장하지는 않는다.

📌 안전하고 효율적인 상태 업데이트

1. 이전 상태를 기반으로 업데이트하기(업데이터 함수)

React는 성능 최적화를 위해 여러 번의 상태 업데이트를 모아서 한 번만 렌더링하는 **일괄 처리(Automatic Batching)**를 수행한다. 모든 이벤트 핸들러가 실행되고 set 함수를 호출한 후에야 화면을 업데이트하는 방식이다.

React 18의 Automatic Batching

사실 React 17 이전에도 일괄 처리는 존재했다. 하지만 onClick 같은 React의 합성 이벤트(Synthetic Event) 핸들러 내부에서만 작동한다는 치명적인 한계가 있었다.

// React 17 이전의 한계: 비동기 콜백에서는 배칭이 풀림
function handleClick() {
  setTimeout(() => {
    setCount(c => c + 1); // 리렌더링 1 발생
    setFlag(f => !f);     // 리렌더링 2 발생
  }, 1000);
}

즉, setTimeout, Promise.then, fetch 콜백 등 비동기 함수 내부에서는 일괄 처리가 풀려버려 상태를 업데이트할 때마다 리렌더링이 폭주하는 문제가 있었다. React 18부터는 자동 일괄 처리가 도입되어, 출처와 상관없이 모든 상태 업데이트를 똑똑하게 모아서 단 한 번만 렌더링하도록 근본적인 아키텍처가 개선되었다.

만약 현재 age가 42일 때, 아래처럼 코드를 작성하면 age는 45가 아니라 43이 된다.

function handleClick() {
  setAge(age + 1); // setAge(42 + 1)
  setAge(age + 1); // setAge(42 + 1)
  setAge(age + 1); // setAge(42 + 1)
}

상태가 '스냅샷'이기 때문에, 이미 실행 중인 코드에서 age는 계속 42로 고정되어 있기 때문이다. 이를 해결하고 이전 상태를 기반으로 안전하게 누적 업데이트를 하려면, 다음 상태 값 대신 업데이터 함수를 전달해야 한다.

항상 업데이터를 사용하는 것이 더 좋은가?

나쁠 건 없지만 항상 그래야만 하는 것은 아니다. 대부분의 경우, 두 가지 접근 방식 사이 차이는 없다. 하지만, state 변수 자체에 접근하는 것이 어려운 경우에는 업데이터 함수가 좋다. 친절한 문법보다 일관성을 더 선호한다면 항상 업데이터로 작성하는 것이 합리적이다. 하지만, 어떤 state가 다른 state 변수의 이전 state로부터 계산되는 경우라면, 업데이터보다는 reducer를 사용하는 것을 권장한다.

function handleClick() {
  setAge(age => age + 1); // setAge(42 => 42 + 1)
  setAge(age => age + 1); // setAge(43 => 43 + 1)
  setAge(age => age + 1); // setAge(44 => 44 + 1)
}

React는 업데이터 함수를 Queue에 넣고, 다음 렌더링 중에 동일한 순서로 호출하여 최종 상태를 계산한다.

2. 객체 및 배열 상태 업데이트하기 (불변성 유지)

React에서 상태는 읽기 전용으로 간주해야 한다. 상태가 변했는지 확인할 때 얕은 비교를 수행하기 때문에, 객체나 배열의 원본을 직접 수정하면 메모리 주소(참조)가 같아 React가 업데이트를 무시해버린다.

// 🚩 잘못된 방법: 기존 객체/배열을 직접 변경 (리렌더링 무시됨)
form.firstName = 'Taylor';
setForm(form); 

users.push('뉴비');
setUsers(users);

기존 객체를 변경하는 대신, 스프레드 문법(...) 등을 사용해 완전히 새로운 객체나 배열(새 메모리 주소)을 할당하여 교체해야 한다.

// ✅ 올바른 방법: 새로운 객체/배열로 교체
setForm({
  ...form,
  firstName: 'Taylor'
});

setUsers([...users, '뉴비']);

3. 초기 상태 다시 생성하지 않기 (게으른 초기화)

React는 초기 상태를 한 번만 저장하고 다음 렌더링부터는 무시한다. 하지만 아래처럼 코드를 작성하면, 결과는 무시되더라도 렌더링될 때마다 무거운 함수가 불필요하게 실행된다.

// ❌ 렌더링될 때마다 무거운 배열 루프나 연산이 실행됨
const [todos, setTodos] = useState(createInitialTodos());

이러한 문제가 발생하는 핵심적인 이유는 **'실행의 주도권(제어권)'**이 누구에게 있느냐에 있다.

useState(createInitialTodos())처럼 괄호를 붙여 함수를 '호출'한 결과를 넘기면, 실행 주도권은 일반적인 자바스크립트 엔진에게 있다. 컴포넌트(함수)가 렌더링될 때마다 자바스크립트는 useState에 인자를 전달하기 위해 저 무거운 함수를 일단 냅다 실행해버린다. React가 뒤늦게 "어? 이거 초기 렌더링 아니니까 결과값은 버릴게"라고 무시하더라도, 이미 불필요한 연산은 끝난 뒤다.

초기값을 구하는 과정이 무거울 때 이 문제를 해결하려면, useState에 함수 호출 결과가 아닌 함수 자체를 전달하면 된다. 이를 **게으른 초기화(Lazy Initialization)**라고 부른다.

// ✅ 초기 1회만 콜백이 실행되어 값을 평가함
const [todos, setTodos] = useState(createInitialTodos);
// 또는
const [todos, setTodos] = useState(() => createInitialTodos());

이렇게 함수 자체나 콜백 형태로 넘기면, 함수의 실행 주도권이 자바스크립트에서 React 내부로 넘어간다. React는 이 함수 참조를 쥐고 있다가, "지금이 초기 렌더링이니까 딱 한 번만 실행해서 값을 꺼내 써야지" 하고 똑똑하게 타이밍을 제어할 수 있게 되어 자원 낭비를 완벽하게 막을 수 있다.

📌 견고한 아키텍쳐를 위한 심화 패턴과 안티패턴

1. key를 활용한 깔끔한 상태 초기화

리스트를 렌더링할 때 주로 쓰는 key 속성은 사실 컴포넌트의 상태를 초기화하는 아주 강력한 무기다.

특정 폼이나 컴포넌트의 상태를 완전히 초기 상태로 되돌리고 싶을 때, 일일히 빈값으로 해주는 것보다 key를 활용하는 것이 훨씬 깔끔하고 직관적이다.

// ❌ 번거롭고 실수하기 쉬운 수동 초기화
function handleReset() {
  setName('');
  setAge(0);
  setAddress('');
  // 상태가 추가될 때마다 여기도 수정해야 함
}

// ✅ key를 활용한 우아한 초기화
const [version, setVersion] = useState(0);

function handleReset() {
  setVersion(version + 1); // version이 바뀌면 Form 컴포넌트가 아예 새로 마운트됨
}

return (
  <>
    <button onClick={handleReset}>초기화</button>
    <Form key={version} />
  </>
);

2. 렌더링 도중 상태 업데이트 (이전 렌더링 정보 저장)

원칙적으로 렌더링 도중에 set 함수를 호출하면 "리렌더링 횟수가 너무 많습니다" 에러와 함께 무한 루프에 빠진다. 하지만 props의 변화에 따라 상태를 조정해야 하는 극히 드문 경우, 조건문으로 감싸서 렌더링 도중에 상태를 직접 업데이트하는 패턴이 존재한다.

export default function CountLabel({ count }) {
  const [prevCount, setPrevCount] = useState(count);
  const [trend, setTrend] = useState(null);
  
  // 렌더링 도중 set 함수 호출 (조건문 필수!)
  if (prevCount !== count) {
    setPrevCount(count);
    setTrend(count > prevCount ? '증가' : '감소');
  }
  
  return <h1>{count} ({trend})</h1>;
}

이 패턴은 useEffect를 사용하지 않고도 컴포넌트 렌더링 중에 상태를 업데이트할 수 있는 방법이다. 렌더링 도중 상태를 업데이트하면 즉시 리렌더링을 시도하여 효율을 높인다.

하지만 이 패턴은 공식 문서에서 권장하지 않는다. 이것을 **파생 상태(Derived State)**라고 부르는데 동기화 버그를 양산하는 최악의 안티패턴 중 하나이다.

3. 파생 상태(Derived State)와 useEffect 남용 금지

기존에 계산할 수 있는 값은 절대 useState로 관리하면 안된다. 특히 상태 동기화를 위해 useEffect를 과도하게 사용하는 것은 피해야 한다.

// ❌ Anti-pattern: 상태 동기화를 위해 useEffect 남용
const [items, setItems] = useState(initialItems);
const [total, setTotal] = useState(0);

// items가 바뀔 때마다 렌더링 -> useEffect 실행 -> setTotal 실행 -> 또 리렌더링 (성능 저하)
useEffect(() => { 
  setTotal(items.length * 100); 
}, [items]);

어차피 상태가 바뀌면 리렌더링이 되니까 굳이 상태를 또 업데이트할 필요가 없다.

// ✅ 올바른 예: 파생 상태는 계산해서 사용
// ✅ Senior pattern: 렌더링 중에 그냥 계산함
const [items, setItems] = useState(initialItems);

// 렌더링 시 자연스럽게 최신 값으로 계산됨. 별도의 상태 업데이트나 Effect가 필요 없다.
const total = items.length * 100;

4. Next.js 16 (App Router) 특화: UI 상태를 useState에 가두지 않기

React 생태계, 특히 Next.js 16의 App Router 환경에서는 상태 관리의 패러다임이 한 단계 더 확장된다. 검색어(?q=), 페이지 번호(?page=), 활성화된 탭(?tab=) 같은 UI 상태를 습관적으로 useState에 담는 것은 안티패턴에 가깝다.

UI 상태를 useState로만 관리하면 발생하는 치명적인 단점은 **"상태가 증발한다"**는 것이다. 유저가 새로고침을 누르거나, 현재 보고 있는 화면의 URL을 복사해 다른 사람에게 공유했을 때 상태가 초기화되어 빈 화면이나 1페이지로 돌아가 버린다.

해결책은 URL 자체를 전역 상태 저장소로 사용하는 것이다. Next.js가 제공하는 useSearchParamsuseRouter를 활용하여 URL Parameter를 업데이트하고 읽어와야 한다.

// ✅ URL을 Source of Truth로 사용
'use client';
import { useSearchParams, useRouter } from 'next/navigation';

export default function ProductList() {
  const searchParams = useSearchParams();
  const router = useRouter();
  
  // URL에서 page 값을 읽어옴 (없으면 '1')
  const page = searchParams.get('page') ?? '1';

  const handleNextPage = () => {
    const params = new URLSearchParams(searchParams);
    params.set('page', String(Number(page) + 1));
    router.push(`?${params.toString()}`); // URL을 변경하면 컴포넌트가 알아서 리렌더링됨
  };
  
  return (
    // ...
  );
}