React 19 공식문서 딥다이브 - 1편 상태와 사이드 이펙트

이가 은·2026년 1월 1일

react

목록 보기
8/9
post-thumbnail

React 19 공식 문서를 기반으로 진행한 몇 달 간의 스터디 내용을 시리즈로 정리하고자 한다.

이 시리즈는 hooks, 컴포넌트, API처럼 특정 기능 단위로 정리하기보다는,
React가 어떤 시점에 무엇을 실행하는지, 그리고 그 실행 시점과 동작 흐름 기준으로 내용을 묶는다.

공식 문서를 단순히 요약하는 것이 아니라,
각 개념을 읽으며 자연스럽게 생기는 “왜 이렇게 동작할까?”라는 질문을 출발로 React 전체를 이해하는 것을 목표로 한다.

따라서 공식 문서만으로 충분히 학습할 수 있는 기본적인 사용법은 다루지 않는다.


useState

useState에서 즉시 실행 초기화와 지연 초기화란 무엇일까?

React에서는 지연 초기화(lazy initialization)를 제안한다.

  1. 예를 들면 useState(add())

useState(add())는 렌더링마다 add()가 즉시 실행된다.
반환값은 첫 마운트 때만 초기 state로 사용되고 이후에는 무시되지만, 리렌더링 시에는 함수 실행 자체가 매번 발생한다.
add가 비싼 연산일 경우 불필요한 계산으로 성능 이슈가 생길 수 있다.

  1. 그러면 useState(add)

useState(add)는 초기 state 계산 함수(initializer)로 취급되어 첫 마운트 시에만 add가 실행되고, 그 반환값이 초기 state로 사용된다. 리렌더링 시에는 함수 실행 자체가 발생하지 않는다.
이를 지연 초기화(lazy initialization)라 하며 성능 최적화에 유리하다.

useState(() => add())useState(add)와 같다.
add 함수의 반환값이 숫자일 경우 아래처럼 useState[상태값, 업데이트 함수] 타입이 같다.

상태가 변경되었을 때 jsx가 아니라 함수를 실행한다면?

부모 컴포넌트에서 전달하는 props의 상태가 변경되면 자식 컴포넌트는 리렌더링된다.
하지만 jsx컴포넌트가 아니라면?

function Parent() {
  const [count, setCount] = React.useState(0);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        부모 상태 변경
      </button>

      {Child({ value: count })} // 함수를 실핼할 뿐
    </div>
  );
}

function Child({ value }) {
  console.log('Child 렌더링됨'); // 실행은 되지만, React 컴포넌트로 관리되지 않음
  return <div>: {value}</div>;
}

React 기준에서 Child는 컴포넌트가 아니라 단순 함수 호출 결과이므로 재조정(reconciliation) 대상에서 제외된다
jsx문법으로 컴포넌트를 생성할 경우 React Fiber에 생성되어 관리된다. 그래서 일반 함수 실행시 등록되지 않은 컴포넌트는 리렌더링되지 않는다.


useReducer

useReducer는 '여러가지 상태들을 어떻게?'의 의도로 사용한다.

useState가 관리하고 있는 상태가 많고, 각 상태들의 관심사가 비슷하다면 useReducer를 사용하자.

예를들면, api로 받은 response data와 연관된 상태 값들...

const [data, setDate] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState({})

// api fetch 후 한 번에 변화되는 세 가지 상태값
// setData(data);
// setIsLoading(true);
// setError(error);
// 기타 등등...

만약 각 상태값들이 특정 연산이나 비즈니스 로직이 필요하다면 useCallback을 사용하여 최적화해야 할 수도 있다.

useReducer를 사용하면 다음의 이점이 생긴다.

  • 관심사를 묶어 각각의 상태를 직접 수정하지 않아도됨
  • 상태 변경을 setState가 아닌 action으로 바뀌어 로직의 흐름이 파악됨

같이 바뀌는 값들은 같이 바뀌게 한다.

// api fetch 후 한 번에 변화되는 세 가지 상태값
// isLoading, error는 내부에서 관리
const [state, dispatch] = useReducer(reducer, initialState);
const { data, isLoading, error } = state;

// api fetch 시작
dispatch({ type: 'FETCH_START' });
// api fetch 후 response를 받음
dispatch({ type: 'FETCH_SUCCESS', payload: data });
// error일 경우 error 전달
dispatch({ type: 'FETCH_ERROR', payload: err });


useContext

props drilling 대체하기

중첩된 자식 요소에 props drilling이 발생되었다면 context를 사용해보자.
내부 자식 요소에 필요한 값을 상속시켜 해결할 수 있다.
이는 CSS의 내부 요소들이 상위 요소에 선언된 값을 상속받아 사용하는 것과 동일하다.

공식 문서 - Using and providing context from the same component


createContext

Context : 특정 트리의 모든 컴포넌트가 공유하는 값을 정의할 수단

동적 스코프처럼 구현하여 내부의 모든 컴포넌트 내에서 동일하게 참조할 수 있도록 구현되어있다. 다이나믹하게 변경되는 값이지만 렌더 시점에서 동일하게 참조해야할 때, useContext와 함께 사용한다.
가장 가까이에 있는 <SomeContext>에서 선언된 값을 참조해서 내부 컴포넌트를 렌더한다.

  • Context 컴포넌트 렌더링 시작 → 값 설정
  • children 렌더링 → useContext()가 값 읽음
  • 렌더링 종료 → 값 복원 (중첩 처리)


useEffect

paint 이후에 동작하므로 화면 깜빡임이나 UI 변경이 보일 수 있다.

useEffect비동기로 실행된다.
여기서 비동기는 흔히 promise, async와는 다른 미뤄두는 이벤트라고 생각하면 될 것 같다.
우리가 흔히 말하는 React에서의 '렌더링'은 UI 그리는 것(paint)이 아닌 컴포넌트 함수의 실행, VDOM 생성을 말한다.

  1. 컴포넌트 렌더 (width = 0)
  2. DOM 업데이트
  3. Paint ← 화면에 width=0으로 그림
  4. useEffect 실행 (비동기 실행. paint는 계속 진행)
  5. setWidth(100) 호출 ← 리렌더 트리거
  6. 리렌더
  7. Paint ← 화면에 width=100으로 다시 그림 (깜빡!)

부모와 자식 effect의 순서 보장

만약 부모의 상태값이 리렌더링 된다면 내부 useEffect(passive) 순서는 어떻게 될까?
렌더링(VDOM 그리기) 시 부모 먼저 동작 후 자식이 동작하게 된다.
하지만 useEffect는 자식 useEffect작동 후 부모 useEffect가 작동된다. cleanup도 마찬가지.
이 순서가 보장되는 이유는 React Fiber commit 단계에서 passive effect를 DFS 후위 순회(post-order)로 실행하도록 설계했기 때문이다.

하지만 useEffect 내부에서 발생한 비동기 작업이나 DOM 이벤트의 실행 순서는 React가 보장하지 않는다.

effect 동작 순서

  • 최초 마운트: render phase → commit phase → 브라우저 paint → useEffect 실행
  • 리렌더링: render phase → commit phase(변경) → 브라우저 paint → useEffect cleanup 실행 → useEffect 실행
  • 언마운트: (부모 render phase) → commit phase(제거) → useEffect cleanup 실행

useLayoutEffect

paint 이전에 동작하므로 깜빡임이 발생되지 않는다.

useLayoutEffect동기로 실행된다.
useLayoutEffect내부에서 state를 변경한다면 paint이전에 한 번 더 렌더링(VDOM 그리기)한다. 그래서 사용자는 변경점을 감지하지 못하고 변경되기 전 상태를 알 수 없으며, 최종 상태값으로 렌더링된다.

리렌더링시 effect 사이클

  • render → commit → layoutEffect cleanup → layoutEffect → paint → useEffect cleanup → useEffect

effect 내부의 cleanup

cleanup은 최초 마운트시에는 동작하지 않는다.
이후에 리렌더링시에는 이전 effect를 정리하고 새 effect를 실행하며, 언마운트되면 남아있는 마지막 effect을 정리한다.

profile
독서와 개발을 좋아하는 프론트 개발자 🍀

0개의 댓글