blan19.com

React 19 beta에 포함된 새로운 feature, hook들과 개선사항들을 알아보자

May 15, 2024 (7mo ago)

......

React 19 beta가 릴리즈되었습니다.

많은 사람의 기대를 모았던 React 19 stable 버전의 릴리즈가 조만간 코앞에 오긴 했나 봅니다.

React Server Components(RSC)와 Action 등과 같은 새로운 기능과 몇 가지 개선사항들이 포함되었는데, 차근차근 정리해보도록 하겠습니다

현재 react 19 beta를 설치하기 위해서는 따로 베타버전을 명시해 설치해야 합니다.

npm install react@beta react-dom@beta

따로 타입스크립트를 지원하기 위해선 오버라이드도 필요합니다.

{
  "devDependencies": {
    "@types/react": "npm:types-react@beta",
    "@types/react-dom": "npm:types-react-dom@beta"
  },
  "overrides": {
    "@types/react": "npm:types-react@beta",
    "@types/react-dom": "npm:types-react-dom@beta"
  }
}

React 19 New Feature

Action

리액트 팀은 `Actions`에 대해 다음과 같이 정의했습니다

`Async Transition을 사용하는 함수`

일반적으로 19 beta 이전 18에서는 transition에 비동기 사용이 불가능했습니다.

React 19 beta부턴 async trasition을 지원하며 이제 우리는 사용자 인터랙션에 영향을 주지 않으면서 데이터 패치 등의 비동기 작업을 더 효율적으로 처리할 수 있게 되었습니다.

Action을 사용하기 이전 일반적인 리액트 앱의 코드에서 데이터 페칭에 따른 상태 업데이트를 관리하는 것은 복잡했습니다.

트랜지션이란? 자바스크립트의 싱글 스레드라는 한계에도 React 18의 동시성 모드를 통해 동시성 렌더링을 이루어 냈습니다. 동시성 렌더링은 렌더링 태스크를 잘게 쪼개 우선순위에 따라 렌더링을 합니다. 우선순위는 React의 Lane 모델을 따르고 있으며, 우선순위 기반 스케줄링, 렌더링 중단/재개 가능 등을 가능케 합니다. Transition lane은 낮은 우선순위의 작업을 처리하기 위한 특수한 lane이며 Lane 모델에서 가장 낮은 우선순위를 가지고 있습니다. Transition API를 사용하면 해당 작업이 transition lane에 할당되며 UI를 차단하지 않고 상태 업데이트가 가능합니다.

// before Action
const UpdateName = ({ currentName }) => {
  const [name, setName] = useState("");
  const [newName, setNewName] = useState(currentName);
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(false);

  const handleSubmit = async () => {
    setIsPending(true);
    setNewName(newName);
    const { error } = await updateName(name);
    setIsPending(false);
    if (error) {
      setError(error);
      return;
    }
    redirect("/path");
  };

  return (
    <div>
      <p>Your name is {newName}</p>
      <input value={name} onChange={(event) => setName(event.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>
        Update
      </button>
      {error && <p>{error}</p>}
    </div>
  );
};

리액트 팀은 data fetching에 따른 복잡성이 증가 되는 것을 Action을 통해 해결하고자 하며 그 대상들은 아래와 같습니다.

  • Pending State: 요청이 시작될 때 펜딩 상태가 활성화되며, 최종 상태 업데이트가 커밋되면 자동으로 리셋됩니다.
  • 낙관적 업데이트(optimistic update): useOptimistic 훅을 사용하면 요청 제출 중에도 즉시 피드백을 사용자에게 보여줄 수 있습니다.
  • 에러 처리: 요청이 실패하면 에러 바운더리를 표시할 수 있고, 낙관적 업데이트를 자동으로 원래 값으로 되돌릴 수 있습니다.
  • Forms: form 기능이 개선되었습니다. <form> 엘리먼트에서 action과 formAction 프로퍼티에 함수를 전달할 수 있습니다. action 프로퍼티에 함수를 전달하면 기본적으로 액션을 사용하며, 제출 후 폼이 자동으로 리셋됩니다.
// Using pending state from Actions
const UpdateName = ({ currentName }) => {
  const [name, setName] = useState("");
  const [newName, setNewName] = useState(currentName);
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();

  const handleSubmit = async () => {
    startTransition(async () => {
      setNewName(newName);
      const { error } = await updateName(name);
      if (error) {
        setError(error);
        return;
      }
      redirect("/path");
    });
  };

  return (
    <div>
      <p>Your name is {newName}</p>
      <input value={name} onChange={(event) => setName(event.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>
        Update
      </button>
      {error && <p>{error}</p>}
    </div>
  );
};

액션에 대해 요약하자면, 비동기 작업 중에도 펜딩 상태, 낙관적 업데이트, 에러 처리 등을 자동으로 관리해주며, 폼 제출 프로세스도 액션을 통해 개선되었다는 점입니다.

React 19 New Hooks

useActionState

이 훅은 기존의 `useFormState` 의 한계를 개선하여 나온 훅입니다.

Actions의 일반적인 경우에 사용되는 훅이며 기존 useFormState의 사용법과 크게 다르지 않습니다.

이름이 변경된 이유는 core PR에 나와 있다시피 useFormState의 훅 이름이 일반적인 오해를 불러올 수 있으며 훅에 pending 상태를 제공하기엔 form에 대한 pending 상태가 아니라 action에 대한 pending 상태를 반환하기 때문입니다.

주의깊게 봐야 할 점은 `useActionState` 훅은 특정 <form>과 관련이 없으므로 꼭 <form>과 함께 사용할 필요는 없다는 점입니다.

꼭 함께 사용할 필요는 없다는 말이지 <form>과 같이 사용한다면 여러 가지 이점이 있습니다.

const UseActionState = () => {
  const [state, action, isPending] = useActionState(
    async (_: string, formData: FormData) => {
      const newName = await updateName(formData.get("name") as string);
      return newName;
    },
    ""
  );

  return (
    <Form action={action} className="flex flex-col items-start">
      <h1 className="pb-2">useActionState</h1>
      {isPending ? <p>loading...</p> : <p>Your name is {state}</p>}
      <input name="title" className="border border-black my-3" />
      <Form.Button>Submit</Form.Button>
    </Form>
  );
};

useFormStatus

디자인 시스템을 고려해 컴포넌트들을 구성할 때, props drilling을 하지 않고 상위 컴포넌트에 대한 상태를 액세스하는 것이 일반적입니다. 보통 Context를 사용하여 해결하지만 <form>에 대한 경우엔 `useFormStatus` 훅을 통해 이 경우를 더 쉽게 해결할 수 있습니다.

`useFormStatus` 훅은 <form>이 Context provider인 것처럼 <form>에 대한 상태를 읽습니다.

import type { PropsWithChildren } from "react";
import { useFormStatus } from "react-dom";

const Button = ({ children }: PropsWithChildren) => {
  const status = useFormStatus();
  return (
    <button
      type="submit"
      className={`py-3 px-2 bg-black text-white font-bold rounded ${
        status.pending ? "opacity-65" : ""
      }`}
      disabled={status.pending}
    >
      {children}
    </button>
  );
};

export default Button;

이 훅으로 우리는 <form> 컴포넌트 깊이에 따른 props drilling을 크게 고민하지 않아도 됩니다.

useOptimistic

서버를 통해 data mutation을 진행할 때 네트워크 응답을 기다리는 동안엔 사용자 인터랙션에 의한 행위가 반영되지 않습니다.

React에서 일반적인 경우엔 낙관적 업데이트를 통해 이를 해결합니다.

우리는 이제 `useOptimistic` 훅을 통해 작업을 완료하는 데 시간이 걸리더라도 사용자에게 즉시 작업의 결과를 표시할 수 있습니다.

function ChangeName({ currentName, onUpdateName }) {
  const [optimisticName, setOptimisticName] = useOptimistic(currentName);

  const submitAction = async (formData) => {
    const newName = formData.get("name");
    setOptimisticName(newName);
    const updatedName = await updateName(newName);
    onUpdateName(updatedName);
  };

  return (
    <form action={submitAction}>
      <p>Your name is: {optimisticName}</p>
      <p>
        <label>Change Name:</label>
        <input
          type="text"
          name="name"
          disabled={currentName !== optimisticName}
        />
      </p>
    </form>
  );
}

use

Context나 Promise와 같은 리소스 값을 읽기 위한 API인 `use` 가 React 19 beta에 포함되었습니다.

개인적으로 이전부터 관심이 있었는데 그 이유는 아주 재밌고 흥미로운 훅이기 때문이었습니다.

`use` 훅이 등장한 이유는 서버 컴포넌트의 등장으로 async 컴포넌트를 서버 컴포넌트에 한해 만드는 게 가능해졌습니다.

하지만 기존 클라이언트 컴포넌트에서 비동기 처리를 하기 위해선 직접 컴포넌트 내에서 바로 처리를 못 하고 useEffect 훅 내부에서 비동기 처리를 해야 했습니다.

리액트 팀에서 이러한 어려움을 해결하기 위해 리액트만의 `async/await` 모델을 구현한 것이 use 훅입니다. async 함수 내에서만 await를 사용할 수 있는 것처럼, use는 React 컴포넌트와 훅 내에서만 사용할 수 있습니다:

// read context
function Component() {
  const context = useContext(SometingContext);
  const context = use(SometingContext); // same
  return (...)
}

// read promise
function Component() {
  const somePromise = getData();

  return (
    <Suspense fallback={<p>loading...<p/>} >
      <Items key={item.id} resource={somePromise} />
    <Suspense />
  );
}

function Items({ resource }) {
  const data = use(resource);

  return data.map((item) => <Item key={item.id} item={item} />)
}

또한 use는 일반적인 다른 훅과 달리 조건, 블록, 루프 내에서 사용이 가능합니다. 이는 로직을 별도의 컴포넌트로 추출해 분리하지 않고도 데이터 로딩을 조건부로 대기할 수 있게 합니다.

function Note({ id, shouldIncludeAuthor }) {
  const note = use(fetchNote(id));

  let byline = null;
  if (shouldIncludeAuthor) {
    const author = use(fetchNoteAuthor(note.authorId));
    byline = <h2>{author.displayName}</h2>;
  }

  return (
    <div>
      <h1>{note.title}</h1>
      {byline}
      <section>{note.body}</section>
    </div>
  );
}

경우에 따라 `use` 훅에 전달된 Promise는 reject가 될 수 있으며 거부된 프로미스를 처리하는 방법은 2가지가 존재합니다.

주의해야 할 점은 `use`는 try-catch 블록에서 호출할 수 없습니다. try-catch 블록 대신 컴포넌트를 Error Boundary로 래핑하거나 Promise의 catch 메서드를 사용하여 대체 값을 제공해야합니다.

  • Promise.catch
import { Message } from "./message.js";

export default function App() {
  const messagePromise = new Promise((resolve, reject) => {
    reject();
  }).catch(() => {
    return "no new message found.";
  });

  return (
    <Suspense fallback={<p>waiting for message...</p>}>
      <Message messagePromise={messagePromise} />
    </Suspense>
  );
}
  • Error Boundary
import { use, Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";

export function MessageContainer({ messagePromise }) {
  return (
    <ErrorBoundary fallback={<p>⚠️Something went wrong</p>}>
      <Suspense fallback={<p>⌛Downloading message...</p>}>
        <Message messagePromise={messagePromise} />
      </Suspense>
    </ErrorBoundary>
  );
}

function Message({ messagePromise }) {
  const content = use(messagePromise);
  return <p>Here is the message: {content}</p>;
}

또 이번 React 19 beta에 cache API도 포함되어서 나올지 궁금했는데 아쉽게도 포함되지 않았습니다.

`use` 훅과 `cache` 는 서로 연관이 있기 때문에 이번 React 19 beta에는 cache API가 포함되지는 않았지만 나중에 19가 릴리즈가 된다면 cache API도 같이 포함되어 등장할 것 입니다.

`cache``use` 를 어떻게 보완하며 어떠한 관계가 있는지는 rfc 문서에 자세히 나와있습니다.

React 19 Improvements

ref

이제 props로 ref를 전달할 수 있게 되었습니다.

function MyInput({ placeholder, ref }) {
  return <input placeholder={placeholder} ref={ref} />;
}

//...
<MyInput ref={ref} />;

`forwardRef` 를 통해 ref를 전달할 이유가 없으므로 향후 버전에서는 deprecated 될 예정입니다.

<Context> as a provider

React 19에서는 `<Context.Provider>` 대신 `<Context>`를 Provider로 렌더링할 수 있습니다

const ThemeContext = createContext("");

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

따라서 <Context.Provider>는 곧 deprecated 될 예정입니다.

마무리

React 19 beta에 포함된 서버 컴포넌트(RSC)는 이번 글에서 다루지 않았습니다. 물론 다른 기능들도 간단히 설명하기에는 부족한 부분이 있지만, 특히 RSC의 경우 짧게 소개하면 오해의 소지가 있을 것 같아 자세히 다루지 않았습니다. RSC에 대해서는 별도로 작성한 React Server Components(RSC) 차근차근 이해하기 글을 참고하시면 더 자세한 내용을 확인할 수 있습니다.

글 작성에 도움을 준 레퍼런스들