폼 다루기 - 폼 요소 컴포넌트 분리와 리액트 19 이후 ref(클린업)

#React

폼 요소 컴포넌트 분리 방법

…폼 요소를 컴포넌트 단위로 분리하면 코드의 재사용성과 유지보수성이 향상되고, ref를 활용한 DOM 제어도 보다 유연하게 처리할 수 있습니다.

폼 요소(ex. input, textarea)를 별도의 컴포넌트로 분리하여 사용할 수 있다. 리액트의 ComponentPropsWithRef 타입을 사용하면 HTML 태그가 가지고 있는 모든 속성을 가지고 올 수 있고, 이걸 컴포넌트의 Props로 지정하여 사용한다.

// components/ButtonRef.tsx
type ButtonProps = React.ComponentPropsWithRef<'button'>;

export default function ButtonRef({ children, ...rest }: ButtonProps) {
  return <button {...rest}>{children}</button>
}

// components/InputRef.tsx
type InputProps = React.ComponentPropsWithRef<'input'> & {label: string;};

export default function InputRef({label, ...rest}: InputProps) {
  const id = rest.id;
  return (
    <div>
      {label && <label htmlFor={id}>{label}</label>}
      <input {...rest} />
    </div>
  )
}

// App.tsx
import { useState } from "react";
import InputRef from "./components/InputRef";
import ButtonRef from "./components/ButtonRef";

export default function App(){
  const [userName, setUserName] = useState('');
  const [password, setPassword] = useState('');

  const handleLogin = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!userName) {
      alert('아이디를 입력하세요!');
      return;
    }
    if (!password) {
      alert('패스워드를 입력하세요!');
      return;
    }
  }

  return (
    <>
      <form onSubmit={handleLogin}>
        <InputRef label="아이디" type="text" id="username" value={userName} onChange={(e)=>(setUserName(e.target.value))}></InputRef>
        <InputRef label="패스워드" type="text" id="password" value={password} onChange={(e)=>(setPassword(e.target.value))}></InputRef>
        <ButtonRef type="submit">제출</ButtonRef>
      </form>
    </>
  );
}

InputRef 컴포넌트는 교차 타입(&)을 써서 왼쪽의 타입(React.ComponentPropsWithRef)과 오른쪽의 타입({label: string;}을 합쳤다. 기본 Input 태그의 속성에 추가로 label이라는 키를 추가하여 커스텀 input을 만들기 위해서다. JSX 구현부를 보면 label 값이 있을 때 label 태그를 추가하여 input 앞에 출력하는 것을 확인할 수 있다.

위 코드에서 경고창을 표시한 후 InputRef에 자동으로 포커스를 이동하고 싶다면, ref 객체를 props로 전달해주어야한다. (컴포넌트를 분리하지 않았을 때는 useRef를 사용하면 해결)

ref 객체 전달

리액트 18 이전

리액트 18 이전에는 반드시 forwardRef() 함수를 이용하여 ref를 별도로 처리해야했다.

// components/inputRef.tsx
import { forwardRef } from "react";

type InputProps = React.ComponentPropsWithRef<'input'> & {label: string;};

const InputRef = forwardRef<HTMLInputElement, InputProps> (
  ({ label, ...rest }, ref) => {
    const id = rest.id;
    return (
      <div>
        {label && <label htmlFor={id}>{label}</label>}
        <input ref={ref} {...rest} />
      </div>
    );
  }
);
InputRef.displayName = 'InputRef';
export default InputRef;

// App.tsx
import { useRef, useState } from "react";
import InputRef from "./components/InputRef";
import ButtonRef from "./components/ButtonRef";

export default function App(){
  const [userName, setUserName] = useState('');
  const [password, setPassword] = useState('');

  const userInputEl = useRef<HTMLInputElement>(null); // useRef 추가
  const passwordInputEl = useRef<HTMLInputElement>(null);  // useRef 추가

  const handleLogin = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!userName) {
      alert('아이디를 입력하세요!');
      userInputEl.current?.focus();  // focus 추가
      return;
    }
    if (!password) {
      alert('패스워드를 입력하세요!');
      passwordInputEl.current?.focus();  // focus 추가
      return;
    }
  }

  return (
    <>
      <form onSubmit={handleLogin}>
        <InputRef ref={userInputEl}  // ref 속성 추가
          label="아이디" type="text" id="username" value={userName} onChange={(e)=>(setUserName(e.target.value))}></InputRef> 
        <InputRef ref={passwordInputEl}  // ref 속성 추가
          label="패스워드" type="text" id="password" value={password} onChange={(e)=>(setPassword(e.target.value))}></InputRef> 
        <ButtonRef type="submit">제출</ButtonRef>
      </form>
    </>
  );
}

forwardRef를 사용하면 리액트 개발자 도구에서 컴포넌트 이름이 Anonymous로 보일 수 있는데, displayName을 수동으로 설정(InputRef.displayName)해 주면 디버깅 시 이름을 명확하게 확인할 수 있다.

리액트 19 이후

리액트 19부터는 ref 객체도 일반 prop처럼 컴포넌트에 전달하여 사용할 수 있다. 다른 prop들과 마찬가지로 구조 분해 할당을 이용하면 된다. App.tsx 코드는 리액트 18 이전와 같다.

// components/InputRef.tsx
type InputProps = React.ComponentPropsWithRef<'input'> & {label: string;};

export default function InputRef({label, ref, ...rest}: InputProps) { // ref 키 추가
  const id = rest.id;
  return (
    <div>
      {label && <label htmlFor={id}>{label}</label>}
      <input ref={ref} {...rest} /> // ref 속성 추가
    </div>
  )
}

클린업 함수

클린업(cleanup) 함수는 useEffect 훅 내부에서 정의하는 일종의 정리 작업 함수입니다. 예를 들어, 컴포넌트가 언마운트되거나, 다음 useEffect 훅이 실행되기 전에 이벤트 리스너 제거와 같은 사이드 이펙트를 정리하는 데 사용합니다.

아직 경험해보지는 못했지만, 클린업 함수를 제대로 실행해주지 않으면 메모리 누수가 발생한다고 한다. 클린업 함수는 나중에 경험이 쌓이고 추후 다시 살펴봐야겠다.

리액트 18 이전

이벤트 등록과 해제를 useEffect 훅에서 수동으로 처리합니다.

import { useEffect, useRef } from "react";

export default function App() {
  const divRef = useRef<HTMLDivElement>(null);
  
  useEffect(() => {
    const handleScroll = () => {
      if (divRef.current) {
        console.log('div scrollTop:', divRef.current.scrollTop);
      }
    };
    const currentDiv = divRef.current; // 변수에 담아두기(클린업 시점에 사라질 수 있음)
    currentDiv?.addEventListener('scroll', handleScroll); // div에 스크롤 이벤트 등록
    return () => {
      currentDiv?.removeEventListener('scroll', handleScroll); // 클린업 함수, 담아둔 변수로 지우기
    }
  }, []);
  
  return (
    <div ref={divRef} // 실제 DOM에 ref 연결
      style={{border: '1px solid black', width: '200px', height: '100px', overflowY: 'scroll'}}>
      {[...Array(20)].map((_, i)=>(
        <p key={i}>item {i + 1}</p>
      ))}
    </div>
  );
}

리액트 19 이후

ref 속성에 콜백 함수를 사용해 DOM 등록과 클린업을 동시에 처리할 수 있습니다.

export default function App() {
  const handleScroll = (e: Event) => {
    const target = e.target as HTMLDivElement;
    console.log('div scrollTop: ', target.scrollTop);
  };

  return (
    <div ref={(currentDiv) => {
      currentDiv?.addEventListener('scroll', handleScroll);
      return () => { currentDiv?.removeEventListener('scroll', handleScroll) };}}
      style={{border: '1px solid black', width: '200px', height: '100px', overflowY: 'scroll'}}
      >
      {[...Array(20)].map((_, i)=>(
        <p key={i}>item {i + 1}</p>
      ))}
    </div>
  );
}