[React 19] 공식문서 톺아보기 - React Hook (2)

Study• 수십명이 읽음
화면 그리기 전에 실행되는 useLayoutEffect, 낙관적 업데이트를 도와주는 useOptimistic, 외부 스토어를 구독하는 useSyncExternalStore 등에 대해서 공부하였습니다.

useInsertionEffect

useInsertionEffect는 layout Effects 가 실행되기 전에 전체 요소를 DOM 에 주입 할 수 있다.

useInsertionEffect(setup, dependencies?)
useInsertionEffect(setup, dependencies?)
  • 내부적으로 useEffect와 거의 동일하게 동작한다.
  • setup 내부에서 상태 업데이트를 할 수 없다.
  • setup가 실행되는 시점에 ref는 아직 연결되지 않은 상태이다.
  • 모든 클린업 함수가 실행되고 그다음에 setup함수가 실행되는 useEffect와 달리useInsertionEffect 는 Effect마다 cleanup → setup을 개별적으로 실행한다.
    • useEffect : A cleanup → B cleanup → A setup → B setup
    • useInsertionEffect : A cleanup → A setup → B cleanup → B setup

CSS-in-JS 라이브러리에서 동적 스타일 주입하기

  • useInsertionEffect 의 주로 사용 목적은 css-in-js에서 동적 스타일을 주입하기 위해 사용된다.
  • 런타임에서 <style> 태그를 이용한 스타일 변경은 권장되지 않으며 필요한 경우 useInsertionEffect를 사용해야 한다.
  • 다른 훅과의 차이점 (레이아웃 변경 예시)
    • useEffect의 경우는 컴포넌트가 렌더링된 후에 실행되기에 깜빡임이 발생한다.
    • useLayoutEffect의 경우는 커밋단계의 마지막에 실행되기에 레이아웃 계산은 끝난 후이다. 따라서 페인트시 레이아웃이 깜빡이며 수정된다.
    • useInsertionEffect는 커밋단계에 실행돼서 실제 DOM 변경전에 스타일이 미리 주입된다. 따라서 DOM 계산 시점에 변경된 레이아웃을 반영할 수 있다.

사용예시

function App() {
  const [width, setWidth] = useState(0);
  const divRef = useRef();

  useLayoutEffect(() => {
    // div의 너비를 측정 (스타일이 없어 잘못된 값)
    setWidth(divRef.current.offsetWidth); // 예: 0 (오류)
  }, []);

  return (
    <div ref={divRef} style={{ width: '100px' }}>
      Width: {width}px {/* "Width: 0px" 출력 (버그) */}
    </div>
  );
function App() {
  const [width, setWidth] = useState(0);
  const divRef = useRef();

  useLayoutEffect(() => {
    // div의 너비를 측정 (스타일이 없어 잘못된 값)
    setWidth(divRef.current.offsetWidth); // 예: 0 (오류)
  }, []);

  return (
    <div ref={divRef} style={{ width: '100px' }}>
      Width: {width}px {/* "Width: 0px" 출력 (버그) */}
    </div>
  );
useInsertionEffect(() => {
  // 스타일을 먼저 주입 (커밋 단계)
  document.head.appendChild(styleTag);
}, []);

useLayoutEffect(() => {
  // 이제 정확한 너비 측정 가능
  setWidth(divRef.current.offsetWidth); // 100px
}, []);
useInsertionEffect(() => {
  // 스타일을 먼저 주입 (커밋 단계)
  document.head.appendChild(styleTag);
}, []);

useLayoutEffect(() => {
  // 이제 정확한 너비 측정 가능
  setWidth(divRef.current.offsetWidth); // 100px
}, []);
  • 먼저 useInsertionEffect가 실행될때 스타일을 주입한다.
  • useLayoutEffect의 실행시점은 커밋의 마지막단계이므로 ref에도 접근이 가능하다. 앞선 useInsertionEffect를 통해 설정된 스타일을 측정할 수 있다.
  • 따라서 useInsertionEffect 는 화면에 그려지기전에 스타일을 동적으로 변경할때 사용하고 useLayoutEffect 는 레이아웃 측정할때 사용한다.

useLayoutEffect

useLayoutEffect는 브라우저가 화면을 다시 그리기 전에 실행되는 useEffect이다.

useLayoutEffect(setup, dependencies?)
useLayoutEffect(setup, dependencies?)

사용예시

  • ui 렌더링을 하기전에 레이아웃 측정이 선행되어야하는 경우 useEffect를 사용하면 [렌더링 → 계산 → 반영(리렌더링)] 으로 불필요한 렌더링이 발생하게 된다.
  • useLayoutEffect 가 실행될때는 커밋단계의 마지막이므로 ref에 접근이 가능하고 브라우저 환경이므로 window 객체에 접근이 가능하다. (DOM 너비나 브라우저 너비 측정가능)
function Tooltip() {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0); // tooltipHeight : 0

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height); // tooltipHeight : 실제높이
  }, []);
}
function Tooltip() {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0); // tooltipHeight : 0

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height); // tooltipHeight : 실제높이
  }, []);
}
  1. Tooltip 은 초기화된 값인 tooltipHeight = 0으로 렌더링된다.
  2. React가 이 툴팁을 DOM에 배치하고 useLayoutEffect 안의 코드를 실행한다.
  3. useLayoutEffect가 툴팁의 높이를 계산하고 바로 다시 렌더링시킨다.
  4. Tooltip 이 실제 tooltipHeight로 렌더링 된다.
  5. React가 DOM에서 이를 업데이트하고 브라우저가 툴팁을 표시한다.
초기 렌더링 → DOM 업데이트 → useLayoutEffect → 리렌더링 → DOM 업데이트 → 페인트
초기 렌더링 → DOM 업데이트 → useLayoutEffect → 리렌더링 → DOM 업데이트 → 페인트
  • 실제로 렌더링은 2번 발생하지만, 화면에는 한 번만 표시(페인트)된다.

SSR을 통해서 서버에서 html을 생성한 경우, 서버에서 측정하여 값을 넘겨줄수 없을까?

  • _서버에서 생성된 html은 실제 브라우저 환경과 다른 제약을 가진다.
    • 서버(Node.js)는 documentwindowgetBoundingClientRect() 같은 브라우저 전용 API를 사용할 수 없다.
    • 따라서 요소의 크기(width/height), 위치(offsetTop), 뷰포트 정보 등을 알 수 없다.
  • SSR의 본질
    • 서버에서는 정적 HTML을 생성하고, 동적 레이아웃은 클라이언트로 위임해야한다.
    • 서버의 역할은 초기 HTML을 빠르게 제공하는 것이지 브라우저와 동일한 렌더링을 보장하는 것이 아니다.

useEffect가 아닌 useLayoutEffect를 써야할때

function Button() {
  const [isActive, setIsActive] = useState(false);

  useEffect(() => {
    if (isActive) {
      // 버튼 클릭 후 잠깐 "비활성 상태"가 보일 수 있음
      calculateExpensiveLayout(); // 무거운 계산
      setIsActive(false);
    }
  }, [isActive]);

  return <button onClick={() => setIsActive(true)}>Click</button>;
}
function Button() {
  const [isActive, setIsActive] = useState(false);

  useEffect(() => {
    if (isActive) {
      // 버튼 클릭 후 잠깐 "비활성 상태"가 보일 수 있음
      calculateExpensiveLayout(); // 무거운 계산
      setIsActive(false);
    }
  }, [isActive]);

  return <button onClick={() => setIsActive(true)}>Click</button>;
}
useLayoutEffect(() => {
  if (isActive) {
    calculateExpensiveLayout(); // 페인트 전에 계산 완료
    setIsActive(false); // 상태 업데이트도 동기 처리
  }
}, [isActive]);
useLayoutEffect(() => {
  if (isActive) {
    calculateExpensiveLayout(); // 페인트 전에 계산 완료
    setIsActive(false); // 상태 업데이트도 동기 처리
  }
}, [isActive]);
  • 렌더링할때 컴포넌트의 좌우너비를 계산하여 그에 맞게 표시하거나, 화면에 그려지기 전에 처리해야할 경우 useLayoutEffect를 사용한다.
  • 그려지기 전에 처리하기 때문에 오래걸리는 작업의 경우 화면 응답성을 저하시킨다. 따라서 DOM조작이 화면 그리기 전에 반드시 완료되어야 할때만 한정적으로 사용해야한다.

useMemo

useMemo 는 리렌더링 사이에 계산 결과를 캐싱할 수 있게 해주는 Hook

const cachedValue = useMemo(calculateValue, dependencies);
const cachedValue = useMemo(calculateValue, dependencies);
  • calculateValue: 캐싱하려는 값을 계산하는 함수이다.
    • 순수해야 하며 인자를 받지 않고, 모든 타입의 값을 반환할 수 있어야 한다. React는 초기 렌더링 중에 함수를 호출한다.
    • React는 dependencies가 변경되지 않았을 때 동일한 값을 다시 반환한다. 그렇지 않다면 calculateValue를 호출하고 결과를 반환하며, 나중에 재사용할 수 있도록 저장한다.
  • dependencies: calculateValue 코드 내에서 참조된 모든 반응형 값들의 목록이다.
    • 반응형 값에는 props, state와 컴포넌트 바디에 직접 선언된 모든 변수와 함수가 포함된다.

주의사항

  • useMemo는 초기 렌더링을 더 빠르게 만들지는 않는다. 리렌더링시 불필요한 작업을 건너뛰는데 도움을 준다.

사용예시

  • 사이트 내에서 상호작용이 세분화 된 경우 유용할 수 있다. (상호작용에 의해 페이지 전체가 바뀌는 경우에는 유용하지 않을 수 있다.)
  • 입력하는 계산이 눈에 띄게 느리고 종속성이 거의 변경되지 않는 경우 유용할 수 있다.
  • memo로 간싸진 컴포넌트에 prop으로 전달하는 경우 필요하다.
  • 다른 훅의 종속성으로 이용할 경우, useEffect 내에서 사용될 경우 필요하다.

useOptimistic

useOptimistic 는 UI를 낙관적으로 업데이트할 수 있게 해주는 Hook

  • 비동기 작업이 진행 중일 때 다른 상태를 보여줄 수 있게 해준다. 실제로 작업을 완료하는 데 시간이 걸리더라도 사용자에게 즉시 작업의 결과를 표시하기 위해 일반적으로 사용된다.
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
  • state: 실제 값을 나타낸다.
  • updateFn(currentState, optimisticValue): 현재 상태와 addOptimistic에 전달된 낙관적인 값을 인자로 받아 낙관적인 업데이트 결과를 리턴하는 함수이다.
  • optimisticState: 낙관적 업데이트가 반영된 임시상태로 updateFn의 결과가 반영된 결과이다.
  • addOptimistic: 낙관적 업데이트를 트리거하는 함수이다.

예시코드

const [optimisticComments, addOptimisticComment] = useOptimistic(
  comments,
  (currentComments, newComment) => [
    ...currentComments,
    { text: newComment, isPending: true }, // 낙관적 업데이트 임시 데이터
  ],
);

async function handleSubmit(formData) {
  const newComment = formData.get('comment');
  addOptimisticComment(newComment); // 1. 즉시 성공 UI 보여주기 (실제요청x)
  await submitComment(newComment); // 2. 실제 API 요청 (실패 시 자동 롤백)
}
const [optimisticComments, addOptimisticComment] = useOptimistic(
  comments,
  (currentComments, newComment) => [
    ...currentComments,
    { text: newComment, isPending: true }, // 낙관적 업데이트 임시 데이터
  ],
);

async function handleSubmit(formData) {
  const newComment = formData.get('comment');
  addOptimisticComment(newComment); // 1. 즉시 성공 UI 보여주기 (실제요청x)
  await submitComment(newComment); // 2. 실제 API 요청 (실패 시 자동 롤백)
}

주의사항

  • 낙관적 업데이트는 대부분의 요청이 성공할 것이라는 가정하에 설계된다.
    • 따라서 미리 보여주는 ui또한 성공한 상태의 ui인경우가 많다.
    • 그렇기에 실패가능성이 높은 요청에 대해서는 ui가 자주 변하므로 부적합하다.
  • 업데이트 요청에 실패하면 자동으로 롤백하는데 이것은 에러를 감지하는게 아니라 state(실제 값)와 optimisticState(임시 값)를 비교해 실제 값으로 동기화한다.

useReducer

useReducer는 컴포넌트에 reducer를 추가하는 Hook

const [state, dispatch] = useReducer(reducer, initialArg, init?)
const [state, dispatch] = useReducer(reducer, initialArg, init?)
  • reducer: state가 어떻게 업데이트 되는지 지정하는 리듀서 함수이다.
  • initialArg: 초기 state가 계산되는 값이다.
  • init: 초기 state를 반환하는 초기화 함수이다. 이 함수가 인수에 할당되지 않으면 초기 state는 initialArg로 설정된다. 할당되었다면 초기 state는 init(initialArg)를 호출한 결과가 할당된다.
  • state: 첫번째 렌더링에서의 state는 init(initialArg) 또는 initialArg로 설정된다.
  • dispatch: state를 새로운 값으로 업데이트하고 리렌더링을 일으킨다.

사용예시

import { useReducer } from 'react';

// action을 통해 state를 변경한다.
function reducer(state, action) {
  if (action.type === 'incremented_age') {
    // 반환하는 값이 state의 최종 변경상태이다.
    return {
      age: state.age + 1,
    };
  }
  throw Error('Unknown action.');
}

export default function Counter() {
  const [state, dispatch] = useReducer(reducer, { age: 42 });

  return (
    <>
      <button
        onClick={() => {
          // type을 포함한 추가적인 속성을 객체로 전달할 수 있다.
          dispatch({ type: 'incremented_age' });
        }}
      >
        Increment age
      </button>
      <p>Hello! You are {state.age}.</p>
    </>
  );
}
import { useReducer } from 'react';

// action을 통해 state를 변경한다.
function reducer(state, action) {
  if (action.type === 'incremented_age') {
    // 반환하는 값이 state의 최종 변경상태이다.
    return {
      age: state.age + 1,
    };
  }
  throw Error('Unknown action.');
}

export default function Counter() {
  const [state, dispatch] = useReducer(reducer, { age: 42 });

  return (
    <>
      <button
        onClick={() => {
          // type을 포함한 추가적인 속성을 객체로 전달할 수 있다.
          dispatch({ type: 'incremented_age' });
        }}
      >
        Increment age
      </button>
      <p>Hello! You are {state.age}.</p>
    </>
  );
}
  • reducer 함수는 보통 action.type을 분기로하는 switch문을 사용한다.

초기값을 함수의 결과로 넣는 경우

const [state, dispatch] = useReducer(reducer, createInitialState(username));
const [state, dispatch] = useReducer(reducer, createInitialState(username));
const [state, dispatch] = useReducer(reducer, username, createInitialState);
const [state, dispatch] = useReducer(reducer, username, createInitialState);
  • useReducer의 두번째 인자는 값으로 평가 되기 때문에 매번 함수가 실행된다. 따라서 함수의 결과가 초기값이 되는 경우는 세번째 인자에 넣어준다.

모든 훅의 인자는 렌더링마다 재평가된다.

useState(fn(initValue)); // ❌ 매번 실행된다.
useState(() => fn(initValue)); // ✅
useState(fn(initValue)); // ❌ 매번 실행된다.
useState(() => fn(initValue)); // ✅
  • useReducer뿐 아니라 다른 훅에서도 useState(fn(initValue))와 같이 작성하면 렌더링마다 함수가 실행되므로 useState(()=>fn(initValue))로 작성해야한다.

dispatch으로 인한 값 변화는 다음 렌더링에 적용된다.

console.log(state.age); // 42

dispatch({ type: 'incremented_age' }); // Request a re-render with 43
console.log(state.age); // 42
console.log(state.age); // 42

dispatch({ type: 'incremented_age' }); // Request a re-render with 43
console.log(state.age); // 42
  • useState와 동일한 매커니즘으로, 값 변화는 다음 렌더링에 적용된다.

reducer에서는 값을 직접수정하지 말고 반환해야한다

    case 'changed': {
      state.age++; // ❌ 직접 변경하면 안된다.
      state.name = action.nextName;
      return state;
    }
    case 'changed': {
      return {
        ...state,
        age: state.age + 1 // ✅ 새로운 객체로 반환해야한다.
        name: action.nextName
      };
    }
    case 'changed': {
      state.age++; // ❌ 직접 변경하면 안된다.
      state.name = action.nextName;
      return state;
    }
    case 'changed': {
      return {
        ...state,
        age: state.age + 1 // ✅ 새로운 객체로 반환해야한다.
        name: action.nextName
      };
    }

dispatch 이후 일부 속성이 undefined 가 되는 경우

    case 'incremented_age': {
      return {
        ...state,
        age: state.age + 1
      };
    }
    case 'incremented_age': {
      return {
        ...state,
        age: state.age + 1
      };
    }
  • 기존 state에 대한 코드를 제외하고 변경된 부분만 넣게되면 변경된 속성만 포함된다.

useRef

useRef는 렌더링에 필요하지 않은 값을 참조할 수 있는 Hook

const ref = useRef(initialValue);
const ref = useRef(initialValue);
  • initialValue: ref 객체의 current프로퍼티 초기 값이다.
  • ref : current를 단일 프로퍼티로 가진 객체를 반환한다.

특징

  • ref.current 프로퍼티를 변경해도 React는 컴포넌트를 다시 렌더링하지 않는다. ref는 일반 JavaScript 객체이기 때문에 React는 변경을 알 수 없다.

  • 초기화를 제외하고는 렌더링 중에 ref.current를 읽거나 쓰게되면 컴포넌트의 동작 예측이 어려워진다.

    function Component() {
      const ref = useRef(0);
    
      ref.current = Math.random(); // ❌ 렌더링 중 ref.current 수정
    
      const [state, setState] = useState(ref.current); // ❌ ref.current로 상태 변경
    
      return <div>{ref.current}</div>;
    }
    function Component() {
      const ref = useRef(0);
    
      ref.current = Math.random(); // ❌ 렌더링 중 ref.current 수정
    
      const [state, setState] = useState(ref.current); // ❌ ref.current로 상태 변경
    
      return <div>{ref.current}</div>;
    }
    function Component() {
      const ref = useRef(null);
    
      if (ref.current === null) {
        ref.current = new HeavyObject(); // ✅ 초기화시 ref 변경 (한번만 실행)
      }
    
      useEffect(() => {
        console.log(ref.current); // ✅ 이벤트/Effect 내부에서 ref 접근
      }, []);
    
      return <div ref={ref} />;
    }
    function Component() {
      const ref = useRef(null);
    
      if (ref.current === null) {
        ref.current = new HeavyObject(); // ✅ 초기화시 ref 변경 (한번만 실행)
      }
    
      useEffect(() => {
        console.log(ref.current); // ✅ 이벤트/Effect 내부에서 ref 접근
      }, []);
    
      return <div ref={ref} />;
    }
  • 렌더링할 때마다 재설정되는 일반 변수와 달리 리렌더링 사이에 정보를 저장할 수 있다.

  • 리렌더링을 촉발하는 state 변수와 달리 변경해도 리렌더링을 발생시키지 않는다.

  • 정보가 공유되는 외부 변수와 달리 각각의 컴포넌트에 로컬로 저장된다.

초기값 지정방법

function Video() {
  const playerRef = useRef(new VideoPlayer());
  // ...
function Video() {
  const playerRef = useRef(new VideoPlayer());
  // ...
  • 모든 훅의 인자는 렌더링마다 재평가가 되기 때문에 초기값을 지정할때 함수의 실행을 넣을경우 값 자체는 초기에 한번만 사용되지만 함수 실행은 렌더링마다 발생한다.
// ✅ 방법1 처음 렌더링 도중 초기화
const playerRef = useRef(null);
if (playerRef.current === null) {
  playerRef.current = new VideoPlayer();
}

// ✅ 방법2 값을 가져오는 함수안에서 초기화 로직 구현
function getPlayer() {
  if (playerRef.current !== null) {
    return playerRef.current;
  }
  const player = new VideoPlayer();
  playerRef.current = player;
  return player;
}
// ✅ 방법1 처음 렌더링 도중 초기화
const playerRef = useRef(null);
if (playerRef.current === null) {
  playerRef.current = new VideoPlayer();
}

// ✅ 방법2 값을 가져오는 함수안에서 초기화 로직 구현
function getPlayer() {
  if (playerRef.current !== null) {
    return playerRef.current;
  }
  const player = new VideoPlayer();
  playerRef.current = player;
  return player;
}

useState

useState는 컴포넌트에 state 변수를 추가할 수 있는 Hook

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

초기값을 지정할때 함수실행을 전달하지 않는다

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


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


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


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


function TodoList() {
  const [todos, setTodos] = useState(()=>createInitialTodos(1));
  // ...
  • 훅의 모든 인자는 렌더링마다 재평가 되기 때문에 초기값을 계산하는 함수의 경우는 함수 자체를 전달한다.
  • 이때 함수에 인자를 받아서 전달하는 경우는 익명함수를 이용한다.

업데이터 함수

setAge(age + 1); // setAge(42 => 43)
setAge(age + 1); // setAge(42 => 43)
setAge(age + 1); // setAge(42 => 43)
console.log(age); // 42
setAge(age + 1); // setAge(42 => 43)
setAge(age + 1); // setAge(42 => 43)
setAge(age + 1); // setAge(42 => 43)
console.log(age); // 42
  • 동일한 렌더링에서 age는 42로 고정된다.
setAge((a) => a + 1); // setAge(42 => 43)
setAge((a) => a + 1); // setAge(43 => 44)
setAge((a) => a + 1); // setAge(44 => 45)
console.log(age); // 42
setAge((a) => a + 1); // setAge(42 => 43)
setAge((a) => a + 1); // setAge(43 => 44)
setAge((a) => a + 1); // setAge(44 => 45)
console.log(age); // 42
  • 업데이터 함수를 사용하면 이전 업데이트 값을 반영하여 다음 렌더링에 적용하지만 여전히 동일한 렌더링에서는 42로 고정된다.

객체나 배열의 일부를 업데이트할때는 전개문법을 사용한다

setForm({
  ...form,
  firstName: 'Taylor',
});
setForm({
  ...form,
  firstName: 'Taylor',
});

key값은 컴포넌트의 state를 초기화 시킨다

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

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

  return (
    <>
      <button onClick={handleReset}>Reset</button>
      <Form key={version} />
    </>
  );
}
export default function App() {
  const [version, setVersion] = useState(0);

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

  return (
    <>
      <button onClick={handleReset}>Reset</button>
      <Form key={version} />
    </>
  );
}
  • React에서 key는 컴포넌트를 식별하는데 중요한 역할을 한다. 컴포넌트의 위치가 변경되더라도 key는 React가 생명주기 내내 해당 컴포넌트를 식별할 수 있게 해준다.
  • 반대로 key값이 바뀌게 되면 동일한 props의 컴포넌트라고해도 컴포넌트를 다시 생성한다.
  • key를 변경하여 의도적으로 컴포넌트를 초기화하는데 사용할수 있다.

이전 상태를 이용해야하는 경우

const [prevCount, setPrevCount] = useState(count);
const [trend, setTrend] = useState(null);

if (prevCount !== count) {
  setPrevCount(count);
  setTrend(count > prevCount ? 'increasing' : 'decreasing');
}
const [prevCount, setPrevCount] = useState(count);
const [trend, setTrend] = useState(null);

if (prevCount !== count) {
  setPrevCount(count);
  setTrend(count > prevCount ? 'increasing' : 'decreasing');
}

공식문서에서는 잘 사용하지 않는 패턴이라고 표현하면서 소개해주고 있었는데, 얼마든지 우회할 수 있는 방법이 있을것같다. 예를들어 setCount로 값을 변경하는 이벤트 핸들러에서 추세를 계산하는 방법도 있을것 같다.

  • 값의 변화가 증가추세인지 감소추세인지를 저장해야할때, useEffect를 사용하지않고 렌더링 도중 계산한다.
  • 렌더링 도중 state가 변하게 되면 진행하던 렌더링을 중단하고 다시 렌더링을 하기 때문에 useEffect를 이용해서 두번 렌더링하는것보다 효율적이라고 설명하고 있다.
  • 렌더링중에 실행되므로 반드시 prevCount !== count와 같은 조건문을 통해 무한 렌더링을 방지한다.

state에 함수를 저장하는 방법

// ❌ React는 someFunction를 초기화 함수로 여긴다
const [fn, setFn] = useState(someFunction);

function handleClick() {
  setFn(someOtherFunction);
}

// ✅ 올바른 설정방법
const [fn, setFn] = useState(() => someFunction);

function handleClick() {
  setFn(() => someOtherFunction);
}
// ❌ React는 someFunction를 초기화 함수로 여긴다
const [fn, setFn] = useState(someFunction);

function handleClick() {
  setFn(someOtherFunction);
}

// ✅ 올바른 설정방법
const [fn, setFn] = useState(() => someFunction);

function handleClick() {
  setFn(() => someOtherFunction);
}

useSyncExternalStore

useSyncExternalStore는 외부 store를 구독할 수 있는 Hook

const snapshot = useSyncExternalStore(
  subscribe,         // 스토어 구독 함수
  getSnapshot,       // 현재 스토어 상태를 읽는 함수
  getServerSnapshot? // (선택) SSR시 초기 상태 제공하는 함수
);
const snapshot = useSyncExternalStore(
  subscribe,         // 스토어 구독 함수
  getSnapshot,       // 현재 스토어 상태를 읽는 함수
  getServerSnapshot? // (선택) SSR시 초기 상태 제공하는 함수
);
  • subscribe: 하나의 callback 인수를 받아 store를 구독한다.
    • store가 변경될 때, 제공된 callback이 호출되어 React가 getSnapshot을 다시 호출하고 (필요한 경우) 컴포넌트를 다시 렌더링한다. subscribe 함수는 구독을 해제하는 함수를 반환해야 한다.
  • getSnapshot: store 데이터의 스냅샷을 반환한다.
    • 저장소가 변경되어 반환된 값이 다르면 React는 컴포넌트를 리렌더링한다.
  • getServerSnapshot: 서버 사이드 렌더링(SSR) 시 초기 상태를 제공한다.

예시코드

function WindowWidth() {
  const width = useSyncExternalStore(
    (callback) => {
      window.addEventListener('resize', callback);
      return () => window.removeEventListener('resize', callback);
    },
    () => window.innerWidth, // 클라이언트 상태
    () => 0, // SSR 기본값
  );

  return <div>창 너비: {width}px</div>;
}
function WindowWidth() {
  const width = useSyncExternalStore(
    (callback) => {
      window.addEventListener('resize', callback);
      return () => window.removeEventListener('resize', callback);
    },
    () => window.innerWidth, // 클라이언트 상태
    () => 0, // SSR 기본값
  );

  return <div>창 너비: {width}px</div>;
}
  • 브라우저 API 구독도 가능하다.

Transition 업데이트로 인한 불일치 문제

function Component() {
  const data = useSyncExternalStore(store.subscribe, store.getSnapshot);

  const handleSearch = () => {
    startTransition(() => {
      // Non-blocking 업데이트 시작
      setSearchQuery(input); // Transition 업데이트
    });
  };
}
function Component() {
  const data = useSyncExternalStore(store.subscribe, store.getSnapshot);

  const handleSearch = () => {
    startTransition(() => {
      // Non-blocking 업데이트 시작
      setSearchQuery(input); // Transition 업데이트
    });
  };
}
  1. 초기 렌더링: data = A (스토어 상태 A)
  2. Transition 업데이트 시작: React는 메인 스레드를 차단하지 않고 백그라운드에서 새 가상 DOM 생성
  3. 도중에 스토어 변경: 외부 스토어가 A → B로 변경됨 (store.subscribe가 알림)
  4. DOM 적용 직전: React는 getSnapshot을 다시 호출 → B 반환 (이전과 다름)
  5. 문제 발생: 렌더링 중 사용된 스토어 상태(A) ≠ 실제 스토어 상태(B) → UI 불일치 가능성

해결과정

  • Transition 업데이트가 DOM을 적용하기 직전, React는 getSnapshot을 다시 호출해 스토어 상태가 변경되었는지 확인한다.
    • 같은 값: Non-blocking 업데이트 계속 진행한다.
    • 다른 값: 기존 업데이트를 취소하고 Blocking 모드로 우선순위를 높여 재시작하여 렌더링을 완료한다.
  • Blocking 업데이트 수행
    • React는 메인 스레드를 차단하고, 즉시 동기적으로 리렌더링한다.
    • 모든 컴포넌트가 최신 스토어 상태(B)를 반영하도록 보장한다.

 useSyncExternalStore로 구독한 값으로 UI를 변경할 경우

function ShoppingApp() {
  const selectedProductId = useSyncExternalStore(...); // 외부 스토어 구독

  return selectedProductId != null ? <LazyProductDetailPage /> : <FeaturedProducts />;
}
function ShoppingApp() {
  const selectedProductId = useSyncExternalStore(...); // 외부 스토어 구독

  return selectedProductId != null ? <LazyProductDetailPage /> : <FeaturedProducts />;
}
  • UI가 selectedProductId에 종속적이다. 이런상황에서 selectedProductId는 외부 스토어의 값으로, 예상치 못하게 자주 값이 변경될 수 있고 사용자는 의도치 않게 로딩화면을 반복적으로 보게된다.
  • 따라서 구독중인 스토어의 값을 기반하여 UI를 변경하는 것은 신중해야한다.

서버렌더링 지원 getServerSnapshot

export function useOnlineStatus() {
  const isOnline = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot,
  );
  return isOnline;
}

function getServerSnapshot() {
  return true; // 서버에서 생성된 HTML에는 항상 "Online"을 표시한다.
}
export function useOnlineStatus() {
  const isOnline = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot,
  );
  return isOnline;
}

function getServerSnapshot() {
  return true; // 서버에서 생성된 HTML에는 항상 "Online"을 표시한다.
}
  • 서버에서는 window 객체에 접근을 못하므로 서버에서 html을 생성할때 해당 값이 어떤값으로 동작할지에 대한 설정을 추가할 수 있다.
    • HTML을 생성할 때 서버에서 실행된다.
    • hydration 중 즉 React가 서버 HTML을 가져와서 인터랙티브하게 만들 때 클라이언트에서 실행된다.

객체를 반환하지 않는다

// ❌ getSnapshot에서 객체를 반환하면 안된다.
function getSnapshot() {
  return {
    todos: myStore.todos
  };
}

// ✅ 불변 데이터를 반환할 수 있다.
function getSnapshot() {
  return myStore.todos;
}
// ❌ getSnapshot에서 객체를 반환하면 안된다.
function getSnapshot() {
  return {
    todos: myStore.todos
  };
}

// ✅ 불변 데이터를 반환할 수 있다.
function getSnapshot() {
  return myStore.todos;
}
  • React는 getSnapshot 반환 값이 지난번과 다르면 컴포넌트를 리렌더링한다.
  • 객체를 반환하게 될 경우 매번 다른 값으로 인식하여 리렌더링하게 되고 무한루프에 빠진다.

subscribe를 훅 호출과 같은 레벨로 선언하지 않는다

function ChatIndicator() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);

  // ❌ 항상 다른 함수를 사용하므로 React는 렌더링할 때마다 다시 구독한다.
  function subscribe() {
    // ...
  }
}
function ChatIndicator() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);

  // ❌ 항상 다른 함수를 사용하므로 React는 렌더링할 때마다 다시 구독한다.
  function subscribe() {
    // ...
  }
}
  • 리렌더링할때마다 재선언되고 다시 구독되어 매번 실행된다.
  • 다른곳에 선언하거나 useCallback으로 감싼다.

useTransition

useTransition은 UI의 일부를 백그라운드에서 렌더링 할 수 있도록 해주는 Hook

const [isPending, startTransition] = useTransition();
const [isPending, startTransition] = useTransition();
  • isPending:  대기 중인 Transition 이 있는지 알려준다.
  • startTransition : 상태 업데이트를 Transition 으로 표시할 수 있게 해준다.
    • startTransition 내에서 호출되는 함수를 "Action"이라고 한다. 따라서 startTransition 내부에서 호출되는 콜백의 이름은 action이거나 "Action" 접미사를 포함해야 한다.
    • action: 하나 이상의 set 함수를 호출하여 일부 상태를 업데이트한다.

사용예시

function Router() {
  const [page, setPage] = useState('/');
  const [isPending, startTransition] = useTransition();

  function navigate(url) {
    startTransition(() => {
      setPage(url);
    });
  }
function Router() {
  const [page, setPage] = useState('/');
  const [isPending, startTransition] = useTransition();

  function navigate(url) {
    startTransition(() => {
      setPage(url);
    });
  }
  • Transition으로 실행된 상태 업데이트는 non-blocking 방식으로 처리되며 로딩표시가 나타나지 않는다.
    • 원치 않는 로딩 표시기를 방지하므로 사용자가 탐색 시 갑작스러운 이동을 방지할 수 있다.
    • useTransition은 백그라운드에서 상태를 준비한 후 한 번에 렌더링한다.
    • non-blocking 방식으로 동작하므로 처리하는동안 사용자는 다른 행동이 가능하다.
  • Transition으로 표시된 state 업데이트는 다른 state 업데이트에 의해 중단된다.
    • 우선순위가 낮아 Transition이 아닌 일반 state 업데이트를 우선적으로 처리한다.
  • 즉, useTransition백그라운드로 처리하여 로딩표시를 보여주고 싶지 않거나 우선순위가 낮은 업데이트를 처리하는데 사용한다.

탭 이동 예시 (A → B → C → A)

순서동작결과
A → BstartTransition으로 B 탭 렌더링 시작B의 렌더링 시작 (백그라운드)
B → CB의 렌더링 완료 전에 C 탭으로 전환B 렌더링 중단 → C 렌더링 시작
C → AC의 렌더링 완료 전에 A 탭으로 전환C 렌더링 중단 → A 탭으로 즉시 복귀
  • A 탭만 실제 DOM에 반영된다.
  • B와 C 탭의 렌더링은 중간에 취소되며, 백그라운드에서 완료되지 않는다.
  • isPending은 A 탭 복귀 후 false가 된다.
읽어주셔서 감사합니다