안녕하세요. J4J입니다.
이번 포스팅은 React 19에 새롭게 추가된 ref as prop과 Context 렌더링 방식을 이용하여 forwardRef 없이 컴포넌트를 작성하는 방법에 대해 알아보는 시간을 가져보려고 합니다.
forwardRef 없이 ref를 prop으로 받아야 하는 이유
재사용 가능한 Input, Button 같은 컴포넌트를 만들다 보면 부모 컴포넌트가 DOM 요소에 직접 접근해야 하는 상황을 마주치게 됩니다.
지금까지는 이런 컴포넌트를 만들 때 함수 컴포넌트를 forwardRef로 감싸고, ref를 두 번째 인자로 따로 받아야 했습니다.
제가 처음 컴포넌트 라이브러리를 만들 때 가장 번거롭게 느꼈던 부분은 TypeScript로 forwardRef의 제네릭 타입 두 개를 매번 맞춰야 한다는 점이었습니다.
// ❌ forwardRef로 ref를 전달하는 기존 방식
import { forwardRef } from 'react'
interface InputProps {
placeholder: string
}
export default forwardRef<HTMLInputElement, InputProps>(({ placeholder }, ref) => {
return (
<input
ref={ref}
placeholder={placeholder}
className="rounded border border-gray-300 px-3 py-1.5 text-sm"
/>
)
})
이렇게 작성하면 컴포넌트 이름과 props 타입을 forwardRef 제네릭에도 다시 한 번 반복해서 지정하게 되고, ref를 두 번째 인자로 받는 함수 시그니처도 별도로 기억하는 부담이 있습니다.
// ✅ React 19: ref를 일반 prop으로 받는 방식
import type { Ref } from 'react'
interface InputProps {
placeholder: string
ref: Ref<HTMLInputElement>
}
export default function Input({ placeholder, ref }: InputProps) {
return (
<input
ref={ref}
placeholder={placeholder}
className="rounded border border-gray-300 px-3 py-1.5 text-sm"
/>
)
}
그러므로 새로 작성하는 컴포넌트라면 forwardRef 없이 ref를 다른 props와 동일하게 다루는 것이 좋습니다.
정리하면 다음과 같은 상황에서 ref as prop 방식을 사용하는 것이 좋습니다.
- 재사용 가능한 Input, Button 같은 컴포넌트에서 부모가 DOM ref에 접근해야 할 때
- forwardRef 제네릭 타입 선언을 줄이고 일반 함수 컴포넌트와 동일한 방식으로 타입을 다루고 싶을 때
- 새로 작성하는 컴포넌트에 불필요한 forwardRef 래핑을 추가하고 싶지 않을 때
forwardRef는 React 19에서도 여전히 정상 동작하며, 공식 문서는 향후 릴리스에서 deprecated 처리할 예정이라고 안내할 뿐 즉시 제거하지는 않습니다.
그러므로 기존 forwardRef 컴포넌트를 급하게 모두 마이그레이션할 필요는 없고, 새로 작성하는 컴포넌트부터 ref as prop 방식을 적용하는 것으로 충분합니다.
그러면 실제로 ref를 prop으로 어떻게 받는지 살펴보겠습니다.
ref를 일반 prop으로 받아 DOM에 전달하기
ref를 prop으로 선언할 때는 react 패키지의 Ref 타입으로 어떤 DOM 요소를 참조하는지 명시합니다.
ref라는 이름의 prop은 forwardRef와 달리 다른 props와 동일한 위치에서 구조 분해할 수 있습니다.
// pages/ref-as-prop/Input.tsx
import type { Ref } from 'react'
interface InputProps {
placeholder: string
ref: Ref<HTMLInputElement>
}
export default function Input({ placeholder, ref }: InputProps) {
return (
<input
ref={ref}
placeholder={placeholder}
className="rounded border border-gray-300 px-3 py-1.5 text-sm"
/>
)
}
부모 컴포넌트는 forwardRef로 감싸여 있지 않은 Input을, useRef로 만든 ref를 받는 일반 컴포넌트처럼 그대로 사용할 수 있습니다.
// pages/ref-as-prop/index.tsx
import { useRef } from 'react'
import Input from './Input'
export default function RefAsPropPage() {
const inputRef = useRef<HTMLInputElement>(null)
function handleFocus() {
inputRef.current?.focus()
}
return (
<div className="flex flex-col gap-4 p-6">
<Input ref={inputRef} placeholder="이름을 입력하세요" />
<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={handleFocus}
>
입력창 포커스
</button>
</div>
</div>
)
}
입력창 포커스 버튼을 누르면 inputRef.current로 Input 내부의 input 요소에 직접 접근해 focus를 호출합니다.
React 19부터는 useRef를 인자 없이 호출하면 TypeScript가 에러를 발생시키므로, useRef(null)처럼 초기값을 명시적으로 전달하는 것이 좋습니다.
다음으로는 ref 콜백이 cleanup 함수를 반환할 수 있게 된 부분을 살펴보겠습니다.
ref 콜백의 cleanup 함수로 리소스 정리하기
ref에 콜백 함수를 직접 전달해서 DOM 요소에 접근하는 방식은 이전부터 가능했습니다.
다만 지금까지는 이 콜백 함수 안에서 이벤트 리스너나 ResizeObserver 같은 리소스를 등록해도, 컴포넌트가 사라질 때 그 리소스를 해제하는 코드를 별도의 useEffect로 다시 작성해야 했습니다.
저의 경우 ref 콜백과 useEffect를 나란히 두고 등록·해제 코드를 각각 관리하다가, 리팩토링 과정에서 해제 코드만 빠뜨려 리소스가 계속 쌓이는 문제를 겪은 적이 있습니다.
// ❌ cleanup 함수 없이 ref 콜백만으로 리소스를 등록하는 경우
<div
ref={(node) => {
if (!node) return
const observer = new ResizeObserver(() => {
console.log('크기 변경 감지')
})
observer.observe(node)
}}
>
크기를 조절해 보세요.
</div>
이렇게 작성하면 컴포넌트가 언마운트되어도 ResizeObserver가 disconnect되지 않아, 이미 사라진 DOM 요소를 계속 참조하는 observer가 메모리에 남아 있게 됩니다.
// ✅ React 19: ref 콜백이 cleanup 함수를 반환하는 방식
<div
ref={(node) => {
if (!node) return
const observer = new ResizeObserver(() => {
console.log('크기 변경 감지')
})
observer.observe(node)
return () => {
observer.disconnect()
}
}}
>
크기를 조절해 보세요.
</div>
React 19부터는 ref 콜백이 반환한 이 cleanup 함수를, 컴포넌트가 언마운트되거나 ref 값이 바뀔 때 React가 대신 호출해 줍니다.
ref 콜백이 cleanup 함수를 반환하지 않으면 React 19에서도 기존과 동일하게 언마운트 시 ref를 null로 호출하는 동작만 일어나고, 리소스는 자동으로 정리되지 않습니다.
그러므로 ref 콜백 안에서 리스너나 observer를 등록했다면, 그 콜백에서 곧바로 cleanup 함수를 반환해 등록·해제를 한 곳에서 관리하는 것이 좋습니다.
실제로 마운트와 언마운트를 토글하며 콘솔에 등록·해제 로그가 남는지 확인해 보겠습니다.
// pages/ref-cleanup/ResizeBox.tsx
export default function ResizeBox() {
return (
<div
ref={(node) => {
if (!node) return
console.log('[ResizeBox] ResizeObserver 등록')
const observer = new ResizeObserver(() => {
console.log('[ResizeBox] 크기 변경 감지')
})
observer.observe(node)
return () => {
console.log('[ResizeBox] ResizeObserver 해제')
observer.disconnect()
}
}}
className="resize overflow-auto rounded border border-gray-300 p-6 text-sm"
>
크기를 조절해 보세요.
</div>
)
}
// pages/ref-cleanup/index.tsx
import { useState } from 'react'
import ResizeBox from './ResizeBox'
export default function RefCleanupPage() {
const [showBox, setShowBox] = useState(true)
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={() => setShowBox((prev) => !prev)}
>
박스 {showBox ? '언마운트' : '마운트'}
</button>
</div>
{showBox && <ResizeBox />}
<p className="text-sm text-gray-500">콘솔을 확인하면 마운트 시 등록, 언마운트 시 해제 로그를 볼 수 있습니다.</p>
</div>
)
}
박스 언마운트 버튼을 누르면 콘솔에 ResizeObserver 해제 로그가 함께 출력되는 것을 확인할 수 있습니다.
개발 모드의 StrictMode에서는 최초 마운트 시 등록 로그와 해제 로그가 한 벌 더 추가로 출력되는데, 이는 React가 컴포넌트를 의도적으로 한 번 마운트했다가 정리하고 다시 마운트하기 때문입니다.
그러므로 개발 모드에서 로그가 두 벌 보이더라도 실제 버그가 아니라 StrictMode의 의도된 동작이라는 점을 먼저 확인하는 것이 좋습니다.
다음으로는 Context를 Provider 없이 바로 렌더링하는 방법을 살펴보겠습니다.
Context를 Provider 없이 바로 렌더링하기
여러 컴포넌트에 공통 값을 내려줄 때는 createContext로 Context를 만들고, 그 값을 사용할 범위를 Context.Provider로 감싸는 방식을 사용해 왔습니다.
그러면 React 19에서는 이 Provider가 어떻게 바뀔까요?
// ❌ Context.Provider로 감싸는 기존 방식
<ThemeContext.Provider value={theme}>
<Panel />
</ThemeContext.Provider>
이렇게 작성해도 여전히 정상 동작하지만, Context 하나당 Provider라는 이름을 한 번 더 붙여야 합니다.
// ✅ React 19: Context를 Provider 없이 바로 렌더링하는 방식
<ThemeContext value={theme}>
<Panel />
</ThemeContext>
createContext로 만든 Context 객체를 컴포넌트처럼 직접 렌더링하면, React가 그 자체를 Provider로 취급합니다.
그러므로 새로 작성하는 Context라면 .Provider를 따로 붙이지 않고 Context 자체를 바로 렌더링하는 것이 좋습니다.
다만 Context.Provider가 React 19에서 제거된 것은 아니며, 공식 문서에도 legacy 표기와 함께 여전히 남아 있어 기존 코드를 즉시 바꿀 필요는 없습니다.
실제로 테마 값을 전환하는 예제로 확인해 보겠습니다.
// pages/context-as-provider/ThemeContext.tsx
import { createContext } from 'react'
export type Theme = 'light' | 'dark'
export const ThemeContext = createContext<Theme>('light')
// pages/context-as-provider/Panel.tsx
import { useContext } from 'react'
import { ThemeContext } from './ThemeContext'
export default function Panel() {
const theme = useContext(ThemeContext)
return <div className="rounded border border-gray-300 p-6 text-sm">현재 테마는 {theme} 입니다.</div>
}
// pages/context-as-provider/index.tsx
import { useState } from 'react'
import { ThemeContext, type Theme } from './ThemeContext'
import Panel from './Panel'
export default function ContextAsProviderPage() {
const [theme, setTheme] = useState<Theme>('light')
return (
<ThemeContext value={theme}>
<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={() => setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))}
>
테마 전환
</button>
</div>
<Panel />
</div>
</ThemeContext>
)
}
테마 전환 버튼을 누르면 ThemeContext에 전달하는 value가 바뀌면서 Panel에 표시되는 테마 값도 함께 바뀝니다.
Context를 Provider 없이 렌더링하는 방식과 별개로, use API로 Context 값을 조건부로 읽는 방법은 React 19 use API, Promise와 Context를 조건부로 읽는 방법에서 다뤘습니다.
React 19 use API, Promise와 Context를 조건부로 읽는 방법
이번 포스팅은 React 19에 새롭게 추가된 use API를 이용하여 Promise와 Context를 다루는 방법에 대해 알아보는 시간을 가져보려고 합니다.
jforj.tistory.com
정리
지금까지 살펴본 내용을 정리하면 다음과 같습니다.
| 구분 | 기존 방식 | React 19 |
|---|---|---|
| DOM ref 전달 | forwardRef로 컴포넌트를 감싸고 두 번째 인자로 ref를 받음 | ref를 다른 props와 동일하게 선언해서 받음 |
| TypeScript 타입 | forwardRef 제네릭 두 개(엘리먼트 타입, props 타입)를 맞춰야 함 | 일반 함수 컴포넌트 props 타입에 ref 필드만 추가 |
| ref 콜백 리소스 정리 | 별도 useEffect로 등록·해제 코드를 직접 작성 | ref 콜백이 반환한 cleanup 함수를 React가 자동으로 호출 |
| Context 값 주입 | Context.Provider로 감싸야 함 | Context를 Provider 없이 바로 렌더링 |
| 기존 코드 호환성 | - | forwardRef, Context.Provider 모두 계속 지원 (즉시 제거 아님) |
이상으로 React 19의 forwardRef 없이 ref 받기와 Context Provider 생략에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'SPA > React' 카테고리의 다른 글
| onCaughtError, onUncaughtError로 완성하는 React 19 에러 로깅 정리 (0) | 2026.07.25 |
|---|---|
| React 19 useTransition, Actions로 비동기 pending과 에러 한 번에 처리하기 (0) | 2026.07.20 |
| React 19 문서 메타데이터와 리소스 프리로드, react-helmet 없이 직접 관리하기 (0) | 2026.07.17 |
| React 19 useOptimistic, Server Action 없이 낙관적 업데이트를 처리하는 방법 (0) | 2026.07.13 |
| React 19 use API, Promise와 Context를 조건부로 읽는 방법 (1) | 2026.07.12 |
댓글