본문 바로가기
SPA/React

React 19.2: Activity·useEffectEvent 정식 지원, View Transitions 실험 단계

by J4J 2026. 8. 9.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 React 19.2에서 새롭게 추가된 Activity, useEffectEvent와 함께, 아직 실험 단계에 있는 View Transitions까지 살펴보는 시간을 가져보려고 합니다.

 

 

 

useEffectEvent로 Effect의 최신 값 참조 문제 해결하기

 

Effect 안에서 여러 반응형 값을 함께 참조하다 보면, 그중 일부 값이 바뀔 때만 Effect를 다시 실행하고 싶은 상황을 마주치게 됩니다.

 

일정 간격마다 값을 증가시키는 카운터를 예로 들어보겠습니다.

 

간격(intervalMs)이 바뀌면 새로운 간격으로 다시 구독해야 하지만, 한 번에 증가시키는 값(step)은 항상 최신 값을 반영하면서도 그 값이 바뀌었다는 이유만으로 다시 구독할 필요는 없습니다.

 

// ❌ step을 의존성 배열에서 제외해 최신 값을 반영하지 못하는 경우
useEffect(() => {
  const id = setInterval(() => {
    setCount((prev) => prev + step)
  }, intervalMs)

  return () => clearInterval(id)
}, [intervalMs])

 

이렇게 작성하면 setInterval의 콜백이 클로저에 담아 둔 최초 렌더링 시점의 step 값을 그대로 사용하기 때문에, 이후 step 값을 바꿔도 카운터는 항상 처음 지정한 step만큼만 증가합니다.

 

그렇다고 exhaustive-deps 규칙에 따라 step을 의존성 배열에 추가하면, 이번에는 step이 바뀔 때마다 setInterval이 해제되고 다시 등록되어 카운터가 일정한 간격으로 증가하지 않는 다른 문제가 발생합니다.

 

 

 

React 19.2는 이 문제를 해결하기 위해 useEffectEvent 훅을 새로 추가했습니다.

 

useEffectEvent는 Effect 안에서 호출하지만 의존성 배열에는 포함되지 않으면서도, 호출되는 시점의 최신 props와 state를 항상 참조하는 함수를 만들어 주는 훅입니다.

 

// ✅ useEffectEvent로 항상 최신 step 값을 참조하는 경우
const onTick = useEffectEvent(() => {
  setCount((prev) => prev + step)
})

useEffect(() => {
  const id = setInterval(() => {
    onTick()
  }, intervalMs)

  return () => clearInterval(id)
}, [intervalMs])

 

그러므로 일부 값은 항상 최신 값을 참조하되 그 값의 변경만으로는 Effect를 재실행하고 싶지 않다면, 해당 로직을 useEffectEvent로 감싸는 것이 좋습니다.

 

 

 

다만 useEffectEvent로 만든 함수는 아무 곳에서나 호출할 수 있는 것은 아닙니다.

 

onTick과 같은 Effect Event 함수를 렌더링 도중에 직접 호출하면 런타임 에러가 발생하며, useEffect 같은 Effect 내부 또는 다른 Effect Event 내부에서만 호출할 수 있습니다.

 

다른 컴포넌트에 prop으로 전달하는 것도 허용되지 않습니다.

 

또한 onTick의 함수 참조는 렌더링마다 새로 생성되도록 의도적으로 설계되어 있어서, 실수로 onTick을 의존성 배열에 추가하면 Effect가 매 렌더링마다 재실행되어 코드가 Effect Event에 잘못 의존하고 있다는 신호를 바로 알아챌 수 있습니다.

 

저의 경우 이런 identity 불안정성이 처음에는 버그처럼 느껴졌지만, 알고 보니 의도적인 런타임 경고 장치라는 점을 알고 나서는 오히려 안심하고 사용하게 되었습니다.

 

eslint-plugin-react-hooks를 최신 버전으로 올려두면, onTick 같은 Effect Event 함수를 의존성 배열에 자동으로 추가하지 않도록 린트가 알아서 처리해 줍니다.

 

실제로 동작하는 카운터 예제를 만들어 보겠습니다.

 

// pages/effect-event/index.tsx
import { useEffect, useEffectEvent, useState } from 'react'

export default function EffectEventPage() {
  const [count, setCount] = useState(0)
  const [step, setStep] = useState(1)
  const [intervalMs, setIntervalMs] = useState(1000)

  const onTick = useEffectEvent(() => {
    setCount((prev) => prev + step)
  })

  useEffect(() => {
    console.log(`${intervalMs}ms 간격으로 구독을 시작합니다.`)

    const id = setInterval(() => {
      onTick()
    }, intervalMs)

    return () => {
      console.log(`${intervalMs}ms 간격의 구독을 해제합니다.`)
      clearInterval(id)
    }
  }, [intervalMs])

  return (
    <div className="flex flex-col gap-4 p-6">
      <p>카운트: {count}</p>
      <div className="flex gap-2">
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => setStep((prev) => (prev === 1 ? 5 : 1))}
        >
          증가량 {step === 1 ? '5로 변경' : '1로 변경'}
        </button>
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => setIntervalMs((prev) => (prev === 1000 ? 2000 : 1000))}
        >
          간격 {intervalMs === 1000 ? '2000ms로 변경' : '1000ms로 변경'}
        </button>
      </div>
    </div>
  )
}

 

간격 변경 버튼을 누르면 콘솔에 "구독을 시작합니다", "구독을 해제합니다" 로그가 각각 남지만, 증가량 변경 버튼을 눌러도 이 로그는 남지 않습니다.

 

그리고 증가량 변경 버튼을 누른 직후부터 카운트가 늘어나는 값을 확인해 보면, 변경한 증가량이 다음 tick부터 곧바로 반영되는 것을 볼 수 있습니다.

 

다음으로는 화면에서 잠깐 숨겼다가 다시 보여줘야 하는 컴포넌트의 상태를 유지하는 방법을 살펴보겠습니다.

 

 

반응형

 

 

Activity로 화면을 전환해도 상태를 유지하기

 

탭 전환이나 모달처럼 화면에서 잠깐 숨겼다가 다시 보여줘야 하는 컴포넌트를 만들 때 흔히 마주치는 고민이 있습니다.

 

저의 경우 탭을 전환했다가 돌아오면 입력하던 값이 사라지는 문제를, 상위 컴포넌트에 state를 억지로 끌어올려 임시로 땜질했던 경험이 있습니다.

 

메모 작성 화면과 다른 화면을 탭으로 전환하는 경우를 예로 들어보겠습니다.

 

// ❌ 조건부 렌더링으로 탭을 전환하는 경우
{tab === 'note' && <NoteEditor />}
{tab === 'other' && <OtherPanel />}

 

이렇게 작성하면 tab이 other로 바뀌는 순간 NoteEditor가 언마운트되어, 그 안에 있던 useState 값(입력하던 메모 텍스트)이 모두 사라집니다.

 

그렇다고 CSS의 display:none 속성으로만 숨기는 방식을 쓰면 state는 유지되지만, 화면에 보이지 않는 동안에도 내부의 Effect(타이머 구독, 이벤트 리스너 등)가 계속 실행된다는 다른 문제가 남습니다.

 

 

 

React 19.2는 이 절충 지점을 메우기 위해 Activity 컴포넌트를 새로 추가했습니다.

 

Activity는 mode prop이 hidden일 때 자식을 화면에서 숨기면서도 state는 그대로 보존하고, Effect는 즉시 정리(clean up)했다가 다시 visible로 바뀌는 시점에 재실행하는 컴포넌트입니다.

 

// ✅ Activity로 탭을 전환하는 경우
<Activity mode={tab === 'note' ? 'visible' : 'hidden'}>
  <NoteEditor />
</Activity>
<Activity mode={tab === 'other' ? 'visible' : 'hidden'}>
  <OtherPanel />
</Activity>

 

그러므로 화면 전환 중에도 사용자가 입력하던 값을 유지하면서 백그라운드의 Effect는 정리하고 싶다면, 조건부 렌더링 대신 Activity로 감싸 mode만 전환하는 방식을 사용하는 것이 좋습니다.

 

실제로 동작하는 메모 화면 예제를 만들어 보겠습니다.

 

// pages/activity/NoteEditor.tsx
import { useEffect, useState } from 'react'

export default function NoteEditor() {
  const [text, setText] = useState('')

  useEffect(() => {
    console.log('자동 저장 타이머를 구독합니다.')

    const id = setInterval(() => {
      console.log('자동 저장을 실행합니다.')
    }, 3000)

    return () => {
      console.log('자동 저장 타이머를 구독 해제합니다.')
      clearInterval(id)
    }
  }, [])

  return (
    <textarea
      className="w-full rounded border border-gray-300 p-2 text-sm"
      value={text}
      onChange={(e) => setText(e.target.value)}
      placeholder="메모를 입력하세요"
    />
  )
}

 

// pages/activity/index.tsx
import { Activity, useState } from 'react'
import NoteEditor from './NoteEditor'

export default function ActivityPage() {
  const [tab, setTab] = useState<'note' | 'other'>('note')

  return (
    <div className="flex flex-col gap-4 p-6">
      <div className="flex gap-2">
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => setTab('note')}
        >
          메모
        </button>
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => setTab('other')}
        >
          다른 화면
        </button>
      </div>
      <Activity mode={tab === 'note' ? 'visible' : 'hidden'}>
        <NoteEditor />
      </Activity>
      <Activity mode={tab === 'other' ? 'visible' : 'hidden'}>
        <p className="text-sm text-gray-500">다른 화면입니다.</p>
      </Activity>
    </div>
  )
}

 

메모 탭에 텍스트를 입력한 뒤 다른 화면 탭으로 전환했다가 다시 메모 탭으로 돌아와도, 입력했던 값이 그대로 남아 있는 것을 확인할 수 있습니다.

 

그리고 다른 화면 탭으로 전환하는 순간 콘솔에 "자동 저장 타이머를 구독 해제합니다" 로그가 남고, 다시 메모 탭으로 돌아오면 "자동 저장 타이머를 구독합니다" 로그가 다시 남는 것도 함께 확인할 수 있습니다.

 

다음으로는 화면 전환 자체에 애니메이션을 적용할 수 있는 View Transitions를 살펴보겠습니다.

 

 

 

 

View Transitions로 화면 전환에 애니메이션 적용하기

 

리스트 순서가 바뀌거나 화면이 전환될 때, CSS transition을 일일이 계산하지 않고도 부드러운 화면 전환을 만들고 싶은 경우가 있습니다.

 

다만 View Transitions는 React 19.2 시점에도 정식(stable) 채널에는 포함되지 않고, Canary 또는 Experimental 채널에서만 사용할 수 있는 실험적 기능입니다.

 

그러므로 앞서 살펴본 Activity, useEffectEvent와 달리, 이번 예제는 react와 react-dom을 Canary 채널로 별도 설치해야 실행할 수 있습니다.

 

// terminal
$ npm install react@canary react-dom@canary

 

이 명령어는 프로덕션에 사용하는 일반적인 npm install react react-dom과 달리, 아직 안정화되지 않은 Canary 채널의 React를 설치합니다.

 

그러므로 이 예제는 실습이나 미리보기 목적으로만 사용하고, 실제 서비스에는 stable 채널로 정식 포함되기 전까지 적용하지 않는 것이 좋습니다.

 

 

 

ViewTransition은 브라우저의 View Transition API(document.startViewTransition)를 React가 대신 호출하여, state 변경 전후의 화면을 부드럽게 전환시켜 주는 컴포넌트입니다.

 

React가 내부적으로 이 호출을 대신 처리하기 때문에, document.startViewTransition을 직접 호출해서는 안 됩니다.

 

또한 일반적인 setState 호출만으로는 ViewTransition이 동작하지 않습니다.

 

startTransition, Suspense, 또는 useDeferredValue로 감싼 업데이트만 ViewTransition을 활성화시킵니다.

 

// ❌ 일반 setState로 목록 순서를 바꾸는 경우
function handleSort() {
  setItems((prev) => [...prev].reverse())
}

 

이렇게 작성하면 items 배열의 순서는 바뀌지만, 자식에 ViewTransition이 있어도 애니메이션 없이 즉시 순서가 바뀝니다.

// ✅ startTransition으로 감싸 ViewTransition을 활성화하는 경우
function handleSort() {
  startTransition(() => {
    setItems((prev) => [...prev].reverse())
  })
}

 

그러므로 화면 전환에 애니메이션을 적용하고 싶다면, state를 변경하는 코드를 startTransition으로 감싸는 것이 좋습니다.

 

 

 

목록의 각 항목처럼 여러 ViewTransition을 동시에 렌더링할 때는 name prop에 서로 다른 값을 지정하는 것이 좋습니다.

 

같은 name을 가진 ViewTransition 두 개가 동시에 마운트되면 에러가 발생하기 때문에, 배열의 index가 아니라 각 항목의 고유 id를 name에 포함시키는 것이 좋습니다.

 

실제로 동작하는 목록 정렬 예제를 만들어 보겠습니다.

 

// pages/view-transition/index.tsx
/// <reference types="react/canary" />
import { startTransition, useState, ViewTransition } from 'react'

const initialItems = [
  { id: 1, label: '사과' },
  { id: 2, label: '바나나' },
  { id: 3, label: '체리' },
  { id: 4, label: '포도' },
]

export default function ViewTransitionPage() {
  const [items, setItems] = useState(initialItems)

  function handleSort() {
    startTransition(() => {
      setItems((prev) => [...prev].reverse())
    })
  }

  return (
    <div className="flex flex-col gap-4 p-6">
      <button
        className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
        onClick={handleSort}
      >
        순서 뒤집기
      </button>
      <ul className="flex flex-col gap-2">
        {items.map((item) => (
          <ViewTransition key={item.id} name={`item-${item.id}`}>
            <li className="rounded border border-gray-200 px-3 py-2 text-sm">{item.label}</li>
          </ViewTransition>
        ))}
      </ul>
    </div>
  )
}

 

순서 뒤집기 버튼을 누르면 각 항목이 즉시 자리를 바꾸는 대신, 브라우저가 View Transition API를 지원하는 경우 서로의 위치로 부드럽게 이동하는 애니메이션이 재생됩니다.

 

브라우저가 View Transition API를 지원하지 않는 환경에서는 애니메이션 없이 순서만 즉시 바뀌는 것으로 알려져 있어, 별도의 폴백 코드는 필요하지 않은 것으로 보입니다.

 

 

 

startTransition 자체의 비동기 pending 처리 방식은 React 19 useTransition, Actions로 비동기 pending과 에러 한 번에 처리하기에서 자세히 다뤘습니다.

 

React 19 useTransition, Actions로 비동기 pending과 에러 한 번에 처리하기

안녕하세요. J4J입니다. 이번 포스팅은 React 19에서 useTransition에 async 함수를 직접 전달하여 비동기 작업의 pending 상태와 에러를 한 번에 관리하는 방법에 대해 알아보는 시간을 가져보려고 합니다

jforj.tistory.com

 

다음으로는 이 세 가지 API를 실무에 어떻게 도입하면 좋을지 정리해 보겠습니다.

 

 

 

 

React 19.2 세 가지 API를 실무에 도입할 때 고려할 점

 

세 API는 안정성 단계가 서로 다르기 때문에, 도입 순서도 다르게 가져가는 것이 좋습니다.

 

  • Activity: React 19.2.0부터 정식(stable) 지원 — react, react-dom을 19.2 이상으로 설치하면 바로 사용 가능
  • useEffectEvent: React 19.2.0부터 정식(stable) 지원 — eslint-plugin-react-hooks도 함께 최신 버전으로 올려야 의존성 배열 관련 경고를 피할 수 있음
  • View Transitions: React 19.2 시점에도 Canary/Experimental 채널 전용 — 프로덕션 도입 전에는 stable 채널 포함 여부를 다시 확인해야 함

 

그러므로 Activity와 useEffectEvent는 지금 진행 중인 프로젝트에도 바로 도입해 볼 수 있지만, View Transitions는 사이드 프로젝트나 미리보기 용도로 먼저 익혀두고 stable 릴리스를 기다리는 것이 좋습니다.

 

개인적으로 가장 크게 체감될 부분은 Activity라고 생각합니다.

 

탭, 아코디언, 모달처럼 화면에서 잠깐 숨겼다가 다시 보여주는 UI 패턴이 실무에서 워낙 자주 등장하기 때문에, 그동안 state 유지와 Effect 정리를 동시에 만족시키지 못해 타협했던 코드를 하나씩 정리해 볼 수 있을 것으로 기대합니다.

 

이번 포스팅에서는 세 API를 순수 React 환경을 기준으로 살펴보았습니다.

 

Next.js 16 App Router 환경에서 이 API들을, 특히 View Transitions를 어떻게 통합해 활용하는지는 별도 포스팅에서 다루겠습니다.

 

 

 

 

 

 

 

이상으로 React 19.2에서 새롭게 추가된 Activity, useEffectEvent와 실험적인 View Transitions에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글