폼 다루기 - 폼 상세 제어(포커스, 커스텀 훅)

#React

useRef 훅 활용

제어 컴포넌트를 사용할 때 useRef를 활용하면 폼 요소에 포커스를 주거나 스크롤을 이동하는 동작을 수행할 수 있다.

import { useState, useRef } from 'react';

export default function App(){
  const [ email, setEmail ] = useState('');
  const [ password, setPassword ] = useState('');
  
  const idRef = useRef<HTMLInputElement>(null);
  const passwordRef = useRef<HTMLInputElement>(null);
  
  const changeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  }
  const changePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value);
  }
  
  const submitHandler = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (email.trim() == ''){
      alert('이메일을 입력하세요');
      idRef.current?.focus(); // 이메일이 공란일 때 id input으로 포커스 이동
      return;
    }
    else if(password.trim() == '') {
      alert('비밀번호를 입력하세요');
      passwordRef.current?.focus();  // 비밀번호가 공란일 때 password input으로 포커스 이동
      return;
    }
  }
  return(
    <form onSubmit={submitHandler}>
      <label>
        이메일
        <input type='text' placeholder='이메일을 입력하세요' value={email} onChange={changeEmail} ref={idRef}></input>
      </label>
      <label>
        비밀번호
        <input type='text' placeholder='비밀번호를 입력하세요' value={password} onChange={changePassword} ref={passwordRef}></input>
      </label>
      <button type="submit">로그인</button>
    </form>
  );
}

커스텀 훅

커스텀 훅(custom hook)이란 리액트 훅을 사용자가 직접 정의한 함수입니다. 기존의 리액트 훅을 다른 함수로 감싸 특정 기능을 수행하도록 새롭게 정의한 함수로, 반복 로직을 하나의 함수로 묶어 재사용성을 높이는 데 목적이 있습니다.

커스텀 훅을 사용하면 제어 컴포넌트에서 상태 마다 각각 이벤트 핸들링을 했던 로직을 줄일 수 있다. 기존 커스텀 훅을 쓰지 않은 코드는 아래와 같다.

import { useState } from 'react';

export default function App(){
  const [ email, setEmail ] = useState('');
  const [ password, setPassword ] = useState('');
  
  const changeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  }
  const changePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value);
  }

  return(
    <form>
      <label>
        이메일
        <input type='text' placeholder='이메일을 입력하세요' value={email} onChange={changeEmail}></input>
      </label>
      <label>
        비밀번호
        <input type='text' placeholder='비밀번호를 입력하세요' value={password} onChange={changePassword}></input>
      </label>
      <button type="submit">로그인</button>
    </form>
  );
}

커스텀 훅(useInput)을 사용한 코드는 아래와 같다.

import { useState } from 'react';

function useInput(initialValue =''){
  const [value, setValue] = useState(initialValue);
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  }
  return { value, onChange };
}

export default function App(){
  const { value: email, onChange: changeEmail } = useInput();
  const { value: password, onChange: changePassword } = useInput();

  return(
    <form>
      <label>
        이메일
        <input type='text' placeholder='이메일을 입력하세요' value={email} onChange={changeEmail}></input>
      </label>
      <label>
        비밀번호
        <input type='text' placeholder='비밀번호를 입력하세요' value={password} onChange={changePassword}></input>
      </label>
      <button type="submit">로그인</button>
    </form>
  );
}

여기서 useInput()은 객체를 반환한다. 그리고 App에서 { value: email, onChange: changeEmail } = useInput(); 형태로 값을 가지고 오는데, 이 부분은 객체 구조 분해 할당 문법을 적용한 것이다. 객체 구조 분해 할당을 사용할 때 콜론(:) 옆에 새로운 변수 명을 작성하여 사용할 수 있다.

위 코드에서는 같은 파일에 커스텀 훅을 선언했지만, 보통 .ts 파일로 분리하여 src/hooks/useInput.ts 경로에 저장한다.

커스텀 훅 심화

처음 커스텀 훅을 접하다 보면 ‘이걸 어떻게 만들어서 적용하지?’ 하는 막연한 두려움이 들 수 있습니다. 하지만 걱정하지 마세요. 여러분은 아직 공부하는 단계이고, 이러한 고급 기능을 처음부터 능숙하게 다루는 것은 누구에게나 어려운 일입니다.

🥲

이 부분은 아직 어려워서 타입 스크립트 문법을 배우고 코드를 받아 써보는 것에 그쳤다. 텍스트, 체크박스, 라디오 버튼 모두 적용할 수 있는 확장형 커스텀 훅 예제이다.

// src/hooks/useInputEx.ts
import { useState } from 'react';

type InputType = 'text' | 'checkbox' | 'radio';

interface UseInputProps<T>{
  initialValue: T,
  validateFn: (value: T) => string | undefined;
  type? : InputType;
}

export default function useInputEx<T>({initialValue, validateFn, type}: UseInputProps<T>){
  const [value, setValue] = useState<T>(initialValue);
  const [error, setError] = useState<string>('');
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = type == 'checkbox' ? (e.target.checked as unknown as T) : (e.target.value as T);
    setValue(newValue);
    setError('');
  };
  const validate = (): boolean => {
    const validationError = validateFn(value);
    setError(validationError || '');
    return !validationError;
  };
  const reset = () => {
    setValue(initialValue);
    setError('');
  };
  return {
    value,
    error,
    onChange,
    validate,
    reset,
  }
}

사용 예시

const { value: name, error: nameError, onChange: handleNameChange, validate: validateName } = useInputEx<string>({
	initialValue: '', validateFn: (value) => {
      if(!value) return '이름은 필수입니다.';
      return undefined;
    },
})
...
<input type='text' value={name} onChange={handleNameChange} />
{ nameError && <p>{nameError}</p> }

제네릭

type InputType = 'text' | 'checkbox' | 'radio';

interface UseInputProps<T>{
  initialValue: T,
  validateFn: (value: T) => string | undefined;
  type? : InputType;
}

interface의 속성에는 타입을 정의하는데, validateFn 같은 경우는 함수 속성이라 매개변수와 반환형의 타입을, type?은 별도로 정의한 InputType을 가진다. validateFn에서 매개변수를 정의할 때 value:없이 T 라고만 적어서는 안된다. 타입스크립트에서 함수 타입을 선언하는 표준 문법은 (매개변수명: 타입) => 반환타입 이므로, 매개변수 이름은 실제 구현부에서 다른 이름을 써도 상관없지만, 인터페이스 상에서는 자리를 표시하는 역할로 반드시 필요하다. type?은 속성이 있을 수도 있고 없을 수도 있다는 뜻의 선택적 속성(Optional Property) 이다. (옵셔널 체이닝은 객체에 접근하는 것이라 선택적 속성과는 다름!) 이 때 type을 지정하지 않으면 그 자리는 undefinded가 되지, 그 자리에 ‘text’가 초깃값으로 오지는 않는다.

이중 타입 단언

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = type == 'checkbox' ? (e.target.checked as unknown as T) : (e.target.value as T);
    setValue(newValue);
    setError('');
  };

e.target.checked as unknown as T이중 타입 단언이라는 문법이다. e.target.checked는 무조건 boolean이지만, 하지만 우리 훅의 제네릭 T가 무엇일지 타입스크립트는 확신할 수 없다. 따라서 일단 이 타입이 뭔지 모른다고 가정(unknown)하고, 다시 내가 정의한 T로 강제 변환한다. (만약 type은 ‘checkbox’이고, T는 string으로 잘못 정의한 상황이면 런타임에서 터진다.)

이중 타입 단언을 사용하는 이유는 setValue() 때문인데, T에 따라 setValue()에 들어가는 타입이 달라 타입 단언을 사용하지 않고 const newValue = type == 'checkbox' ? (e.target.checked) : (e.target.value); 라고 사용하면 빌드가 되지 않는다.

두 번 끊어서 확인하면 쉽다. e.target.checked as unknown : unknown으로 친다. unknown as T : T로 친다.

e.target.checked에만 unknown이 붙은 이유는 boolean과 T가 서로 완전 다른 타입일 가능성이 높기 때문이라고 한다. 더 엄격한 설정에서는 string인 e.target.value도 e.target.value as unknown as T라고 작성해주는 것이 좋다.