react useImperativeHandle hook으로 부모 상태제어
2024년 12월 11일
복잡한 폼(Form), 서드파티 라이브러리(차트, 에디터 등) 연동, 혹은 레거시 코드와 결합할 때 이 원칙만으로는 해결하기 까다로운 예외 상황이 발생한다. 대표적으로 부모 컴포넌트에서 특정 이벤트가 발생했을 때, 자식 컴포넌트 내부의 함수를 실행하거나 DOM에 포커스를 줘야 하는 경우다.
이때 상태에 함수를 drilling으로 억지로 내리지 않고, forwardRef와 useImperativeHandle을 활용해 제어하는 법을 정리한다.
1. 상태 끌어올리기의 한계
텍스트 에디터 컴포넌트(<CustomEditor />)가 있고, 부모 컴포넌트의 '초기화' 버튼을 눌러 에디터 내용을 비워야 한다고 가정해 보자. 부모에 isCleared 같은 상태를 추가하는 방식을 떠올리기 쉽다.
// ❌ 비효율적인 예시: 억지로 상태를 끌어올린 경우
const Parent = () => {
const [isCleared, setIsCleared] = useState(false);
return (
<div>
<button onClick={() => setIsCleared(true)}>에디터 초기화</button>
<CustomEditor
isCleared={isCleared}
onClearDone={() => setIsCleared(false)}
/>
</div>
);
};
이 방식은 단순히 자식 내부의 함수 하나를 실행하기 위해 부모의 상태를 변경한다. 결과적으로 부모 컴포넌트 전체가 리렌더링되며, 불필요한 연산이 발생한다. 필요한 것은 단순히 editor.clear()를 호출하는 것뿐이다.
2. forwardRef를 통한 참조 전달
React에서 함수형 컴포넌트는 기본적으로 ref를 Props로 받을 수 없다. 부모가 ref를 전달하더라도 자식 컴포넌트 내부로 연결되지 않는다. 이를 가능하게 하는 것이 forwardRef다.
const CustomEditor = forwardRef((props, ref) => {
return <div ref={ref}>...</div>;
});
3. useImperativeHandle을 활용한 API화
자식 컴포넌트의 독립성을 유지하려면 DOM을 직접 노출하는 대신, 필요한 동작만 메서드 형태로 제공해야 한다. 이때 useImperativeHandle을 사용한다. 부모가 전달한 ref 객체에 자식이 직접 정의한 객체나 메서드를 바인딩하는 훅이다.
import { forwardRef, useImperativeHandle, useRef } from "react";
// 1. 부모가 사용할 수 있는 메서드의 타입을 명확히 정의한다.
export interface EditorRef {
focus: () => void;
clear: () => void;
}
const CustomEditor = forwardRef<EditorRef, Props>((props, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
// 2. 부모의 ref.current에 바인딩될 객체를 정의한다.
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current?.focus();
},
clear: () => {
if (inputRef.current) {
inputRef.current.value = "";
}
console.log("에디터 초기화 완료");
},
}));
return (
<div className="editor-wrapper">
<input ref={inputRef} placeholder="내용을 입력하세요" />
</div>
);
});
export default CustomEditor;
4. 개선된 부모 컴포넌트
이제 부모 컴포넌트에서는 불필요한 상태(State) 없이, 자식 컴포넌트가 제공하는 메서드만 호출하면 된다. 리렌더링 문제도 자연스럽게 해결된다.
import { useRef } from "react";
import CustomEditor, { EditorRef } from "./CustomEditor";
const Parent = () => {
const editorRef = useRef<EditorRef>(null);
return (
<div>
<header>
<button onClick={() => editorRef.current?.focus()}>
에디터 포커스
</button>
<button onClick={() => editorRef.current?.clear()}>전체 지우기</button>
</header>
<main>
<CustomEditor ref={editorRef} />
</main>
</div>
);
};