리액트19 베타

마침내 npm에서 리액트 19 베타를 사용할 수 있다. 관련한 문서에서는 액션이라는 새로운 개념과 파생된 리액트 훅을 소개한다. 그동안 사용했던 API를 더 편하게 바꾸거나 일부 라이브러리 기능을 대체하는 개선사항도 있다. 이 문서를 통해 앞으로 정식 버전이 될 리액트 19의 변경사항을 미리 살펴보자.

Action과 useActionState

액션(Action)이란 개념을 소개한다.

By convention, functions that use async transitions are called “Actions”.

비동기 트랜지션을 사용하는 함수라고 한다.

이 말이 좀 어려웠는데 리덕스나 모빅스의 액션과 비슷하지 않을까? 18 버전에 나온 useTransition 훅이 UI를 차단하지 않고 상태를 변경한다고 한다. 액션은 트랜지션을 비동기로 처리한다.

컴포넌트에서는 useActionState 훅으로 액션을 사용할 수 있다. 폼을 다루는 일반적인 경우를 이 훅으로 손쉽게 구현할 수 있다고 하는데 아래 네 가지 역할을 한다.

  1. 보류(pending) 상태
  2. 낙관적 갱신(Optimistic update)
  3. 오류 상태
  4. 폼(Form) 앨리먼트

제시한 샘플코드를 보면서 살펴보자.

function ChangeName({ name, setName }) {
  // 액션을 정의한다.
  const action = async (previousState, formData) => {
    const newName = formData.get("name")

    try {
      await updateName(newName)
    } catch (e) {
      // 실패할 때는 오류를 반환한다.
      return e
    }

    setName(name)
    // 성공할 때는 null을 반환한다.
    return null
  }

  // 오류를 나타내는 초기 상태다.
  const initialState = null

  // 준비한 값을 useActionState 로 전달해 컴포넌트에서 액션을 사용할 수 있다.
  const [error, submitAction, isPending] = useActionState(action, initialState)
  return (
    // form 앨리먼트의 action 프롭에 submitAction을 전달한다.
    <form action={submitAction}>      <input type="text" name="name" defaultValue={name} />
      {/* 펜딩 플래그를 사용한다. */}
      <button type="submit" disabled={isPending}>        Update
      </button>
      {/* 오류를 표시한다. */}
      {error && <p>{error}</p>}    </form>
  )
}

훅에 전달할 action과 initialState 값을 준비했다. 액션은 비동기 트랜지션 함수인데 훅에 의해 관리되는 오류 상태와 폼 데이터가 이 함수 인자로 들어올 것이다. 함수 본문에서는 이 값으로 비동기 로직을 작성하는데 사용할 수 있겠다.

훅을 호출하면 배열을 반환하는데 이 중 세번째 값 isPending이 보류 상태다. 비동기 트랜지션의 진행 여부를 식별하는 역할이다. 리액트 앨리먼트에서는 이 값으로 제출 버튼의 비활성 여부를 제어했다.

액션 함수의 반환 값에 따라 리액트가 이 액션이 성공인지 실패인지를 판가름하는 모양이다. 배열의 첫번째 값 error로 전달해 준다. 리액트 앨리먼트에서는 이 값으로 오류를 표시한다.

배열의 두 번째 값 submitAction은 form 앨리먼트에 바로 전달할 수 있다. 리액트는 폼이 제출될 때 action에 전달된 함수를 실행해 useActionState에 전달한 액션을 호출하는 듯 하다. 이전 상태와 사용자가 입력한 필드 값을 액션으로 전달한다.

이처럼 폼 상태관리를 제공하기 위한 훅을 지원하는 게 인상적이다. formik이나 react-hook-form 라이브러리를 사용하던 방식에 영향을 줄 수 있을 것 같다.

useFormStatus

폼 제출 버튼을 컴포넌트로 분리한다면 대기 상태를 프롭으로 받아야 할 것이다. 프롭 드릴링이나 별도 컨택스트로 전달할 수 있다.

19에서는 useFormStatus 훅을 제공한다. 렌더 트리 부모에있는 폼 상태를 직접 조회하는 역할이다.

function DesignButton() {
  // 상위에 있는 form 상태를 바로 가져온다. 마치 컨택스트처럼.
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending}>      Update
    </button>
  )
}

useOptimistic

"Optimistic Update". 구글 번역기로 찾아보니 "낙관적 갱신"이라고 한다.

사용자가 입력한 값을 서버 API로 전달해 응답까지 받은 후 UI를 변경하는 것이 일반적인 방법이라면 낙관적 갱신은 API가 성공일 것이라고 가정하고 네트웍 요청 즉시 UI 상태를 바꾸는 것을 말한다. 비교적 빠른 사용자 경험을 만드는 기법이다. 물론 서버의 실패 응답을 받으면 UI도 이전 상태로 되돌려 놓는다.

useOptimistic 훅은 낙관적 갱신을 구현할 수 있는 훅이다.

function ChangeNameOptimistic({ name, setName }) {
  // 옵티미스틱 값과 세터
  const [optimisticName, setOptimisticName] = useOptimistic(name)
  const submitAction = async formData => {
    const newName = formData.get("name")

    // 옵티미스틱 값을 먼저 바꾼다.
    setOptimisticName(newName)
    // 비동기 로직을 실행한다.
    try {
      await updateName(newName)
    } catch (e) {
      return e
    }

    // 비동기 로직을 성공하면 실제 값을 바꾼다.
    setName(newName)
  }

  return (
    <form action={submitAction}>
      <p>OptimisticName: {optimisticName}</p>      <input type="text" name="name" defaultValue={name} />
      <DesignButton />
    </form>
  )
}

useOptimistic 함수에 기본 값을 전달하면 상태와 세터를 튜플로 반환한다.

form 앨리먼트에 전달할 액션 함수 submitAction을 정의했다. 리액트는 폼이 제출될 때 이 함수에 formData를 전달해주는데 이 객체를 통해 name 필드값을 조회했다. 이 값으로 optimisticName을 먼저 변경한다.

서버 API 호출을 비동기로 실행한 뒤 성공하면 setName으로 실제 이름을 변경한다. 실패하면 이 액션은 오류를 반환하고 리액트가 optimisticName을 이전 name으로 복구할 것이다.

use

use는 컴포넌트 안에서 프라미스가 이행될때까지 렌더링하지 않고 기다리는 훅이다. 예를 들어 서버로부터 데이터를 가져와 UI를 그리는 경우 사용할 수 있겠다.

function Comments({ commentsPromise }) {
  // 프라미스가 이행될때까지 렌더하지 않는다.
  const comments = use(commentsPromise)
  return comments.map(comment => <p key={comment.id}>{comment}</p>)
}

function Page() {
  // use가 프라미스가 이행될때까지 기다리는 동안,
  // 이 서스펜스 바운더리가 렌더될 것이다.
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Comments commentsPromise={fetch()} />
    </Suspense>
  )
}

Comments 는 서버에서 데이터를 가져올 때까지(commentsPromise 가 이행될 때 까지) 실행을 멈추고 기다린다. 그 동안 UI는 보이지 않을 것이다. 시간이 충분이 흘러 프라미스가 이행되면 실행을 재개해 리액트 앨리먼트를 반환할 것이다.

개선사항

기존 API에 대한 개선사항도 있다.

ref

더 이상 레프 객체를 전달할 때 forwardRef 함수를 사용하지 않아도 된다고 한다. 다른 프롭들처럼 ref로 전달한다.

// ref 프롭으로 레프 객체를 받는다.
const Field = ({ ref }) => {  return <input ref={ref} />
}

const RefTest = () => {
  const ref = useRef()

  useEffect(() => {
    if (ref.current) ref.current.focus()
  }, [])

  return <Field ref={ref} />
}

Context

훅이 없을 때는 Provider와 Consumer 컴포넌트를 사용했다. 훅이 나온후 Consumer를 useContext 훅으로 대체했는데 이번 업데이트로 Provider까지 Context로 대체할 수 있다고 한다.

const Context = createContext()

const Counter = () => {
  const { value, setValue } = React.useContext(Context)

  return (
    <>
      {value}
      <button onClick={() => setValue(value + 1)}>+1</button>
    </>
  )
}

const ContextTest = () => {
  const [value, setValue] = useState(1)

  return (
    // Context.Provider를 사용하지 않고 Context를 사용한다.
    <Context value={{ value, setValue }}>      <Counter />
    </Context>
  )
}

개발자 도구에는 여전히 Context.Provider로 나온다. Context가 JSX에서 사용되면 Context.Provider의 별칭이 되는 듯 하다.

Document Metadata

<title>, <meta>처럼 HTML 헤드 영역에 넣는 앨리먼트는 검색엔진 최적화나 오픈그래프, 브라우져 탭 이름으로 사용된다. 리액트 컴포넌트 입장에서는 일종의 부수 효과라서 useEffect로 처리하거나 아예 HTML 템플릿에서 작성하기도 한다. react-helmet 같은 전용 라이브러리를 사용하기도 했다.

19에서는 이 기능을 리액트에서 제공한다.

function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>

      {/* <head>에 마운트될 것이다 */}
      <title>{post.title}</title>

      {/* <head>에 마운트될 것이다 */}
      <meta name="author" content="Josh" />

      {/* <head>에 마운트될 것이다 */}
      <link rel="author" href="https://twitter.com/joshcstory/" />

      {/* <head>에 마운트될 것이다 */}
      <meta name="keywords" content={post.keywords} />

      <p>Eee equals em-see-squared...</p>
    </article>
  )
}

컴포넌트는 HTML 바디에 마운트 된다. 하지만 헤드에 있어야 할 코드도 컴포넌트 정의에 있는데 리액트는 이것을 올바른 장소인 헤드로 옮겨 줄 것이다.

정적 자원 관리

HTML 헤드에 들어갈 앨리먼트를 컴포넌트에서 다루는 방식은 CSS, 자바스크립트 같은 정적 파일도 포함한다.

function PrimaryButton() {
  return (
    <Suspense fallback="loading...">
      {/* <head>에 마운트될 것이다 */}
      <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
        precedence="default"
      />

      {/* <head>에 마운트될 것이다 */}
      <script
        async={true}
        src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js"
      />

      <button className="btn btn-primary">
        Twitter Bootstrap Primary Button
      </button>
    </Suspense>
  )
}

트위터 부트스트랩 스타일의 PrimaryButton 컴포넌트다. 번들에 라이브러리가 없다면 어플리케이션 실행 중에 CDN에서 동적으로 코드를 가져와야 한다. 컴포넌트에서 <link><script> 태그를 직접 사용하면 리액트는 자원을 가져오는 코드를 헤드에 마운트해 줄 것이다. 물론 같은 자원을 다른 컴포넌트에서 사용하면 중복 다운로드 받지 않도록 처리해 준다고 한다.

프리로딩, 프리패치도 해준다고 하는데 실험해 보지는 못했다.

결론

  • 액션을 관리하는 useActionState 훅이 추가 되었다. 폼 앨리먼트와 함께 사용하면 폼을 관리하는 일반적인 시나리오에 대응할 수 있을 것 같다.
  • 하위 컴포넌트에서는 useFormStatus로 상태에 직접 접근 할수 있다.
  • useOptimistic 훅을 사용하면 낙관적 업데이트 UI를 손쉽게 구현할 수 있다.
  • 컴포넌트 안에서 프라미스가 이행될 때까지 use 훅으로 렌더를 지연시킬 수 있다.

개선사항도 많다.

  • 레프 객체와 컨택스트 제공자 컴포넌트를 편하게 사용할 수 있다.
  • HTML 헤드 영역에 있는 메타 데이터나 정적 리소스를 컴포넌트 안에서 관리할 수 있다.

--

라이브러리 업데이트는 처음 정리해 보았다. 18 버전을 모른채 19 버전 변경사항을 읽는데 조금 어려웠다. 18도 한 번 정리해 보면 좋겠다. 19 버전을 실무에서 사용할 날이 언제쯤 되려나 싶다.

참고