KAITA세계 한국인 IT 연합
← 돌아가기

React 19.3 실전 — View Transitions, Fragment Refs, browser()를 이렇게 씁니다

React 19.3이 9월 9일에 나왔습니다. 거창한 메이저 릴리스는 아니고, 실험 기능이던 두 개가 정식으로 졸업한 마이너 릴리스예요. 그런데 이 세 가지는 실무에서 바로 손에 닿습니다. 목록 화면이 바뀔 때 애니메이션, 래퍼 div 없이 여러 요소에 ref 거는 일, 그리고 서버 렌더링에서 빼야 하는 브라우저 전용 컴포넌트. 각각 따로 라이브러리를 붙이거나 꼼수를 쓰던 것들인데, 이제는 React 안에 공식 방법이 있습니다. 업그레이드는 `npm install react@19.3 react-dom@19.3` 한 줄이면 되고, 지금 설치하면 10월 14일 패치를 포함한 19.3.1이 깔립니다.

**1. View Transitions — 목록이 바뀔 때, 애니메이션 라이브러리 없이**

문제는 이거예요. 탭을 바꾸거나 목록에서 항목을 지우면 화면이 뚝뚝 바뀝니다. framer-motion 같은 라이브러리를 붙이자니 작은 화면 하나에 과하고, CSS transition만으로는 마운트·언마운트까지 못 잡습니다.

19.3에서는 `<ViewTransition>`으로 감싸기만 하면 됩니다. 브라우저의 View Transition API를 React 렌더링과 연결해줘요.

```jsx
import { ViewTransition, useState, startTransition } from 'react';

export default function Component() {
  const [showItem, setShowItem] = useState(false);
  return (
    <>
      <button => {
        startTransition(() => setShowItem(prev => !prev));
      }}>
        {showItem ? '➖' : '➕'}
      </button>
      {showItem && (
        <ViewTransition>
          <Video video={videos[0]} />
        </ViewTransition>
      )}
    </>
  );
}
```

핵심은 두 가지입니다. 첫째, **애니메이션은 Transition으로 표시한 업데이트에서만** 일어납니다. `startTransition` 안의 setState, Suspense reveal, `useDeferredValue`가 여기에 해당하고, 긴급 업데이트는 애니메이션 없이 바로 반영됩니다. 그래서 위 예제처럼 버튼 핸들러를 `startTransition`으로 감싸는 게 필수예요. 둘째, 기본값은 부드러운 cross-fade이고, 네 가지 경우(enter / exit / update / share)에 따라 다르게 줄 수 있습니다. 캐러셀처럼 같은 상태 변경인데 방향마다 다르게 움직여야 할 때는 `addTransitionType`을 씁니다.

```jsx
function nextSlide() {
  startTransition(() => {
    addTransitionType('next');
    setCurrentSlide(c => c + 1);
  });
}

// 방향마다 다른 CSS 클래스를 지정
<ViewTransition
  enter={{ 'next': 'from-right', 'previous': 'from-left' }}
  exit={{ 'next': 'to-left', 'previous': 'to-right' }}
>
  <Page />
</ViewTransition>
```

Suspense와 같이 쓸 때는 한 가지만 조심하세요. `<Suspense>`를 그대로 감싸면 이미 로딩된 UI까지 매번 애니메이션이 돕니다. 공식 블로그가 권하는 패턴은 이렇습니다.

```jsx
<ViewTransition update="auto" default="none">
  <Suspense fallback={<Fallback />}>
    <Component />
  </Suspense>
</ViewTransition>
```

이렇게 하면 fallback이 뜰 때는 바로 뜨고, fallback에서 진짜 콘텐츠로 넘어갈 때만 애니메이션이 돕니다. 지금은 DOM에서만 동작하고, React Native 지원은 아직 준비 중입니다. 브라우저가 View Transition API를 지원하지 않으면 애니메이션 없이 바로 바뀌니까, 기능이 없다고 화면이 깨지지는 않습니다.

**2. Fragment Refs — 래퍼 div 없이, 여러 요소에 한 번에 ref**

두 번째로 자주 부딪히는 문제입니다. 형제 요소 여러 개에 포커스를 주거나, 화면에 보이는지 관찰하거나, 이벤트를 달고 싶은데 부모가 될 div가 없습니다. 스타일 때문에 래퍼 div를 추가할 수도 없고, 라이브러리 컴포넌트라 ref를 안 받는 경우도 있습니다.

19.3에서는 `<Fragment>`에 직접 ref를 달 수 있습니다. `<></>` 축약형은 안 되고, `Fragment`를 import해서 써야 합니다.

```jsx
import { Fragment, useRef, useEffect } from 'react';

function Component() {
  const fragmentRef = useRef(null);
  useEffect(() => {
    fragmentRef.current.focus();
  }, []);
  return (
    <Fragment ref={fragmentRef}>
      {posts.map(post => (
        <Heading key={post.id}>{post.title}</Heading>
      ))}
    </Fragment>
  );
}
```

ref로 받는 건 DOM 노드가 아니라 `FragmentInstance`라는 핸들입니다. DOM 구조를 바꾸지 않고, 자식들을 한 그룹으로 다루는 메서드만 모아뒀어요. `addEventListener` / `removeEventListener` / `dispatchEvent`, `focus` / `focusLast` / `blur`, `observeUsing` / `unobserveUsing`(IntersectionObserver·ResizeObserver 연결), 그리고 `getClientRects` / `scrollIntoView` 같은 측정·스크롤 메서드입니다. 예를 들어 화면 진입을 감지하는 래퍼는 이렇게 쓸 수 있습니다.

```jsx
// InView 안에서 Fragment ref에 observeUsing으로 IntersectionObserver를 연결하고,
// 자식이 뷰포트에 들어오면 onChange를 호출합니다
<InView>
  <Card title="First section" />
  <Card title="Second section" />
</InView>
```

`Card`가 ref를 안 받아도, 부모 div가 없어도 됩니다. 다만 `FragmentInstance`는 DOM 노드가 아니니까 `className`을 주거나 `querySelector`를 부르는 식으로는 쓸 수 없습니다. 있는 메서드 안에서만 쓰세요.

**3. browser() — 서버 렌더링에서 빼야 하는 컴포넌트는 이제 한 줄로**

세 번째는 서버 렌더링을 쓰는 분들이 다 한 번씩 써본 꼼수입니다. `localStorage`를 읽거나 기기의 타임존을 보여주는 컴포넌트는 서버에서 의미 있는 HTML을 만들 수 없습니다. 그래서 `mounted` state를 `useEffect`에서 true로 바꾸거나, `typeof window !== 'undefined'`로 체크하는 코드를 넣어왔습니다.

19.3에는 공식 API가 있습니다. `react-dom`에서 가져온 `browser()`를 `use()`에 넘기면 됩니다.

```jsx
import { Suspense, use } from 'react';
import { browser } from 'react-dom';

function TimeZone() {
  use(browser());
  const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
  return <p>{timeZone}</p>;
}

export default function App() {
  return (
    <Suspense fallback="Loading...">
      <TimeZone />
    </Suspense>
  );
}
```

서버에서는 가장 가까운 Suspense의 fallback이 HTML에 들어가고, 클라이언트에서 하이드레이션이 끝나면 `use(browser())`는 더 이상 suspend하지 않고 그대로 렌더링합니다. 다른 `use()` 호출과 달리 조건문 안이나 early return 뒤에도 쓸 수 있어서, 기본값이 있으면 서버에서 렌더링하고 없을 때만 브라우저로 넘기는 식으로 쓸 수 있습니다. 단, 두 가지는 지켜야 합니다. 반드시 Suspense 경계 안에 두어야 하고, RSC 앱에서는 Client Component 안에서 불러야 합니다.

**그 외에 같이 들어온 것들**

- **Trusted Types 지원:** `require-trusted-types-for 'script'` CSP를 쓰는 사이트에서, React가 `TrustedHTML` 같은 값을 문자열로 강제 변환하지 않고 그대로 넘겨줍니다. DOM XSS 방어 정책을 쓰는 앱이라면 따로 손댈 게 없어졌어요.
- **Server Components에서 `<Context>` 직접 렌더링:** 이제 래퍼 Provider 컴포넌트 없이, `'use client'` 모듈에서 가져온 Context를 서버 컴포넌트에서 바로 렌더링할 수 있습니다.
- **공식 릴리스 노트에 breaking change는 없습니다.** 19.2에서 그대로 올릴 수 있는 마이너 업그레이드입니다.

**흔한 함정 3가지**

1. **`startTransition` 없이 `<ViewTransition>`만 감싸는 것.** 긴급 업데이트는 애니메이션이 안 돕니다. 애니메이션이 안 된다고 라이브러리 탓을 하기 전에, 상태 변경이 Transition으로 표시됐는지 먼저 확인하세요.
2. **Suspense에 ViewTransition을 그대로 감싸는 것.** 이미 로딩된 UI까지 매번 애니메이션이 돌아서 오히려 느려 보입니다. `update="auto" default="none"`으로 fallback에서 콘텐츠로 넘어갈 때만 애니메이션하게 하세요.
3. **`FragmentInstance`를 DOM 노드로 착각하는 것.** `className`을 주거나 `querySelector`를 부를 수 없습니다. 있는 메서드(`focus`, `observeUsing`, `scrollIntoView` 등) 안에서만 쓰세요. 그리고 `<></>`에는 ref가 안 달리니 `Fragment`를 import하는 걸 잊지 마세요.

**체크리스트**

- [ ] `npm install react@19.3 react-dom@19.3`으로 올렸나 (지금 설치하면 19.3.1)
- [ ] ViewTransition을 쓸 때 상태 변경을 `startTransition`으로 감쌌나
- [ ] Suspense와 같이 쓸 때는 `update="auto" default="none"`을 썼나
- [ ] Fragment ref는 `<Fragment>`에 달았나 (`<></>`에는 안 됨)
- [ ] `browser()`를 쓰는 컴포넌트는 Suspense 안에 있나 (RSC에서는 Client Component 안에 있나)
- [ ] Trusted Types CSP를 쓰는 앱이라면, 따로 우회 코드가 남아 있지 않나

여러분은 19.3에서 뭘 먼저 써보실 건가요? 저는 Fragment Refs가 제일 손이 갈 것 같아요. 래퍼 div 하나 줄이는 게 이렇게 반가울 줄은 몰랐네요.
0 0 2

댓글 0

로그인하고 댓글 남기기 →