들어가기 전에
useImperativeHandle hook은 ref, forwardRef와 아주 밀접한 관련이 있는 hook입니다. 이 글을 읽기 전 sunny 선임님께서 작성해 주신 React 컴포넌트 내 DOM 접근하기를 읽어주시면 글을 이해하는 데 더 도움이 됩니다!
들어가며
신규 프로젝트를 시작할 때면 반드시 만들어야 하는 컴포넌트들이 있는데요, 그중에 가장 대표적인 컴포넌트가 input, button과 같은 form 요소들입니다. 이번 프로젝트에서도 어김없이 form과 관련된 요소들을 컴포넌트로 만들면서 알게 된 useImperativeHandle hook에 소개하려고 합니다.
useImperativeHandle hook이 뭐죠?
많은 분께 useImperativeHandle hook은 낯선 hook일 거로 생각합니다. 저 또한 이번에 필요에 의해 사용하기 전까지는 몰랐던 hook이었으니까요!
React 공식 문서에서는 useImperativeHandle을 다음과 같이 설명하고 있습니다.
useImperativeHandle is a React Hook that lets you customize the handle exposed as a ref.
한국어로 번역하면, useImperativeHandle은 Ref로 노출된 핸들을 사용자 정의할 수 있는 React Hook이라는 뜻이 됩니다.
단순하게 위 문구를 번역하면 조금 난해하게 느껴지는데요, 쉽게 말하자면 useImperativeHandle은 자식 컴포넌트의 상태 변경을 부모 컴포넌트에서 하거나, 자식 컴포넌트의 핸들러를 부모 컴포넌트에서 호출할 때 사용할 수 있는 hook입니다.
어떻게 사용하는 건가요?
useImperativeHandle의 문법은 다음과 같습니다.
useImperativeHandle(ref, createHandle, createHandle?)
- ref : forwardRef()의 두 번째 인자로 전달받은 ref 객체입니다.
- createHandle : ref에 연결된 값을 리턴합니다. 이 값을 부모 컴포넌트가 사용합니다.
- createHandle (optional) : createHandle 함수가 변경되어야 하는 경우를 정의하는 배열입니다.
자식 컴포넌트를 forwardRef로 감싸고, ref로 전달 할 엘리먼트를 연결한 후 createHandle로 필요한 메서드를 정의해주면 됩니다. 부모 컴포넌트에서 자식 컴포넌트의 ref를 연결하고, 정의된 메서드를 호출하면 됩니다.
import { useRef, useImperativeHandle, forwardRef } from 'react';
const Input = forwardRef((props, ref) => {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
}));
return <input ref={inputRef} />;
});
const ParentComponent = () => {
const inputRef = useRef();
return (
<div>
<Input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>
클릭하면 input에 포커스 됩니다.
</button>
</div>
);
};
export default ParentComponent;
프로젝트에 왜 사용하게 되었나요?
이번에 1:1 문의 팝업을 맡게 되면서 form 요소를 컴포넌트로 만들기 시작했습니다. 그 안에는 당연히 input도 있었습니다. 요구사항에 맞추기 위해 input에 value가 있으면 clear 버튼을 노출하고, clear 버튼 클릭 시 다시 input에 focus가 되도록 작업해야 했습니다. 다음과 useRef를 사용하여 input 요소를 ref에 담아 input Element에 접근할 수 있도록 만들어 주었습니다.
import { useRef, useState } from 'react';
import classNames from 'classnames/bind';
import IconButton from '../button/IconButton';
import SvgIcon from '../icon/svg-icon/SvgIcon';
import style from './Input.module.scss';
const cx = classNames.bind(style);
type Props = {
id: string,
value?: string | number,
defaultValue?: string | number,
title: string,
disabled?: boolean,
className?: string,
placeholder?: string,
hasClear?: boolean,
onChange?: React.ChangeEventHandler<HTMLInputElement>,
};
const Input = ({
id,
value,
defaultValue = '',
title,
disabled,
className,
placeholder,
hasClear,
onChange,
}: Props) => {
const [inputValue, setInputValue] = useState(defaultValue);
const inputRef = useRef < HTMLInputElement > null;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const onClear = () => {
setInputValue('');
inputRef.current?.focus();
};
return (
<div
className={cx('input-wrap', className, {
'input-disabled': disabled,
})}
>
<div className={cx('input-inner')}>
<input
id={id}
value={value || inputValue}
onChange={(e) => {
if (value === undefined) {
handleChange(e);
}
if (onChange) {
onChange(e);
}
}}
disabled={disabled}
placeholder={placeholder}
title={title}
ref={inputRef}
/>
{hasClear && inputValue.toString().length > 0 && !disabled && (
<div className={cx('clear-btn-wrap')}>
<IconButton
size={20}
onClick={onClear}
className={cx('clear-btn')}
icon={<SvgIcon iconName="input_clear" size={20} />}
>
clear
</IconButton>
</div>
)}
</div>
</div>
);
};
export default Input;
문제는 만들어진 Input 컴포넌트를 사용하여 생년월일을 입력하는 컴포넌트를 만들면서 만났습니다.
각각의 인풋에 년, 월, 일을 입력할 때 정해진 글자 수를 입력하면 다음 인풋으로 자동 포커스 시키는 기능이 필요했는데요, 연결된 포커스 동작을 구현하기 위해 input 컴포넌트를 forwardRef로 감싸고 ref를 연결하려 봤더니… 이미 input 엘리먼트는 Input 컴포넌트 안에서 useRef로 ref가 연결되어 있었던 것이었죠!
React에서는 하나의 엘리먼트에 여러 개의 ref를 직접 연결할 수 없기 때문에, 위와 같은 문제를 해결하기 위하여 useImperativeHandle hook을 사용하여 문제를 해결했습니다.
// Input 컴포넌트
import { useRef, useState, forwardRef, useImperativeHandle } from 'react';
import classNames from 'classnames/bind';
import IconButton from '../button/IconButton';
import SvgIcon from '../icon/svg-icon/SvgIcon';
import style from './Input.module.scss';
const cx = classNames.bind(style);
export interface InputRefType {
input: HTMLInputElement | null;
focus: () => void;
}
type Props = {
id: string,
value?: string | number,
defaultValue?: string | number,
title: string,
disabled?: boolean,
className?: string,
placeholder?: string,
hasClear?: boolean,
onChange?: React.ChangeEventHandler<HTMLInputElement>,
};
const Input = forwardRef<InputRefType, Props>(
(
{
id,
value,
defaultValue = '',
title,
disabled,
className,
placeholder,
hasClear,
onChange,
}: Props,
ref
) => {
const [inputValue, setInputValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const onClear = () => {
setInputValue('');
inputRef.current?.focus();
};
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current?.focus();
},
input: inputRef.current
}));
return (
<div
className={cx('input-wrap', className, {
'input-disabled': disabled
})}
>
<div className={cx('input-inner')}>
<input
id={id}
value={value || inputValue}
onChange={(e) => {
if (value === undefined) {
handleChange(e);
}
if (onChange) {
onChange(e);
}
}}
disabled={disabled}
placeholder={placeholder}
title={title}
ref={inputRef}
/>
{hasClear && inputValue.toString().length > 0 && !disabled && (
<div className={cx('clear-btn-wrap')}>
<IconButton
size={20}
onClick={onClear}
className={cx('clear-btn')}
icon={<SvgIcon iconName="input_clear" size={20} />}
>
clear
</IconButton>
</div>
)}
</div>
</div>
);
}
);
export default Input;
// 생년월일 입력 컴포넌트
import { useRef, useState, ChangeEvent } from 'react';
import Input, { InputRefType } from '@components/atom/input/Input';
import classNames from 'classnames/bind';
import style from './InquiryBirthInput.module.scss';
const cx = classNames.bind(style);
const InquiryBirthInput = () => {
const [values, setValues] = useState<string[]>(['', '', '']);
const dayInputRef = useRef<InputRefType>(null);
const monthInputRef = useRef<InputRefType>(null);
const yearInputRef = useRef<InputRefType>(null);
const valueLimits = [2, 2, 4];
const handleInputChange = (index: number, e: ChangeEvent<HTMLInputElement>) => {
const nextInputValues = [...values];
nextInputValues[index] = e.target.value.replace(/[^0-9]/g, '');
if (nextInputValues[index].length >= valueLimits[index]) {
nextInputValues[index] = nextInputValues[index].slice(0, valueLimits[index]);
switch (index) {
case 0:
monthInputRef.current?.focus();
break;
case 1:
yearInputRef.current?.focus();
break;
default:
break;
}
}
setValues(nextInputValues);
};
return (
<div className={cx('birth-input-wrap')}>
<span className={cx('title')}>Date of birth</span>
<div className={cx('birth-input-list')}>
<Input
id="birth-day"
title="day"
placeholder="DD"
ref={dayInputRef}
value={values[0]}
onChange={(e) => handleInputChange(0, e)}
/>
<Input
id="birth-month"
title="month"
placeholder="MM"
ref={monthInputRef}
value={values[1]}
onChange={(e) => handleInputChange(1, e)}
/>
<Input
id="birth-year"
title="year"
placeholder="YYYY"
ref={yearInputRef}
value={values[2]}
onChange={(e) => handleInputChange(2, e)}
className={cx('input-year')}
/>
</div>
</div>
);
};
export default InquiryBirthInput;
Input 컴포넌트 내부에 useImperativeHandle로 부모 컴포넌트가 접근할 수 있는 focus 메서드와, 이 외의 컨트롤이 필요할 경우 사용할 수 있도록 input.current도 넘겨주도록 했습니다. 이제 프로젝트를 진행하며 부모 컴포넌트에서 input을 추가로 컨트롤할 일이 생겨도 걱정할 것이 없게 되었습니다. 컨트롤을 위한 메서드를 추가해 주기만 하면 되니까요!
마치며
오늘은 낯설지만 유용하게 사용할 수 있는 훅인 useImperativeHandle에 대해 알아보았는데요! React는 선언형 문법을 사용하므로 명령형 문법인 ref의 무분별한 사용은 좋지 않습니다. 공식 문서에도 ref 사용을 남용하지 말라고 하고 있으니, 저와 같이 필요한 순간에 적절히 사용하는 것이 좋을 것 같습니다.
오늘도 읽어주셔서 감사합니다.🙇♀️