Cllaude99Cllaude99

React 19에서 새롭게 추가된 기능들

·Cllaude99
ReactReact 19HooksActions

React 18에서 폼을 다룰 때, 아래 코드와 같은 패턴을 반복적으로 작성하고 계시지 않으셨나요?

function UpdateName() {
  const [name, setName] = useState('');
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async () => {
    setIsPending(true);
    try {
      const error = await updateName(name);
      if (error) {
        setError(error);
        return;
      }
      redirect('/profile');
    } catch (e) {
      setError(e);
    } finally {
      setIsPending(false);
    }
  };

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>
      {error && <p>{error}</p>}
    </div>
  );
}

isPending을 통해 로딩 상태를 직접 관리하고, try-catch로 에러를 잡고, finally에서 로딩 상태를 되돌리고, 어떻게 보면 이러한 과정비동기 처리를 할 때마다 매번 반복되는 보일러플레이트였어요.

React 19는 이런 반복적인 패턴들을 프레임워크 레벨에서 지원하기 시작했습니다.

이번 글에서는 React 19에서 새롭게 추가되거나 개선된 기능들을 정리해보려고 해요.

useTransition - 비동기 transition의 확장

기본 개념

useTransition은 UI를 차단하지 않으면서 상태를 업데이트할 수 있게 해주는 훅이에요.

const [isPending, startTransition] = useTransition();
  • isPending: transition이 진행 중인지를 나타내는 boolean 값
  • startTransition: 상태 업데이트를 transition으로 표시하는 함수

React 18에서도 useTransition은 존재했지만, startTransition동기 함수만 전달할 수 있었어요. React 19부터는 async 함수를 전달할 수 있게 되었습니다. async transition이 진행되는 동안 React가 isPendingtrue로 유지해주기 때문에, 로딩 상태를 직접 관리할 필요가 없어졌어요.

앞서 본 보일러플레이트를 useTransition으로 개선하면 이렇게 바뀝니다.

// React 19 - useTransition으로 비동기 처리
function UpdateName() {
  const [name, setName] = useState('');
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();

  const handleSubmit = () => {
    startTransition(async () => {
      const error = await updateName(name);
      if (error) {
        setError(error);
        return;
      }
      redirect('/profile');
    });
  };

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>
      {error && <p>{error}</p>}
    </div>
  );
}

useStateisPending을 직접 관리하던 코드가 사라지고, useTransition이 제공하는 isPending으로 대체된 것을 확인할 수 있어요.

startTransition (독립 함수)

useTransitionstartTransition과 별도로, React에서 직접 import할 수 있는 **독립 함수 startTransition**도 있어요.

import { startTransition } from 'react';

// 컴포넌트 외부에서도 사용 가능
startTransition(async () => {
  await saveData(data);
});

두 가지의 핵심 차이는 다음과 같아요.

useTransitionstartTransition
pending 상태 추적isPending 제공제공하지 않음
사용 위치컴포넌트 내부 (훅)어디서든 가능
주요 용도로딩 UI 표시가 필요한 경우pending 상태가 불필요한 경우

로딩 인디케이터를 보여줘야 한다면 useTransition을, pending 상태 추적 없이 transition만 시작하면 된다면 독립 함수 startTransition을 사용하면 됩니다.

Actions - 비동기 처리의 통합 패턴

이렇게 startTransition이 async 함수를 지원하게 되면서, React 19에서는 이를 Actions라는 개념으로 정의하고 있어요. ( async 함수를 사용하는 transition을 "Action"이라고 부릅니다. )

Action의 상태 흐름을 다이어그램으로 표현하면 다음과 같아요.

Actions는 단순히 pending 상태 관리를 넘어서, 에러 처리, 낙관적 업데이트, 폼 자동 리셋까지 포함하는 통합된 패턴이에요. 이후에 소개할 useActionState, useFormStatus, useOptimistic 모두 이 Actions 개념 위에서 동작합니다.

useActionState - 폼 상태를 한 번에 관리하기

기본 사용법

useActionState는 Action의 결과와 pending 상태를 한 번에 관리할 수 있는 훅이에요.

const [state, formAction, isPending] = useActionState(actionFn, initialState);
  • actionFn: (previousState, formData) => newState 형태의 함수
  • initialState: 초기 상태 값
  • state: Action의 마지막 반환 값
  • formAction: <form>action에 전달할 수 있는 래핑된 함수
  • isPending: 현재 Action이 실행 중인지 여부

장바구니에 상품을 추가하는 폼을 예시로 살펴볼게요.

import { useActionState } from 'react';

function AddToCartForm({ itemId, itemTitle }) {
  const [message, formAction, isPending] = useActionState(
    async (previousState, formData) => {
      const error = await addToCart(itemId);
      if (error) {
        return `${itemTitle} 추가 실패: ${error}`;
      }
      return `${itemTitle}이(가) 장바구니에 추가되었습니다.`;
    },
    null,
  );

  return (
    <form action={formAction}>
      <h2>{itemTitle}</h2>
      <button type="submit" disabled={isPending}>
        {isPending ? '추가 중...' : '장바구니에 담기'}
      </button>
      {message && <p>{message}</p>}
    </form>
  );
}

useState로 에러 상태, 성공 메시지, 로딩 상태를 각각 관리하던 것이 useActionState 하나로 깔끔하게 정리되었어요. 또한 <form>action prop에 직접 전달할 수 있어서, 폼 제출 시 자동으로 리셋되는 것도 장점이에요.

서버 액션과의 연동

useActionState은 Next.js의 Server Actions와 함께 사용할 때 더욱 편리하게 사용할 수 있어요.

// actions.ts
'use server';

export async function createTodo(previousState, formData) {
  const title = formData.get('title');

  if (!title || title.toString().trim() === '') {
    return { error: '할 일을 입력해주세요.' };
  }

  await db.todos.create({ data: { title: title.toString() } });
  return { error: null };
}
// TodoForm.tsx
'use client';

import { useActionState } from 'react';
import { createTodo } from './actions';

function TodoForm() {
  const [state, formAction, isPending] = useActionState(createTodo, {
    error: null,
  });

  return (
    <form action={formAction}>
      <input type="text" name="title" placeholder="할 일을 입력하세요" />
      <button type="submit" disabled={isPending}>
        {isPending ? '추가 중...' : '추가'}
      </button>
      {state.error && <p style={{ color: 'red' }}>{state.error}</p>}
    </form>
  );
}

위의 예시처럼 클라이언트 코드에서 별도의 API 호출 로직 없이, 서버 액션이 자연스럽게 폼과 연결되는 것을 확인할 수 있어요.

useFormStatus - Props Drilling 없이 폼 상태 읽기

useFormStatus는 부모 <form>의 제출 상태를 Context처럼 읽을 수 있는 훅이에요.

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? '제출 중...' : '제출'}
    </button>
  );
}

이 훅의 핵심은 부모 <form>의 상태를 prop으로 전달받을 필요가 없다는 점이에요.

function ContactForm() {
  const [state, formAction] = useActionState(submitContact, null);

  return (
    <form action={formAction}>
      <input type="text" name="name" placeholder="이름" />
      <input type="email" name="email" placeholder="이메일" />
      <textarea name="message" placeholder="메시지" />
      {/* isPending을 prop으로 넘기지 않아도 됩니다 */}
      <SubmitButton />
      {state?.error && <p>{state.error}</p>}
    </form>
  );
}

한 가지 주의할 점은, useFormStatus반드시 <form> 내부에 렌더링되는 컴포넌트에서 호출해야 한다는 거예요. 같은 컴포넌트 내의 <form>이 아니라, 부모의 <form>을 바라보고 있어요.

// 올바른 사용 - form 내부의 자식 컴포넌트에서 호출
function Form() {
  return (
    <form action={someAction}>
      <SubmitButton /> {/* 이 안에서 useFormStatus를 호출해야 함 */}
    </form>
  );
}

// 잘못된 사용 - form과 같은 레벨에서 호출
function Form() {
  const { pending } = useFormStatus(); // 부모 form이 없으므로 동작하지 않음
  return <form action={someAction}>...</form>;
}

useOptimistic - 낙관적 업데이트 간소화

낙관적 업데이트란?

SNS에서 좋아요 버튼을 누르는 상황을 생각해볼게요.

이때, 서버에 요청을 보내고 응답이 올 때까지 기다린 후에야 하트가 채워진다면 사용자 입장에서는 답답하게 느껴질 수 있어요.

낙관적 업데이트는 서버 응답을 기다리지 않고, 요청이 성공할 것이라고 가정하고 UI를 먼저 업데이트하는 패턴이에요. 만약 요청이 실패하면 이전 상태로 되돌아가요.

useOptimistic 사용법

React 19에서는 useOptimistic 훅으로 이 패턴을 간단하게 구현할 수 있어요.

import { useOptimistic, useState, startTransition } from 'react';

function LikeButton({ postId, initialLiked, initialCount }) {
  const [{ liked, count }, setLikeState] = useState({
    liked: initialLiked,
    count: initialCount,
  });

  // reducer 형태: (현재 상태, 액션) => 다음 낙관적 상태
  const [optimistic, setOptimistic] = useOptimistic(
    { liked, count },
    (current, newLiked) => ({
      liked: newLiked,
      count: current.count + (newLiked ? 1 : -1),
    }),
  );

  const handleLike = () => {
    // setOptimistic은 반드시 startTransition 또는 Action 내부에서 호출해야 합니다
    startTransition(async () => {
      // UI를 먼저 업데이트
      setOptimistic(!optimistic.liked);

      // 서버에 요청
      const result = await toggleLike(postId);
      setLikeState({ liked: result.liked, count: result.count });
    });
  };

  return (
    <button onClick={handleLike}>
      {optimistic.liked ? '❤️' : '🤍'} {optimistic.count}
    </button>
  );
}

여기서 주의할 점은, setOptimistic반드시 startTransition 또는 Action 내부에서 호출해야 한다는 거예요. 일반 이벤트 핸들러에서 직접 호출하면 낙관적 상태가 유지되지 않아요.

또한 useOptimistic의 두 번째 인자로 reducer 함수를 전달하면, 여러 값을 하나의 상태로 묶어서 관리할 수 있어요. reducer는 (현재 상태, 액션) 형태로, Action 진행 중 부모 상태가 변경되더라도 최신 값을 기반으로 낙관적 상태를 재계산해줘요.

이 흐름을 시퀀스 다이어그램으로 표현해보면 다음과 같아요.

즉, useOptimisticasync Action이 진행되는 동안에만 낙관적 값을 보여주고, Action이 완료되면 자동으로 실제 값(liked, count)으로 돌아가는 것이에요. 따라서 서버 요청이 실패해도 별도의 롤백 코드를 작성할 필요가 없어요.

use() - 조건부로 Promise와 Context 읽기

Promise를 컴포넌트에서 직접 읽기

use는 React 19에서 새롭게 추가된 API예요. 기존의 훅들과 달리, 조건문이나 반복문 안에서도 호출할 수 있다는 것이 가장 큰 차별점이에요.

Suspense와 함께 사용하면, 컴포넌트에서 Promise를 직접 읽을 수 있어요.

import { use, Suspense } from 'react';

function Comments({ commentsPromise }) {
  // Promise가 resolve될 때까지 Suspense fallback이 표시됩니다
  const comments = use(commentsPromise);

  return (
    <ul>
      {comments.map((comment) => (
        <li key={comment.id}>{comment.text}</li>
      ))}
    </ul>
  );
}

function PostPage({ postId }) {
  // Promise를 생성하지만 await하지 않습니다
  const commentsPromise = fetchComments(postId);

  return (
    <article>
      <h1>게시글</h1>
      <Suspense fallback={<p>댓글 로딩 중...</p>}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
    </article>
  );
}

여기서 중요한 점은 commentsPromise부모 컴포넌트에서 생성하고, 자식 컴포넌트에서 use()로 읽는다는 거예요. 부모에서 Promise를 생성하기 때문에 자식이 렌더링되기 전에 데이터 fetching이 시작되어, 불필요한 워터폴을 방지할 수 있어요.

Context를 use()로 읽기

use는 Context도 읽을 수 있어요. 다만, useContext와의 핵심 차이는 조건문 내에서 호출이 가능하다는 점이에요.

import { use, createContext } from 'react';

const ThemeContext = createContext('light');

function Heading({ children }) {
  if (children == null) {
    return null;
  }

  // useContext와 달리, 조건부 반환 이후에도 호출할 수 있어요
  const theme = use(ThemeContext);

  return (
    <h1 style={{ color: theme === 'dark' ? '#fff' : '#000' }}>{children}</h1>
  );
}

useContext는 훅의 규칙에 따라 컴포넌트 최상위에서만 호출할 수 있었지만, use()는 early return 이후에도 호출할 수 있어서 더 유연한 코드를 작성할 수 있어요.

더 간결해진 API들

React 19에서는 기존 API들도 더 간결하게 개선되었어요.

ref를 prop으로 직접 전달하기

React 19부터는 함수 컴포넌트에서 ref를 일반 prop처럼 받을 수 있어요. 더 이상 forwardRef로 감싸지 않아도 됩니다.

// Before - forwardRef 필요
const MyInput = forwardRef(({ placeholder }, ref) => {
  return <input placeholder={placeholder} ref={ref} />;
});

// After - ref를 prop으로 직접 전달
function MyInput({ placeholder, ref }) {
  return <input placeholder={placeholder} ref={ref} />;
}

forwardRef는 향후 deprecated 될 예정이에요.

ref 콜백의 클린업 함수

ref 콜백에서 클린업 함수를 반환할 수 있게 되었어요. DOM 노드가 제거될 때 자동으로 호출됩니다.

function MeasuredComponent() {
  return (
    <div
      ref={(node) => {
        if (!node) return;

        const observer = new ResizeObserver((entries) => {
          for (const entry of entries) {
            console.log('크기 변경:', entry.contentRect);
          }
        });

        observer.observe(node);

        // 클린업 함수 - 컴포넌트 언마운트 시 호출
        return () => {
          observer.disconnect();
        };
      }}
    >
      크기가 변하는 요소
    </div>
  );
}

기존에는 ref 콜백에서 nodenull인지 확인하여 정리 로직을 작성해야 했는데, 이제는 클린업 함수로 명확하게 분리할 수 있게 되었어요.

Context를 Provider로 바로 사용하기

<Context.Provider> 대신 <Context>를 직접 Provider로 사용할 수 있어요.

const ThemeContext = createContext('light');

// Before
function App({ children }) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}

// After
function App({ children }) {
  return <ThemeContext value="dark">{children}</ThemeContext>;
}

Context.Provider는 향후 deprecated 될 예정이에요.

useDeferredValue - 무거운 UI 업데이트 지연시키기

useDeferredValue는 UI의 일부 업데이트를 지연시켜, 나머지 UI가 먼저 반응할 수 있도록 해줘요.

const deferredValue = useDeferredValue(value, initialValue?);

언제, 왜 사용할까?

검색 입력창에 타이핑할 때마다 수천 개의 아이템을 필터링하는 리스트가 있다고 해볼게요. 타이핑할 때마다 리스트가 다시 렌더링되면, 입력이 버벅거리는 현상이 발생할 수 있어요.

이럴 때 useDeferredValue를 사용하면, 입력 필드는 즉시 업데이트하면서 무거운 리스트의 리렌더링은 뒤로 미룰 수 있어요.

import { useState, useDeferredValue, memo } from 'react';

function App() {
  const [text, setText] = useState('');
  const deferredText = useDeferredValue(text);

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <SlowList text={deferredText} />
    </>
  );
}

// memo로 감싸야 useDeferredValue의 효과를 볼 수 있어요
const SlowList = memo(function SlowList({ text }) {
  const items = [];
  for (let i = 0; i < 250; i++) {
    items.push(<li key={i}>Text: {text}</li>);
  }
  return <ul>{items}</ul>;
});

text가 바뀌면 React는 먼저 이전 deferredText 값으로 리렌더링을 완료한 뒤, 백그라운드에서 새로운 값으로 리렌더링을 시도해요. 이 과정에서 타이핑 같은 긴급한 업데이트가 들어오면, 백그라운드 리렌더링을 중단하고 긴급한 업데이트를 먼저 처리해요.

여기서 한 가지 중요한 점은 SlowListmemo로 감싸야 한다는 거예요. memo가 없으면 deferredText가 같더라도 부모가 리렌더링될 때 SlowList도 함께 리렌더링되기 때문에, 지연 효과를 제대로 볼 수 없어요.

Suspense와 함께 사용하기

데이터 fetching과 함께 사용할 때는 Suspense와 결합하면 더욱 자연스러운 UX를 만들 수 있어요. 새로운 데이터를 불러오는 동안 Suspense fallback 대신 이전 결과를 흐리게 표시하는 패턴이에요.

import { Suspense, useState, useDeferredValue } from 'react';
import SearchResults from './SearchResults';

export default function App() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      <label>
        앨범 검색:
        <input value={query} onChange={(e) => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>로딩 중...</h2>}>
        <div
          style={{
            opacity: isStale ? 0.5 : 1,
            transition: isStale
              ? 'opacity 0.2s 0.2s linear'
              : 'opacity 0s 0s linear',
          }}
        >
          <SearchResults query={deferredQuery} />
        </div>
      </Suspense>
    </>
  );
}

query !== deferredQuery를 비교하면 현재 보여주는 결과가 오래된 것인지 알 수 있어요. 이를 활용해 오래된 결과에 opacity를 낮춰주면, 사용자에게 "새 결과를 불러오는 중"이라는 시각적 피드백을 줄 수 있어요.

React 19에서 추가된 initialValue

React 19에서는 useDeferredValueinitialValue 옵션이 추가되었어요. 초기 렌더링에서는 initialValue를 반환하고, 백그라운드에서 실제 값으로 리렌더링을 예약해요.

function Search({ deferredValue }) {
  // 초기 렌더링에서는 빈 문자열, 이후 deferredValue로 업데이트
  const value = useDeferredValue(deferredValue, '');

  return <Results query={value} />;
}

initialValue를 생략하면 초기 렌더링에서 지연 없이 바로 원래 값을 사용해요. 하지만 initialValue를 지정하면 초기 렌더링을 빠르게 완료한 뒤, 무거운 컴포넌트의 렌더링을 백그라운드로 미룰 수 있어요.

문서 메타데이터와 리소스 관리

컴포넌트 안에서 메타데이터 선언하기

React 19부터는 <title>, <meta>, <link> 태그를 컴포넌트 내부에서 직접 렌더링할 수 있어요. React가 자동으로 <head>로 호이스팅해줍니다.

function BlogPost({ post }) {
  return (
    <article>
      <title>{post.title}</title>
      <meta name="author" content={post.author} />
      <meta name="keywords" content={post.keywords} />
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

기존에는 react-helmet 같은 서드파티 라이브러리가 필요했지만, 이제 React만으로 가능해졌어요.

리소스 프리로딩 API

react-dom에서 리소스 프리로딩을 위한 새로운 API들이 추가되었어요.

API역할사용 예시
prefetchDNSDNS 조회만 미리 수행곧 방문할 외부 도메인
preconnectDNS + TCP + TLS 연결 수립API 서버에 대한 사전 연결
preload리소스를 미리 다운로드폰트, 이미지, 스타일시트
preinit리소스를 다운로드하고 즉시 실행스크립트, 스타일시트
import { prefetchDNS, preconnect, preload, preinit } from 'react-dom';

function App() {
  // 외부 API 도메인에 미리 연결
  preconnect('https://api.example.com');

  // 중요 폰트 미리 다운로드
  preload('https://example.com/font.woff2', { as: 'font' });

  // 핵심 스크립트 미리 로드 및 실행
  preinit('https://example.com/analytics.js', { as: 'script' });

  return <div>...</div>;
}

이 API들을 활용하면 페이지 초기 로드 성능과 클라이언트 사이드 네비게이션 성능을 개선할 수 있어요.

참고 자료