리액트19의 use() 파헤쳐 보기

밍글·2025년 2월 2일
6

FE스터디

목록 보기
3/4

시작하기 전에


누끼 따는 것도 귀찮… 얜 포실핑입니다 4기에 나오는 ~~ 모른다고요? 괜찮아요 5기가 이제 대세라서

react19에서는 여러가지 변경사항이 있었는데 그 중 가장 눈에 들어온 건 use였다. use를 읽다보면 useEffect, useContext를 대체할 수 있다고 나온다. react는 계속해서 업데이트 될 것이기 때문에 use에 대해 알아볼 겸 어떻게 useEffect와 useContext를 대체할 수 있는지 궁금해서 작성하게 되었다.


use

📃 use 개념설명
usePromiseContext와 같은 데이터를 참조하는 React API

// 기본 사용 방식
const value = use(resource);
// 활용 예시
import { use } from 'react';
function MessageComponent({ messagePromise }) {
  const message = use(messagePromise);
  const theme = use(ThemeContext);

다른 React Hook과 달리 useif와 같은 조건문과 반복문 내부에서 호출할 수 있다.
Promise와 함께 호출될 때 use Hook은 SuspenseError Boundary와 통합된다.
서버 컴포넌트에서 클라이언트 컴포넌트로 Promise Prop을 전달하여 서버에서 클라이언트로 데이터를 스트리밍할 수 있다.

한줄 요약 : use 훅은 비동기 데이터 페칭 또는 context 를 비동기로 불러올 수 있다.

useContext 대신 use로 context 참조하기

결론부터 말하자면 use훅을 사용할 시 context 관리가 더 직관적으로 된다.

useContext는 컴포넌트의 최상위 수준에서 호출해야 하지만, useif와 같은 조건문이나 for와 같은 반복문 내부에서 호출할 수 있다. 이는 use 훅이 유연하다는 것을 보여주고, useContext 을 대체할 수 있음을 보여준다.

use는 context의 context value를 반환하고 use는 context 값을 결정하기 위해 항상 이를 호출하는 컴포넌트의 위쪽에서 가장 가까운 context provider 를 찾는다.

코드 사용 방법은 기존 useContext를 사용하듯이 쓰면 된다.

// ContextExample.tsx, use(React19) 사용
import { use } from 'react';
import { ThemeContext } from '../context/ThemeContext';

export default function ContextExample() {
  const { setTheme } = use(ThemeContext);

  const handleThemeToggle = () => {
    setTheme((prevTheme) => {
      if (prevTheme === 'light') {
        return 'dark';
      } else {
        return 'light';
      }
    });
  };

  return (
    <div>
      <button onClick={() => handleThemeToggle()}>Switch Theme</button>
    </div>
  );
}
//App.tsx
import { ThemeContext } from './context/ThemeContext';
import ContextExample from './components/ContextExample';

function App() {
  const { theme } = use(ThemeContext);

  return (
    <div className={`App ${theme}`}>
      <ContextExample />
    </div>
  )
}

export default App;

⚠️ 주의점
useContext와 마찬가지로, use(context)는 항상 이를 호출하는 컴포넌트의 위쪽에서 가장 가까운 Context Provider를 찾는다. 위쪽으로 탐색하며, use(context)를 호출하는 컴포넌트 내부의 Context Provider는 고려하지 않는다!

useEffect 대신 use로 async data fetching하기

둘의 차이를 설명하기 이전에 기존에 어떻게 하였는지 먼저 코드로 보여주겠다.

코드

import React, { useState, useEffect } from 'react';

const DataFetchingComponent = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://api.example.com/data');
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <div>
      <h1>Data:</h1>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};

export default DataFetchingComponent;

// 혹은 다른 예시
import React, { useState, useEffect } from 'react';

async function fetchPerson(): Promise<Person> {
  const response = await fetch('https://swapi.py4e.com/api/people/1/');
  return response.json();
}

interface Person {
  name: string;
  [key: string]: any; // 기타 속성을 허용
}

function PersonComponent() {
  const [person, setPerson] = useState<Person | null>(null);

  useEffect(() => {
    // async data fetch
    fetchPerson().then((data) => setPerson(data));
  }, []);

  if (!person) return <h1>Loading...</h1>;

  return <h1>{person.name}</h1>;
}

export default PersonComponent;

흐름
1️⃣ useEffect는 데이터 가져오기를 시작하기 위해 컴포넌트가 마운트된 후 트리거된다.
2️⃣ 적절한 UI를 관리하고 표시하기 위해 loading, data, error 상태를 유지하거나 데이터 유무에 따른 처리를 한다.
→ 이에 해당하는 상태를 관리하거나 데이터 유무확인을 별도로 했어야 했다.
3️⃣ 데이터가 가져와지면 상태가 업데이트되어 데이터를 표시하기 위한 리렌더링이 트리거된다.

useEffect를 사용한 데이터 페칭에서는 Suspense와 Error Boundary를 사용할 수 없었다. 기존에는 에러가 떠서 Suspense나 Error관련 컴포넌트를 만들어서 처리를 했었지 이유에 대해서는 자세히 알아보진 않았었다. 이유는 다음과 같다.

1️⃣ useEffect는 컴포넌트가 렌더링된 후에 실행되기 때문에, 데이터 로딩 상태를 Suspense로 처리할 수 없다.
2️⃣ useEffect 내부에서 발생하는 에러는 이미 컴포넌트가 마운트된 후이기 때문에 Error Boundary로 캐치할 수 없었다.

Suspense나 Error Boundary는 서버 렌더링 단계에서 데이터 요청 상태를 파악할 수 있게 만들었기 때문에 react-query와 주로 활용했던 것을 많이 볼 수 있다.

하지만 use() 훅을 사용하게 된다면 더 이상 useState()와 같은 상태 관리 훅에 의존할 필요가 없게 된다.

use() 훅을 활용한 코드

import React, { use } from 'react';

async function fetchData() {
  const response = await fetch('https://api.example.com/data');
  if (!response.ok) {
    throw new Error('Failed to fetch data');
  }
  return response.json();
}

const DataFetchingComponent = () => {
  // `use()` promise가 resolve될 때까지 컴포넌트 렌더링이 중지된다.
  const data = use(fetchData());

  return (
    <div>
      <h1>Data:</h1>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};

export default DataFetchingComponent;

// 관리하는 다른 예시

import React, { use, Suspense } from 'react';

// Async function to fetch data
async function fetchPerson() {
  const response = await fetch('https://swapi.py4e.com/api/people/1/');
  return response.json();
}

// Person component using `use()`
function PersonComponent() {
  const person = use(fetchPerson());
  return <h1>{person.name}</h1>;
}

// App component with Suspense fallback
function App() {
  return (
    <Suspense fallback={<h1>Loading...</h1>}>
      <PersonComponent />
    </Suspense>
  );
}

export default App;

1️⃣ use()를 사용하면 promise가 resolve될 때까지 컴포넌트 렌더링이 일시 중단됩니다. 오류가 발생하면 Suspense 에러 바운더리를 트리거 할 수 있다.
2️⃣ React가 내부적으로 처리하기 때문에 부수 효과로 데이터 페칭을 수동으로 관리할 필요가 없다.
3️⃣ 이제 loading이나 error 같은 상태를 수동으로 추적하지 않고도 Suspense 에러 바운더리를 사용하여 전역적으로 관리할 수 있다.

🚨 결론을 말하자면 Suspense와 Error Boundary모두 한 컴포넌트에 너무나 많은 역할이 부담을 덜기 위해서 나온 것이기 때문에 use를 사용한다면 더 간결하게 코드를 작성하며 컴포넌트 역할을 덜어낼 수 있다.

서버에서 클라이언트로 데이터 스트리밍하기

위에서 잠깐 다뤘던 Suspense와 Error Boundary 사용을 useEffect 대신 use훅을 활용하면 가능하다고 했었다. 어떻게 할 수 있는지 좀 더 자세하게 다뤄보도록 하겠다.

🔫 흐름
  1. 서버 컴포넌트에서 클라이언트 컴포넌트로 Promise Prop을 전달하여 서버에서 클라이언트로 데이터를 스트리밍할 수 있다.

  2. 클라이언트 컴포넌트는 Prop으로 받은 Promise를 use API에 전달합니다. 클라이언트 컴포넌트는 서버 컴포넌트가 처음에 생성한 Promise에서 값을 읽을 수 있다.

  3. 예시로 나오는 MessageSuspense로 래핑되어 있으므로 Promise가 리졸브될 때까지 Fallback이 표시됩니다. Promise가 리졸브되면 use Hook이 값을 참조하고 Message 컴포넌트가 Suspense Fallback을 대체한다.

    import { use, Suspense } from "react";
    
    function Message({ messagePromise }) {
      const messageContent = use(messagePromise);
      return <p>Here is the message: {messageContent}</p>;
    }
    
    export function MessageContainer({ messagePromise }) {
      return (
        <Suspense fallback={<p>⌛Downloading message...</p>}>
          <Message messagePromise={messagePromise} />
        </Suspense>
      );
    }

유의사항 : 서버 컴포넌트에서 클라이언트 컴포넌트로 Promise를 전달할 때 리졸브된 값이 직렬화 가능해야 합니다. 함수는 직렬화할 수 없으므로 Promise의 리졸브 값이 될 수 없다.

→ 이게 무슨 소리냐면 서버 컴포넌트에서 클라이언트 컴포넌트로 데이터를 전달할 때, 그 데이터는 JSON으로 변환이 가능해야 한다는 뜻! 함수의 경우 클라이언트로 전달할 수 없기 때문이다.

Promise 처리는 서버 컴포넌트에서? 아니면 클라이언트 컴포넌트에서?

Promise resolve는 둘 다 할 수 있긴 하다.

다만, 서버 컴포넌트에서 await를 사용한다면 완료될 때까지 렌더링이 차단된다. 서버 컴포넌트에서 클라이언트 컴포넌트로 Promise를 Prop으로 전달하면 Promise가 서버 컴포넌트의 렌더링을 차단하는 것을 방지할 수 있다.

서버 컴포넌트는 한 번만 실행되어 Promise를 한 번만 만들지만, 클라이언트 컴포넌트는 화면이 다시 그려질 때마다 Promise를 새로 만들어야 하기 때문이다.

이를 활용하여 관리자용과 일반 사용자용 API 요청을 따로 가질 수 있다. 이전에는 React 훅을 조건부로 호출할 수 없었지만, use는 이러한 틀을 깨고 활용할 수 있다. 다음과 같은 코드로 말이다.

if (isAdmin) {
  use(getAllUsers)
} else {
  use(getUsersInMyAccount)
}

React는 확실히 SSR과 폼 작업에 중점을 두면서 서버 및 비동기 코드 작업을 더 쉽게 만들고 있다는 모습을 보여준다.

주의사항

🔥 usetry-catch 블록에서 호출할 수 없다. try-catch 블록 대신 컴포넌트를 Error Boundary로 래핑하거나 Promise의 catch 메서드를 사용하여 대체값을 제공해야 한다.

🔥 React 컴포넌트 또는 Hook 함수 외부에서, 혹은 try-catch 블록에서 use를 호출하고 있는 경우에 Suspense Exception이 일어날 수 있다. try-catch 블록 내에서 use를 호출하는 경우 컴포넌트를 Error Boundary로 래핑하거나 Promise의 catch를 호출하여 오류를 발견하고 Promise를 다른 값으로 리졸브한다.

→ 웬만하면 Error Boundary를 활용하자

🔥 use는 렌더링 중에 생성된 Promise를 지원하지 않는다.

렌더링 중에서 생성된 Promise는 매 렌더링마다 새로운 Promise가 생성될 수 있어 예측 불가능한 동작을 발생시킬 수 있으며, React 렌더링 모델의 순수성과 일관성을 해칠 수 있다.

🔥 다른 use~훅들과 마찬가지로 컴포넌트나 use로 시작하는 커스텀Hook 내부에서 호출되어야 한다.

🔥 서버 컴포넌트에서 데이터를 fetch할 때는 use보다 async/await을 사용한다. → 이건 아까 서버 컴포넌트에서 Promise 처리에서 다뤘으니 생략하겠다.

동작방식

동작원리

결론만 말하면 Promise를 use API로 처리한다. 여기서는 컴포넌트가 Suspense와 Error Boundary로 감싸져 있다고 가정하겠다.

use동작원리

  1. pending일 때는 Suspense의 fallback UI를 렌더링하고
  2. rejected가 되면 가장 가까운 Error Boundary의 fallback UI를 렌더링한다.
  3. resolve될 때까지 기다렸다가 데이터를 받아 Suspense 내부 컴포넌트를 렌더링한다.

내부 동작원리

function use<T>(usable: Usable<T>): T {
  if (usable !== null && typeof usable === 'object') {
    if (typeof usable.then === 'function') {
	    // Thenable 객체인 경우
      const thenable: Thenable<T> = (usable: any);
      return useThenable(thenable);
    } else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
	    // Context 객체인 경우 
      const context: ReactContext<T> = (usable: any);
      return readContext(context);
    }
  }
  // 둘 다 아닌 경우
  throw new Error('An unsupported type was passed to use(): ' + String(usable));
}

thenable 객체 처리

let thenableIndexCounter: number = 0;
let thenableState: ThenableState | null = null;

export function createThenableState(): ThenableState { // Thenable 상태 초기화
  return [];
}

function useThenable<T>(thenable: Thenable<T>): T {
  // 각 Promise 객체에 고유한 인덱스를 할당하여 구분
  const index = thenableIndexCounter;
  thenableIndexCounter += 1;
  if (thenableState === null) {
    // Thenable 상태가 없다면 초기화(단순히 배열을 생성하는 것이다)
    thenableState = createThenableState();
  }
  const result = trackUsedThenable(thenableState, thenable, index);
  const workInProgressFiber = currentlyRenderingFiber; // 현재 렌더링 중인 Fiber
  const nextWorkInProgressHook =
    workInProgressHook === null
      ? workInProgressFiber.memoizedState
      : workInProgressHook.next; // 다음 작업 중인 Hook을 식별

  if (nextWorkInProgressHook !== null) {
  } else {
    // 훅의 디스패처 설정
    const currentFiber = workInProgressFiber.alternate;
    // 컴포넌트가 마운트, 업데이트 되는지에 따라 적절한 디스패처 선택
    ReactSharedInternals.H =
      currentFiber === null || currentFiber.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }
  return result; // Promise에서 해결된 값이거나 아직 해결되지 않은 값일 수 있다.
}

⭐ 정리
1. 각 thenable 객체에 고유한 인덱스를 부여해서 구분하고 thenableState가 없다면 배열로 초기화한다.
2. 그 후 thenable 객체를 시작하고 추적할 수 있는 trackUsedThenable 함수를 호출한다.
3. 컴포넌트가 초기 마운트이거나 업데이트인지 상태에 따라 적절한 디스패치를 설정한다.
4. 마지막으로 결과를 반환하는데, 이 때 결과는 Promise의 상태에 따라 달라진다.

여기서 나오는 thenableIndexCounterthenableState는 실제로 각 fiber에 대해 finishRenderingHooks()에서 초기화된다. 이는 thenableState가 매 렌더링마다 새로 생성된다는 것을 의미한다.

trackUsedThenable thenable 객체 추적 함수

최대한 DEV 부분은 제외했다. 직접 구현할 수 없는 부분이기 때문이다.

function noop(): void {} // 빈 함수(핸들러 추가용, 메모리 누수 방지)
export function trackUsedThenable<T>(
  thenableState: ThenableState,
  thenable: Thenable<T>,
  index: number,
): T {
  const trackedThenables = getThenablesFromState(thenableState);
  const previous = trackedThenables[index];
  if (previous === undefined) {
  // 새로운 thenable이라면 추적한다.
  trackedThenables.push(thenable);
  }else {
    if (previous !== thenable) {
      // 기존과 다르면 이전 thenable은 유지한다.
      // noop 함수는 빈 함수로, thenable에 핸들러를 추가하여 메모리 누수를 방지한다.
      thenable.then(noop, noop);
      thenable = previous;
    }
  }
 // 상태에 따라 다르게 처리한다.
 switch (thenable.status) {
    case 'fulfilled': {
      const fulfilledValue: T = thenable.value;
      return fulfilledValue;
    }
    case 'rejected': {
      const rejectedError = thenable.reason;
      checkIfUseWrappedInAsyncCatch(rejectedError);
      throw rejectedError;
    }
    default: {
	    // 상태가 문자열이라면 noop 핸들러를 추가한다.
      if (typeof thenable.status === 'string') {
      thenable.then(noop, noop);
      } else {
      const root = getWorkInProgressRoot();
        if (root !== null && root.shellSuspendCounter > 100) {
        // suspense 관련 작업의 수가 100을 초과한 경우 에러를 throw
        throw new Error(
            'async/await is not yet supported in Client Components, only ' +
              'Server Components. This error is often caused by accidentally ' +
              "adding `'use client'` to a module that was originally written " +
              'for the server.',
          );
        }
        // 상태를 pending으로 설정하고 성공 및 실패에 대한 callback
        const pendingThenable: PendingThenable<T> = (thenable: any);
        pendingThenable.status = 'pending';
        pendingThenable.then(
        // success callback하는 부분
          fulfilledValue => {
            if (thenable.status === 'pending') {
              const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
              fulfilledThenable.status = 'fulfilled';
              fulfilledThenable.value = fulfilledValue;
            }
          },
          // error callback하는 부분
          (error: mixed) => {
            if (thenable.status === 'pending') {
              const rejectedThenable: RejectedThenable<T> = (thenable: any);
              rejectedThenable.status = 'rejected';
              rejectedThenable.reason = error;
            }
          },

        );
      }
      // Check one more time in case the thenable resolved synchronously.
      switch (thenable.status) {
      // ... 생략
       suspendedThenable = thenable;
      throw SuspenseException; // Suspense 예외를 throw

    }
  }
}

⭐ 정리

  1. thenable 추적 배열에서 이전 thenable 확인
    ❌ : 새로 추가
    ✅ : 기존 것과 비교하여 처리를 한다. (기존 것과 다르면 기존 것을 재사용하고 noop핸들러에 추가하여 메모리를 관리한다.)

  2. thenable 상태가 fulfilled라면 값을 반환하고, rejected라면 에러를 던진다.

  3. pending 상태라면 빈 핸들러를 추가하여 메모리 누수를 방지한다.

  4. 위 조건에 부합하지 않는 경우(커스텀 thenable 객체)라면 현재 트리의 루트를 가져와 shellSuspendCounter(suspense 관련 작업의 수)를 체크하고 100을 초과한다면 에러를 던진다.

  5. 커스텀 thenable에서 처리를 하고 성공 or 실패했을 때 동작할 핸들러(상태 변경, 값 반환 등)를 추가한다.
    상태가 fulfilled라면 값을 반환하고, rejected라면 에러를 던진다.

  6. 위 조건에 부합하지 않는 경우(미해결 된 pending 상태) Suspense 상태로 간주하여 suspendedThenable에 thenable을 저장하고 SuspenseException를 throw한다.

💡 noop 함수를 핸들러에 추가하는 이유

Promise에 .then() or .catch() 핸들러가 없으면, 그 Promise는 가비지 컬렉션이 지연될 수 있어 메모리 누수의 원인이 될 수 있다.

  • noop이라는 빈 함수를 핸들러에 추가함으로써 Promise가 해결되거나 거부될 때 처리된 것으로 간주하고 필요 없어졌을 때 가비지 컬렉션의 대상으로 만들 수 있다.

readContext?

이거는 useContext()는 단순히 readContext()의 별칭이라고 보면 되는데 그 이유는 내부적으로 같은 의미로 쓰이기 때문이다. useContext의 동작 원리는 여기서 다루진 않겠다.

const HooksDispatcherOnMount: Dispatcher = {
  readContext,
  use,
  useCallback: mountCallback,
  useContext: readContext, 
}

use Hook을 조건문이나 반복문 내부에서 호출할 수 있는 이유

대부분의 useXXX() 훅들은 Fiber 노드에서 데이터를 저장하고 접근하며, 이 데이터 포인트들은 연결 리스트를 형성하여 해당 Fiber에 연결된다.

useState를 예시로 들자면 mountWorkInProgressHook()updateWorkInProgressHook()이 연결 리스트에서 올바른 데이터를 설정하고 가져오므로 호출 순서가 중요하다.

  • 초기 마운트 단계에서 mountWorkInProgressHook를 호출하여 새로운 Hook 노드를 생성하고 연결 리스트에 추가한다. 이 Hook 노드에 초기 상태 값을 저장한다.
  • 업데이트 단계에서 updateWorkInProgressHook를 호출하여 기존 Hook 노드를 찾아 업데이트한다. 이 과정에서 이전 상태 값을 읽고 새로운 상태 값을 설정한다.

즉 React에서는 매 렌더링마다 같은 순서로 Hook이 호출되어야 React가 올바른 Hook 노드를 찾아 데이터를 관리할 수 있기 때문에 useState와 같은 React Hook들은 호출 순서가 중요한 것이다.

하지만 useThenable()의 경우 데이터는 promise 자체에 첨부되고, readContext()의 경우 데이터는 Context Provider의 가장 가까운 조상 Fiber 노드에서 가져온다.

그렇기 때문에 use Hook은 useThanble과 readContext를 호출하기 때문에 조건문이나 반복문에서 호출될 수 있는 것이다.

🤷 그러면 readContext를 호출하는 useContext도 조건문이나 반복문에서 사용할 수 있지 않을까?
useContext도 조건문이나 반복문 내에서 사용해도 문제가 없지만 Lint Rule에서 경고를 표시한다고 한다.


참고자료

[React] React 19 기능 다시 살펴보기

use – React

New React 19 use hook–deep dive

New React 19 Features You Should Know – Explained with Code Examples

How to use... "use", the new React 19 API

use Hook의 내부 동작 원리

How does use() work internally in React?

profile
예비 초보 개발자의 기록일지

0개의 댓글

관련 채용 정보