React19.1 - State Hooks(useState-1)

Hunter Joe·2025년 4월 17일

이번엔 State hook에 대해 심도 있게 알아보고자 한다.
이 글은 공식문서를 기반으로 작성될것이며 deep-dive를 위한 소스코드 분석도 함께 해볼 예정이다.
또한 useState, useReducer를 각각 언제 사용하면 좋을지에 대한 논의도 해보려고 한다.
아마 이번 글에는 다 적기가 힘들 것 같아 4부에 걸쳐서 나눠 작성할 것 같다.

내가 이 글을 작성한 이유:
useState 위주의 코드, useReducer도 함께 사용하고 싶음
훌륭한 코드를 좀 내뱉고 싶다. useState 단일로 작성한 뻔한 패턴에 대한 회의감? + 더욱 좋은 코드 작성을 위한 참조 글

📌 useState

A Component's Memory

Convention

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

Parameter

  • initialState(초기값)

Returns

  • state(getter) : 현재 상태 + 첫 번째 렌더링에서는 initialState에서 전달한 상태와 일치
  • setState(setter) : set 함수는 상태를 다른 값으로 업데이트하고 다시 렌더링을 트리거할 수 있는 함수

Code + 개발자 도구

import { useState } from 'react';

function App() {
  console.log(useState(0));
  return <div className="App"></div>;
}

export default App;

↓ 개발자 도구
개발자도구에서 보면 배열의 0은 state값을 배열의 1은 setState 즉 함수를 보여준다.
그리고 ƒ()의 Property를 보면 다양하게 있는데 dispatchSetState나중에 찾아볼 함수이기도 하다.

set function

setState(nextState) : The value that you want the state to be. It can be a value of any type
→ 원하는 상태 값, 어떤 유형의 값이든 가능

nextState로 함수를 전달하면, React는 그것을 업데이터 함수(updater function)로 처리하게 된다.
이때 해당 nextState로 전달된 함수는 반드시 순수 함수여야 하며 현재 pending state를 유일한 인자로 받아 다음 상태를 반환해야 한다.

React는 이 업데이터 함수를 queue에 넣고 컴포넌트를 리렌더링을 진행한다.
그리고 다음 렌더링 시점에 React는 이 queue에 쌓인 모든 업데이터 함수들을 이전 상태에 순차적으로 적용하여 새로운 상태를 계산한다.

말이 어려운데 조금 더 쉽게 설명하면

// setState(nextState)
setCount((prev) => prev + 1);
  • nextState에 함수를 넣으면 해당 함수는 업데이터 함수로 처리
  • nextState는 순수 함수
  • pending state는 prev이다.

Caveats (주의사항)

6개의 주의사항이 있는데 하나씩 순서를 매겨서 살펴보자

  1. setState 함수는 다음 렌더링을 위한 상태값만 업데이트한다.
    setState를 호출한 직후에 state 값을 읽으면 여전히 이전 렌더링 때의 오래된 값(old value)이 반환
export default function App() {
  const [ name, setName ] = useState("Taylor");
  
  function handleClick() {
    setName('Robin');
    console.log(name); // Still "Taylor"!
  }
  return (
    <Fragment>
      <button onClick={handleClick}>{name}</button>
    </Fragment>
  );
}

setState()는 비동기적으로 동작하기에 바로 다음 줄에서 상태를 읽으면 아직 바뀌지 않은 상태(old state)를 보게 된다.

  1. setState`로 전달한 새 값이 이전 값과 완전히 같다면 렌더링을 생략한다. → 최적화
    Object.is로 비교함

  2. React는 여러 개의 상태 업데이트를 한꺼번에 처리(batch) 한다.
    이벤트 핸들러들이 모두 실행되고 그 안에서 setState들이 호출된 이후에 한 번만 화면을 업데이트(render)한다.
    → 하나의 이벤트 처리 안에서 여러 번 리렌더링되는 걸 방지 → 최적화

function handleClick() {
  setCount(c => c + 1);
  setText("hi");
  // React는 여기서 바로 리렌더링 X
}
// 이벤트 핸들러 끝난 후에 한 번만 리렌더링

⚠️ 드믈게 화면을 강제로 즉시 업데이트해야 할 경우
Ex)DOM 값을 바로 읽어야 할 때, 이럴 땐 flushSync를 사용할 수 있다.

function App() {
  const [count, setCount] = useState(0);
  const [bool, setBool] = useState(false);

  const handleClick = () => {
    flushSync(() => {
      setCount(count + 1); // 상태 업데이트 ①
    });
    setBool(!bool);        // 상태 업데이트 ②
    
    console.log("count on handleClick", count);
    console.log("bool on handleClick", bool);
  };

  useEffect(() => {
    console.log("리렌더링");
    console.log("count on useEffect", count);
    console.log("bool on useEffect", bool);
  }, [count, bool]);

  return <div onClick={handleClick}>{count}</div>;
}
/*
console 결과 값은 아래와 같이 나온다. 

count on handleClick 0       ← 아직 setCount 적용 전
bool on handleClick false    ← 아직 setBool 적용 전

리렌더링                      ← flushSync 이후 첫 번째 리렌더링
count on useEffect 1
bool on useEffect false

리렌더링                      ← setBool 이후 두 번째 리렌더링
count on useEffect 1
bool on useEffect true
*/
  1. setState함수는 항상 동일한 참조를 유지
    React에서 제공하는 set함수는 렌더링이 바뀌어도 항상 같은 함수 객체(reference)를 유지
    → 의존성 배열에 안넣어도 된다는 것

  2. 렌더링 중에set함수를 호출하는 것은 현재 렌더링 중인 컴포넌트 내에서만 허용
    이 경우 React는 해당 렌더링 결과를 버리고
    새로운 상태로 컴포넌트를 즉시 다시 렌더링하려고 시도한다.
    이 패턴은 거의 필요하지 않지만 이전 렌더링에서 발생한 정보를 저장해야 할 때 사용할 수 있다.

5번 패턴을 다시 코드와 함께 알아보자 ↓

function CountLabel({ count }: { count: number }) {
  const [prevCount, setPrevCount] = useState(count);
  const [trend, setTrend] = useState<"increasing" | "decreasing" | null>(null);
  if (prevCount !== count) {
    setPrevCount(count);
    setTrend(count > prevCount ? "increasing" : "decreasing");
  }
  return (
    <>
      <h1>{count}</h1>
      {trend && <p>The count is {trend}</p>}
    </>
  );
}

function App() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
      <CountLabel count={count} />
    </>
  );
}


export default App;

1.App 컴포넌트에서 count가 변경 됨 → App 컴포넌트 리렌더링 발생

  • setCount가 호출되면 App이 다시 렌더링 됨
  • 새로운 count 값이 CountLabel로 props로 전달 됨

2.자식 컴포넌트 CountLabel도 props가 바뀌었기 때문에 리렌더링 됨

  • 이제 새로운 props인 count 값과
  • 이전에 저장된 prevCount 상태를 비교함

3.이 시점에서 실행되는 조건문 (렌더링 중)

if (prevCount !== count) {
  setPrevCount(count);
  setTrend(count > prevCount ? "increasing" : "decreasing");
}
  • set함수들은 렌더링 중간에 실행되고 있음
    → React는 이를 감지하고 다음과 같이 행동

a) 렌더 중 setState()발생 감지 !!
b) 현재 렌더링 중인 결과를 버림(discard)
c) 즉시 리렌더링을 시도

이렇게 하면 CountLabel의 자식 컴포넌트가 있었다면, 그 자식은 한 번만 리렌더링이 일어남
useState에서 setState를 하는 것보다 효율적이게 됨
CountLabel에서의 렌더링은 두 번이지만 CountLabel의 자식 컴포넌트에게 전파되는건 한번임!!

즉, 이 코드는 렌더링 중 조건부 setState()를 활용해 React가 진행하고 있는 현재 렌더링을 취소하고 최신 상태를 반영한 새로운 렌더링를 즉시 수행하게 만드는 패턴

NOTE
이 패턴을 사용하게 되면 불필요한 Effect를 쓰지 않아도 된다.
나도 앞으로 자주 쓸거같은 패턴

  1. Strict 모드에서 React는 실수로 생성된 불순물을 찾는 데 도움이 되도록 updater 함수를 두 번 호출 합니다.
  • 개발 환경에서만 동작하며 프로덕션 환경에는 영향 X.
  • updater 함수가 순수 함수라면(그럴 가능성이 높으므로) 동작에 영향을 미치지 않는다.

Is using an updater always preferred?

  • updater 함수를 쓰는 것이 항상 선호될까?

state를 이전 상태로부터 계산할 때는 항상 이렇게 쓰라고 한다.
setAge(a => a + 1) 이 방식은 전혀 나쁘지 않지만 항상 필요하지는 않다.

대부분의 경우 둘의 결과가 같다.

// 방법 1: 값 직접 전달
setAge(age + 1);

// 방법 2: updater 함수
setAge(prev => prev + 1);

하지만 아래 상황에선 updater가 더 안전하다.

setCount(count + 1);
setCount(count + 1); // ❌ 예상대로 2 증가 안 함

setCount(prev => prev + 1);
setCount(prev => prev + 1); // ✅ 예상대로 2 증가 
  • updater 함수가 여러 번 연속 업데이트할 땐 안전
  • 일관성 있게 작성하고 싶다면 항상 쓰는 것도 나쁘지 않음
  • 다른 state variable의 이전 상태를 기반으로 계산된 것이라면 두 변수를 하나의 객체로 합치고 리듀서를 사용하는 것이 좋다.
import { useState } from 'react';

export default function Counter() {
  const [age, setAge] = useState(42);

  function increment() {
    setAge(a => a + 1);
  }

  return (
    <>
      <h1>Your age: {age}</h1>
      <button onClick={() => {
        increment();
        increment();
        increment();
      }}>+3</button>
      <button onClick={() => {
        increment();
      }}>+1</button>
    </>
  );
}

setState함수는 비동기처럼 동작하지만 (렌더링 주기에 따라 반영)
setState함수 자체가 비동기는 아님 Promise를 반환하지 않음
→ 이 개념은 소스코드 볼 때 다시 알아볼 예정

Avoiding recreating the initial state

  • 초기 상태 재생성 방지
function TodoList() {
  const [todos, setTodos] = useState(createInitialTodos());
  // ...

→ 해당 코드는 모든 렌더링에서 createInitialTodos()함수를 호출 함

function TodoList() {
  const [todos, setTodos] = useState(createInitialTodos);
  // ...

두 코드의 동작은 별 차이가 없지만 전자는 렌더링이 될 때마다 해당 함수가 호출 됨(비효율)

Resetting state with a key

  • Key로 상태 재설정하기
import { useState } from 'react';

export default function App() {
  const [version, setVersion] = useState(0);

  function handleReset() {
    setVersion(version + 1);
  }

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

function Form() {
  const [name, setName] = useState('Taylor');

  return (
    <>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <p>Hello, {name}.</p>
    </>
  );
}
  1. 사용자가 <input>"joeki" 입력 → name 상태 = "joeki"
  2. Reset 버튼 클릭 → version이 바뀌면서 Form key가 변경
    React는 Form(key=1)을 새 컴포넌트로 인식 → 기존 Form(key=0) 제거, 새로 마운트
    useState("")가 다시 실행되며 name 상태가 초기화됨 → <input> 값이 비워짐

⚠️주의
리렌더가 아니라 완전한 언마운트 → 마운트이기 때문에
컴포넌트의 모든 state, ref, effect가 날아감
불필요하게 쓰면 성능에 안 좋음

NOTE
핵심 원리: React의 Reconciliation
React는 이전 렌더 트리와 이번 렌더 트리를 비교해서
"무엇을 바꿔야 최소한으로 화면을 업데이트할 수 있을까?" 를 판단

이때 기준이 되는 게 바로 key
key가 다르면 React는 "이건 완전히 다른 컴포넌트"라고 판단해 기존 컴포넌트를 아예 언마운트하고 새로운 키값으로 컴포넌트를 마운트 함

Updating Objects in State

state의 상태는 어떻게 다뤄야하나에 대한 리액트 공식 문서이다.
참고하면 좋을꺼 같다.(후에 다시 정리해도 되고 지금은 링크로만 남겨둠 )
https://react.dev/learn/updating-objects-in-state

생각정리

set()함수를 통한 렌더링 최적화 패턴이 가장 내가 유용하게 쓸 수 있는 패턴인거 같다.
useState공식문서를 이렇게까지 읽어본 적이 없는데 상당히 도움이 많이 됐다.

profile
Async FE 취업 준비중.. Await .. (취업완료 대기중) ..

0개의 댓글