메인 콘텐츠로 이동

React 19에서 없어지는 보일러플레이트

·-
링크 복사 완료!
ReactReact 19Frontend
React 19 로고 표지

들어가며

19에서 사라진 보일러플레이트가 꽤 있어요. forwardRef, Context.Provider, useState로 직접 관리하던 폼 상태와 Promise를 useEffect로 받던 패턴 같은 것들 말이에요. 18 코드와 19 코드를 나란히 두고 어디가 어떻게 달라지는지 그리고 내부적으로 React가 왜 이렇게 바꿀 수 있었는지까지 소스 코드를 까보면서 정리해볼게요.

이 글에서 인용한 React 소스 코드는 v19.2.0 태그 기준이에요. 이 블로그가 쓰는 [email protected]와는 패치 차이라 줄 번호와 구현 모두 거의 같아요.

1. forwardRef는 이제 그만

18에서 자식 컴포넌트가 ref를 받으려면 forwardRef로 감싸야 했어요.

// React 18
import { forwardRef } from "react";
 
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, ...props }, ref) => {
    return (
      <label>
        {label}
        <input ref={ref} {...props} />
      </label>
    );
  },
);
 
Input.displayName = "Input";

타입 두 개와 wrapper 함수를 같이 써야 했고 devtools에 이름이 잘 나오게 하려면 displayName도 따로 설정해야 했어요. 19에서는 ref가 그저 props 중 하나가 돼요.

// React 19
function Input({ label, ref, ...props }: InputProps) {
  return (
    <label>
      {label}
      <input ref={ref} {...props} />
    </label>
  );
}

forwardRef는 deprecated 됐고 codemod도 같이 제공돼요. 기존 코드를 한 번에 옮길 수 있어요.

npx types-react-codemod@latest preset-19 ./src

여기서 끝이 아니에요. ref 콜백에 cleanup 함수도 들어왔어요.

18에서는 ref 콜백이 cleanup을 반환할 수 없었어요. node가 unmount될 때는 콜백이 null을 인자로 다시 호출되는데 이걸 분기로 처리해야 했어요.

// React 18
<div
  ref={(node) => {
    if (node) {
      observer.observe(node);
    } else {
      // unmount 시점. 어떤 node를 unobserve해야 하지?
    }
  }}
/>

unmount 시점에는 node가 null로 들어오니 어떤 노드를 정리해야 할지 따로 들고 있어야 했어요. 19부터는 useEffect처럼 cleanup 함수를 반환할 수 있어요.

// React 19
<div
  ref={(node) => {
    observer.observe(node);
    return () => observer.unobserve(node);
  }}
/>

setup과 cleanup이 한 클로저 안에 같이 있어서 흐름이 훨씬 명확해요. node 참조도 자연스럽게 닫혀서 따로 들고 있을 필요가 없어요.

내부에선 어떻게 ref가 그냥 prop이 됐을까

JSX element를 만드는 ReactElement 함수를 보면 19에서 ref를 따로 받지 않고 props.ref에서 직접 꺼내요.

function ReactElement(type, key, props, owner, debugStack, debugTask) {
  // Ignore whatever was passed as the ref argument and treat `props.ref` as
  // the source of truth. The only thing we use this for is `element.ref`,
  // which will log a deprecation warning on access. In the next release, we
  // can remove `element.ref` as well as the `ref` argument.
  const refProp = props.ref;
  const ref = refProp !== undefined ? refProp : null;
  // ...
  element = {
    $$typeof: REACT_ELEMENT_TYPE,
    type, key, ref, props,
  };
}

packages/react/src/jsx/ReactJSXElement.js#L161-L223

주석이 잘 설명해주고 있어요. "ref 인자로 무엇이 들어왔든 무시하고 props.ref를 source of truth로 다룬다." 18까지는 ref가 두 번째 인자로만 전달돼서 함수 컴포넌트가 직접 받을 수 없었고 forwardRef라는 별도 wrapper로 ref를 prop처럼 받게 풀어줬어요. 19에선 그 wrapper 없이 props.ref로 바로 접근하니 forwardRef 자체가 사라질 수 있게 된 거예요.

element.ref 접근 자체는 deprecation 경고만 남겨두었고 다음 메이저 버전에서 완전히 빠질 예정이에요.

2. <Context> 자체가 Provider예요

18에서 Context를 쓰려면 항상 .Provider를 거쳐야 했어요.

// React 18
const ThemeContext = createContext("light");
 
function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Toolbar />
    </ThemeContext.Provider>
  );
}

19에서는 Context 자체가 컴포넌트가 돼요.

// React 19
function App() {
  return (
    <ThemeContext value="dark">
      <Toolbar />
    </ThemeContext>
  );
}

.Provider가 사라진 게 단순한 단축이 아니에요. React에는 historical하게 contextTypes/childContextTypes로 시작한 legacy Context API가 있었고 거기서 createContext + Provider 형태로 넘어왔어요. 두 API는 뗼레야 뗄 수 없었기 때문에 새 API에 Provider라는 이름을 따로 붙여둔 거였어요.

createContext 내부에서는

19의 createContext를 보면 단 한 줄이 새로 추가된 형태예요. 반환 객체의 Provider 필드에 자기 자신을 그대로 할당해요.

export function createContext<T>(defaultValue: T): ReactContext<T> {
  const context: ReactContext<T> = {
    $$typeof: REACT_CONTEXT_TYPE,
    _currentValue: defaultValue,
    _currentValue2: defaultValue,
    _threadCount: 0,
    Provider: (null: any),
    Consumer: (null: any),
  };
 
  context.Provider = context; // 추가된 한 줄
  context.Consumer = {
    $$typeof: REACT_CONSUMER_TYPE,
    _context: context,
  };
  // ...
  return context;
}

packages/react/src/ReactContext.js#L14-L46

context.Provider = context. 이 한 줄로 <MyContext>로 쓰든 <MyContext.Provider>로 쓰든 reconciler가 받게 되는 fiber의 type이 같은 객체가 돼요. 양쪽 표기를 동시에 지원할 수 있어요.

reconciler 쪽도 보면 fiber type을 그대로 context로 취급해요.

function updateContextProvider(current, workInProgress, renderLanes) {
  const context: ReactContext<any> = workInProgress.type;
  const newProps = workInProgress.pendingProps;
  const newValue = newProps.value;
 
  pushProvider(workInProgress, context, newValue);
 
  const newChildren = newProps.children;
  reconcileChildren(current, workInProgress, newChildren, renderLanes);
  return workInProgress.child;
}

packages/react-reconciler/src/ReactFiberBeginWork.js#L3607-L3632

workInProgress.typeReactContext<any>로 캐스팅하고 그대로 pushProvider에 넘겨요. context 객체 자체가 fiber의 type이라는 점이 핵심이에요. 사용자 입장에서는 .Provider를 떼는 단순한 단축처럼 보이지만 안쪽에서는 context 객체 자체가 컴포넌트의 자리를 받을 수 있도록 reconciler 분기가 정리된 결과예요.

Context.Consumer도 deprecated이고 use(Context)로 대체돼요.

// React 18
function Toolbar() {
  return (
    <ThemeContext.Consumer>
      {(theme) => <button className={theme}>Click</button>}
    </ThemeContext.Consumer>
  );
}
 
// React 19
function Toolbar() {
  const theme = use(ThemeContext);
  return <button className={theme}>Click</button>;
}

useContext도 그대로 동작하지만 use()는 할 수 있는 게 더 많아요. 다음 섹션에서 마저 살펴볼게요.

3. Actions: 폼 상태를 React가 가져가요

18에서 폼을 제출할 때는 보통 이렇게 썼어요.

// React 18
function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [isPending, setIsPending] = useState(false);
 
  const handleSubmit = async () => {
    setIsPending(true);
    setError(null);
    const error = await updateName(name);
    setIsPending(false);
    if (error) {
      setError(error);
      return;
    }
    redirect("/profile");
  };
 
  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>
        Update
      </button>
      {error && <p>{error}</p>}
    </div>
  );
}

pending과 error를 직접 관리해야 했고 요청이 서로 엇갈리는 경우도 직접 막아줘야 했어요. 같은 폼을 빠르게 두 번 제출하면 늦게 도착한 응답이 먼저 도착한 응답을 덮어쓰는 식의 버그가 흔했어요.

19에서는 Actions로 React가 이 부분을 가져가요.

// React 19
function UpdateName() {
  const [error, submitAction, isPending] = useActionState(
    async (_previousState: string | null, formData: FormData) => {
      const error = await updateName(formData.get("name") as string);
      if (error) return error;
      redirect("/profile");
      return null;
    },
    null,
  );
 
  return (
    <form action={submitAction}>
      <input name="name" />
      <button disabled={isPending}>Update</button>
      {error && <p>{error}</p>}
    </form>
  );
}

useActionState 훅 하나가 pending과 결과 상태를 한 번에 돌려줘요. <form action={...}>도 이제 함수를 받아서 submit 시 자동으로 폼을 reset하고 hydration 전에도 동작해요.

useFormStatus는 폼 안쪽 어디서든 부모 폼의 상태에 접근할 수 있어요. props drilling을 할 필요가 없이요.

// React 19
function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>Submit</button>;
}
 
function UpdateName() {
  return (
    <form action={submitAction}>
      <input name="name" />
      <SubmitButton />
    </form>
  );
}

depth가 깊이 있는 자식 컴포넌트가 조상 컴포넌트의 폼 상태를 알아야 할 때 따로 prop을 내려주지 않아도 돼요.

useOptimistic은 변경 요청이 처리되는 동안 화면에 미리 결과를 보여주는 훅이에요.

// React 19
function ChangeName({ currentName, onUpdateName }) {
  const [optimisticName, setOptimisticName] = useOptimistic(currentName);
 
  const submitAction = async (formData: FormData) => {
    const newName = formData.get("name") as string;
    setOptimisticName(newName);
    const updated = await updateName(newName);
    onUpdateName(updated);
  };
 
  return (
    <form action={submitAction}>
      <p>현재 이름: {optimisticName}</p>
      <input name="name" />
      <button type="submit">Update</button>
    </form>
  );
}

await가 끝나기 전까지 사용자에게는 newName이 보이고 응답이 오면 진짜 값으로 교체돼요. 응답이 실패해서 action이 에러를 던지면 React가 자동으로 원래 값으로 되돌려요.

세 훅이 한 세트로 움직여요. useActionState로 pending/error를 잡고 useFormStatus로 자식이 폼 상태에 접근하고 useOptimistic으로 응답 전 UI를 보여주는 방식이에요.

useActionState는 안에서 어떻게 동작할까

useActionState는 한 줄짜리 호출처럼 보이지만 mountActionState 안을 보면 React가 hook 세 개를 동작시켜요. 사용자가 useState를 세 번 호출한 것과 비슷한 효과예요.

function mountActionState<S, P>(
  action: (Awaited<S>, P) => S,
  initialStateProp: Awaited<S>,
  permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
  // State hook. The state is stored in a thenable which is then unwrapped by
  // the `use` algorithm during render.
  const stateHook = mountWorkInProgressHook();
  stateHook.memoizedState = stateHook.baseState = initialState;
 
  // Pending state. This is used to store the pending state of the action.
  // Tracked optimistically, like a transition pending state.
  const pendingStateHook = mountStateImpl((false: Thenable<boolean> | boolean));
 
  // Action queue hook. This is used to queue pending actions. The queue is
  // shared between all instances of the hook. Similar to a regular state queue,
  // but different because the actions are run sequentially, and they run in
  // an event instead of during render.
  const actionQueueHook = mountWorkInProgressHook();
  const actionQueue: ActionStateQueue<S, P> = {
    state: initialState,
    dispatch: (null: any),
    action,
    pending: null,
  };
  actionQueueHook.queue = actionQueue;
  // ...
  return [initialState, dispatch, false];
}

packages/react-reconciler/src/ReactFiberHooks.js#L2356-L2439

주석을 보면 (1) state hook, (2) pending state hook, (3) action queue hook. state hook의 값을 use 알고리즘으로 풀어준다는 내용이에요. action이 비동기로 끝날 수 있으니 결과를 thenable로 잡아두고 render 시점에 use()가 풀어내는 구조예요.

큐가 따로 있는 이유는 같은 폼이 빠르게 두 번 제출됐을 때 action을 순차적으로 실행하기 위해서예요. 18에서 race condition으로 새 응답이 옛 응답을 덮어쓰던 문제가 큐로 자연스럽게 정리돼요.

dispatch가 호출되면 큐에 들어간 action은 transition으로 감싸 실행돼요. transition이 뭐냐면 React 18에서 추가된 개념으로 '지금 당장 화면에 반영되지 않아도 괜찮은 업데이트'를 표시하는 장치예요. 예를 들어 검색창에 글자를 칠 때 입력 자체는 즉시 반영되어야 하지만 그 결과를 보여주는 무거운 리스트 렌더링은 잠깐 미뤄도 괜찮잖아요. startTransition으로 감싼 업데이트는 React가 사용자 입력 같은 급한 일을 먼저 처리하고 천천히 반영해줘요. 폼 제출도 같은 이유로 응답을 기다리는 동안 UI가 막히지 않도록 transition으로 감싸요.

function runActionStateAction(actionQueue, node) {
  const action = node.action;
  const payload = node.payload;
  const prevState = actionQueue.state;
 
  if (node.isTransition) {
    const prevTransition = ReactSharedInternals.T;
    const currentTransition: Transition = ({}: any);
    ReactSharedInternals.T = currentTransition;
    try {
      const returnValue = action(prevState, payload);
      const onStartTransitionFinish = ReactSharedInternals.S;
      if (onStartTransitionFinish !== null) {
        onStartTransitionFinish(currentTransition, returnValue);
      }
      handleActionReturnValue(actionQueue, node, returnValue);
    } catch (error) {
      onActionError(actionQueue, node, error);
    }
    // finally: prevTransition 복원
  }
}

packages/react-reconciler/src/ReactFiberHooks.js#L2148-L2199

ReactSharedInternals.T는 현재 어떤 transition이 진행 중인지 담아두는 React 내부의 자리예요. React는 setState가 일어날 때마다 이 자리를 들여다보고 "이번 업데이트를 transition으로 분류할지"를 결정해요. 이 자리에 transition이 들어 있으면 그 안에서 일어나는 setState가 모두 transition 업데이트로 분류돼요. 비어 있으면 평소대로 일반 업데이트로 다뤄지고요.

그래서 React는 사용자 action을 호출하기 직전에 이 자리에 새 transition을 채워둬요. 그러면 action 안에서 setState가 몇 번 일어나든 전부 자동으로 transition으로 처리돼요. action이 끝난 뒤에는 이전 값으로 되돌려서 바깥에는 영향이 없게 해요. 사용자가 직접 startTransition(() => ...)으로 감싸지 않아도 useActionState가 같은 효과를 자동으로 만들어주는 이유예요.

4. use(): Promise를 hook으로 사용하기

18에서 비동기 데이터를 컴포넌트에서 꺼내려면 useEffect로 풀거나 React Query 같은 라이브러리를 써야 했어요.

// React 18
function Comments({
  commentsPromise,
}: {
  commentsPromise: Promise<Comment[]>;
}) {
  const [comments, setComments] = useState<Comment[] | null>(null);
 
  useEffect(() => {
    const load = async () => {
      const data = await commentsPromise;
      setComments(data);
    };
    load();
  }, [commentsPromise]);
 
  if (!comments) return <Spinner />;
  return (
    <ul>
      {comments.map((comment) => (
        <li key={comment.id}>{comment.text}</li>
      ))}
    </ul>
  );
}

19에는 use()가 새로 들어왔어요.

// React 19
function Comments({
  commentsPromise,
}: {
  commentsPromise: Promise<Comment[]>;
}) {
  const comments = use(commentsPromise);
  return (
    <ul>
      {comments.map((comment) => (
        <li key={comment.id}>{comment.text}</li>
      ))}
    </ul>
  );
}
 
function Page() {
  return (
    <Suspense fallback={<Spinner />}>
      <Comments commentsPromise={fetchComments()} />
    </Suspense>
  );
}

use()는 Promise나 Context를 받아서 값을 꺼내줘요. Promise가 결과를 돌려줄 때까지 컴포넌트는 잠시 멈춰 있고 가장 가까운 Suspense 경계가 대체 화면을 보여줘요.

여기서 use()는 어떻게 Promise를 hook 자리에서 받을 수 있는 걸까요?

컴포넌트가 render 도중 use(promise)를 만나면 React가 promise 상태를 확인해요. 아직 진행 중이면 promise 자체를 던져서 컴포넌트 경계를 넘어 가장 가까운 <Suspense>까지 거슬러 올라가요. 그걸 받아서 대체 화면으로 바꾸고 promise가 끝나길 기다려요. 결과가 도착하면 React가 같은 컴포넌트를 다시 render하고 다시 만난 use(promise)는 미리 저장해둔 결과를 바로 돌려줘요.

핵심은 React가 Promise 객체 자체에 status/value/reason 같은 속성을 직접 붙여서 추적한다는 점이에요. 다음 render에서 같은 Promise 인스턴스를 만나면 then을 다시 걸 필요 없이 status로 바로 분기할 수 있어요. 단순화하면 이런 모양이에요.

type ReactPromise<T> = Promise<T> & {
  status?: "pending" | "fulfilled" | "rejected";
  value?: T;
  reason?: unknown;
};
 
function use<T>(promise: ReactPromise<T>): T {
  if (promise.status === "fulfilled") return promise.value!;
  if (promise.status === "rejected") throw promise.reason;
  throw promise; // 진행 중이면 Suspense까지 올림
}

실제 React 안의 trackUsedThenable은 좀 더 꼼꼼하게 처리해요.

switch (thenable.status) {
  case 'fulfilled': {
    const fulfilledValue: T = thenable.value;
    return fulfilledValue;
  }
  case 'rejected': {
    const rejectedError = thenable.reason;
    checkIfUseWrappedInAsyncCatch(rejectedError);
    throw rejectedError;
  }
  default: {
    if (typeof thenable.status === 'string') {
      thenable.then(noop, noop);
    } else {
      // 처음 보는 thenable: pending으로 마킹하고 status를 추적
      const pendingThenable: PendingThenable<T> = (thenable: any);
      pendingThenable.status = 'pending';
      pendingThenable.then(
        fulfilledValue => {
          if (thenable.status === 'pending') {
            const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
            fulfilledThenable.status = 'fulfilled';
            fulfilledThenable.value = fulfilledValue;
          }
        },
        (error: mixed) => {
          if (thenable.status === 'pending') {
            const rejectedThenable: RejectedThenable<T> = (thenable: any);
            rejectedThenable.status = 'rejected';
            rejectedThenable.reason = error;
          }
        },
      );
    }
    // 동기적으로 풀렸을 가능성 한 번 더 체크
    switch ((thenable: Thenable<T>).status) {
      case 'fulfilled':
        // return value
      case 'rejected':
        // throw reason
    }
 
    suspendedThenable = thenable;
    throw SuspenseException;
  }
}

packages/react-reconciler/src/ReactFiberThenable.js#L193-L286

핵심 포인트가 셋이에요. (1) thenable.status로 분기해서 fulfilled면 즉시 값을 돌려주고 rejected면 에러를 던져요. (2) 처음 보는 thenable이면 pending으로 마킹하고 then 콜백에서 status/value/reason을 직접 기록해둬요. 다음 render에서 같은 인스턴스를 만나면 status만 보고 끝나요. (3) pending 상태로 끝나면 마지막에 SuspenseException을 throw해요. Promise를 직접 던지지 않고 별도의 신호용 객체를 던지는 이유는 work loop가 "이건 정상적인 suspense 신호다"라고 구분하기 위해서예요. work loop가 이 객체를 잡아서 가장 가까운 Suspense 경계로 거슬러 올라가요.

이 구조 덕분에 use()는 hooks rules에서도 자유로워요. if 분기 안에서도 호출할 수 있어요.

function Comments({ commentsPromise, showComments }) {
  if (!showComments) return null;
 
  const comments = use(commentsPromise); // OK
  return <ul>...</ul>;
}

다른 hook이 if 안에 들어가면 안 되는 이유는 React가 hook을 '몇 번째로 호출됐는지' 순서로 식별하기 때문이에요. useState를 세 번 호출하면 React는 첫 번째 결과는 1번 사물함 두 번째 결과는 2번 사물함 식으로 보관해두고 다음 render에서도 같은 순서로 다시 꺼내줘요.

function Component({ flag }) {
  const [a] = useState(1); // 항상 1번째 호출
  if (flag) {
    const [b] = useState(2); // flag일 때만 2번째 호출
  }
  const [c] = useState(3); // flag가 true면 3번째, false면 2번째!
}

flag 값이 바뀌면 c가 자기 사물함이 아니라 b 자리를 읽게 돼요. 그래서 hook은 항상 같은 순서로 호출돼야 한다는 규칙을 두는 거예요.

use()는 이 사물함을 쓰지 않아요. 인자로 받은 Promise나 Context 인스턴스 자체로 결과를 찾기 때문에 호출 순서가 바뀌어도 자기 자리를 정확히 찾아가요. 그래서 if 안에서 호출해도 안전해요.

여기서 중요한 함정이 하나 있어요. Promise를 컴포넌트 안에서 만들면 안 돼요.

// 안 됨: 매 render마다 새 Promise가 생김
function Comments() {
  const comments = use(fetchComments());
  return <ul>...</ul>;
}

매 render마다 새 Promise 인스턴스가 만들어지니 React가 추적하던 status를 잃어버려요. 결과가 도착해도 다음 render에서 또 새 Promise를 만나서 영원히 진행 중 상태로 남아요. Promise는 부모(특히 Server Component나 page)에서 한 번 만들어 prop으로 내려주거나 RSC라면 cache()로 중복 호출을 방지해줘야 해요. 위 예제처럼요.

정리

버전이 올라가면서 나타난 변경들은 한 방향을 가리켜요. 사용자가 직접 만들어 쓰던 패턴이 React core 안으로 들어왔다는 거예요.

  • forwardRef -> ref가 props
  • Context.Provider -> Context 자체가 컴포넌트
  • 폼 관련 useState + try/catch -> useActionState
  • useEffect + Promise -> use()

각각 보면 단순한 문법 단축처럼 보일 수도 있지만 React가 책임을 더 가져가는 만큼 사용자 코드는 줄어들게 된 결과예요. 비동기와 폼 제출 그리고 ref cleanup처럼 자주 반복되던 패턴이 코어로 흡수된 거예요.

2편에서는 React Compiler를 파헤쳐볼게요. useMemo/useCallback이 사라지는 방향이라 체감되는 변화가 역시 큰 부분이에요.