리액트에서 입력 다루기

수많은 입력의 여정

2023-10-30

현재 프로젝트에서 굉장히 많은 양의 폼을 다루고 있습니다. Form을 어떻게 다루면 좋을 지 알아보려고 합니다.

간단한 Form

현재 비밀번호 입력, 비밀번호 확인 두 개의 화면이 있는 폼입니다.

Alt text

제가 가장 먼저 생각났던 방법은 각각 input의 value를 다룰 상태와 핸들러를 만들어서 넘겨주는 방식입니다.

import { useState } from 'react'
import { PassWordInput } from '@/components'

const PassWordGroupForm = () => {
  //각 input마다 상태를 기록해둡니다.
  const [password, setPassWord] = useState<string>('')
  const [passwordConfirm, setPassWordConfirm] = useState<string>('')

  const handleSubmit = () => {
    console.log(password, passwordConfirm)
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>PassWord</label>
      <PassWordInput
        type="password"
        onChange={(password) => setPassWord(password ?? '')}
      />
      <label>PassWordConfirm</label>
      <PassWordInput
        type="confirmation"
        onChange={(password) => setPassWordConfirm(password ?? '')}
      />
    </form>
  )
}

export default PassWordGroupForm

위의 코드에서 input은 모두 제어 컴포넌트입니다.

마크다운 이미지

제어 컴포넌트에서 form의 데이터들은 컴포넌트의 상태(state)로 관리됩니다.

마크다운 이미지

위 그림을 차근차근 살펴보겠습니다. 처음 상태는 빈 문자열입니다. a를 입력했을 때 handleNameChange가 a를 가져오고 input이 다시 a를 갖도록 다시 랜더링됩니다.

input의 변경을 계속 추적해 나가는 방식이기 때문에, state와 UI가 항상 동기화가 될 수 밖에 없습니다.

위 순서로 진행이 됩니다. 그럼 해당 방식의 문제점이 무엇일까요?

관리하는 상태가 많아진다면..?

위의 그림에서는 단순히 2개의 input이 존재하지만, form에 여러 input이 존재해야 하는 상황이라면 어떻게 될까요?? 예를 들어 다음과 같은 form이 있다고 가정해보겠습니다.

import { useState } from 'react'
import { PassWordInput } from '@/components'

const PassWordGroupForm = () => {
  //각 input마다 상태를 기록해둡니다.
  const [password, setPassWord] = useState<string>('')
  const [passwordConfirm, setPassWordConfirm] = useState<string>('')
  const [phoneNumber, setPhoneNumber] = useState('');
  const [job, setJob] = useState('');
  const [aboutMe, setAboutMe] = useState('');

  const handleSubmit = () => {
    console.log(password, passwordConfirm)
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>PassWord</label>
      <PassWordInput
        type="password"
        onChange={(password) => setPassWord(password ?? '')}
      />
      <label>PassWordConfirm</label>
      <PassWordInput
        type="confirmation"
        onChange={(password) => setPassWordConfirm(password ?? '')}
      />
      <Input name='이메일' value={email} onChange={setEmail} />
      <Input name='비밀번호' value={password} onChange={setPassword} />
      <Input name='집주소' value={address} onChange={setAddress} />
    </form>
  )
}

결국 form안에서 관리해야 하는 input이 늘어날수록 점점 복잡해지고, 해당 상태를 추적하기 힘들어집니다. 요기서 만약 유효성검증까지 판별해야 한다면, 정말 정말 관리하기 힘들어질 수 있습니다.

또한 모든 상태가 한번에 뭉쳐있기 때문에, 해당 컴포넌트를 다시 재사용하기도 쉽지 않아집니다. 그럼 이 문제점을 어떻게 해결할 수 있을까요?

useImperativeHandler 훅

이 훅은 생소할 수도 있습니다..(저도 처음 보는 훅입니다.!) useImperativeHandler훅은 부모 컴포넌트에서 자식 컴포넌트의 stated을 관리할 수 있게 해주는 훅입니다. 앞서 작성한 비밀번호 관리 폼을 이 훅으로 바꿔보겠습니다.

//PassWordGroup.tsx
import { Ref, useImperativeHandle, useState } from 'react'
import { PassWordInput } from '@/components'

interface PasswordGroupValues {
  password: string
  passwordConfirm: string
}

const PassWordGroup = ({
  ref,
}: {
  ref: Ref<{ values: PasswordGroupValues }>
}) => {
  const [password, setPassWord] = useState<string>('')
  const [passwordConfirm, setPassWordConfirm] = useState<string>('')

  useImperativeHandle(
    ref,
    () => ({
      values: {
        password,
        passwordConfirm,
      },
    }),
    [password, passwordConfirm],
  )

  return (
    <>
      <label>PassWord</label>
      <PassWordInput
        type="password"
        onChange={(password) => setPassWord(password ?? '')}
      />
      <label>PassWordConfirm</label>
      <PassWordInput
        type="confirmation"
        onChange={(password) => setPassWordConfirm(password ?? '')}
      />
    </>
  )
}

export default PassWordGroup

이렇게 바꾸면 어떤 점이 개선될까요? PassWordGroup의 컴포넌트 안에서 관리되는 value를 직접 내보내지 않고 오직 values 객체를 통해만 값을 제공합니다. 따라서 부모 컴포넌트에서는 이 컴포넌트 내부의 상태에 대해 간섭을 덜 할 수 있습니다.

이제 부모 컴포넌트에서 이 PassWordGroup을 사용해보겠습니다. 이렇게 쓰는 것이 왜 더 좋은지?에 대한 예시가 한 가지 더 존재합니다.

import { useRef } from 'react'
import PassWordGroup from '@/components/PassWordGroup'
// 부모 컴포넌트에서 ref 생성
interface PasswordGroupValues {
  password: string
  passwordConfirm: string
}

const PassWordGroupContext = () => {
  const passwordGroupRef = useRef<{ values: PasswordGroupValues } | null>(null)

  return (
    <div>
      <PassWordGroup ref={passwordGroupRef} />
    </div>
  )
}

export default PassWordGroupContext

선택된 영화 제목에 따라, 영화의 전체 정보가 필요한 경우가 있을 수 있습니다. 앞서 보았던 useImperativeHandler훅과 ref를 적절히 써서 이를 해결할 수 있습니다.

//부모
const 영화Form = () => {
  const selectedMovieRef = useRef(null);
  //커스텀 훅에서 검증
  const { handleMovieValidation } = useMovieValidation();

  return (
    <>
      <영화_선택 ref={selectedMovieRef} />
      <계좌번호>
        <button
          onClick={() => {
            handleMovieValidation(selectedMovieRef.current.selectedMovie);
          }}
        />
      </계좌번호>
    </>
  );
};

const 영화_선택 = (_, ref: Ref) => {
  const movieList = useMovieList();
  const [selectedMovieName, setSelectedMovieName] = useState('');
  const selectedMovie = useMemo(() => {
    return movieList.data.find(({ name }) => name === selectedMovieName);
  }, [movieList, selectedMovieName]);

  useImperativeHandle(ref, () => ({ selectedMovieName }), [selectedMovieName]);

  return (
    <Select>
      {movieList.map(movie => (
        <Option key={movie.id} onChange={onchange(movie)}>
          {movie.name}
        </Option>
      ))}
    </Select>
  );
};

영화선택의 자식 컴포넌트를 만들고 부모 컴포넌트에서 선택된 영화에 대한 값을 알 수 있게 ref을 사용하고 있습니다.

이렇게 응집도 있게 부모-자식으로 컴포넌트를 구분하고, ref와 useImperativeHandler훅을 사용해도, 결국 input마다 상태가 들어갈 수 밖에 없습니다.

마크다운 이미지

그럼 input의 상태를 계속 추적하지 말고, 특정 행위가 발생했을 때 처리하는 건 어떨까요? 예를 들어 사용자의 버튼 입력 한번이 발생하면 그 떄, 특정 로직을 실행하는 것입니다!! 제어 컴포넌트와 다른, 비제어 컴포넌트에 대해 알아보겠습니다!

비제어 컴포넌트

비제어 컴포넌트는 각 input의 value가 DOM에 저장됩니다.

마크다운 이미지

상태와 이벤트 핸들러를 사용하는 대신, ref을 이용해 DOM에 접근해 이벤트를 핸들링합니다.

const NameForm = () => {
  const inputRef = useRef<HTMLInputElement>(null);

  function handleSubmit(event) {
    alert('A name was submitted: ' + inputRef.current.value);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input type="text" ref={inputRef} />
      </label>
      <input type="submit" value="Submit" />
    </form>
  )
}

input의 상태를 따라가지 않고 ref를 통해 필요할 시점에 값을 받아옵니다. 앞의 비밀번호 폼을 비제어 컴포넌트로 바꿔볼까요?

import { Ref, useImperativeHandle, useRef } from 'react'
import { PassWordInput } from '@/components'

interface PasswordGroupValues {
  password: string
  passwordConfirm: string
}

const PassWordGroup = ({
  ref,
}: {
  ref: Ref<{ values: PasswordGroupValues }>
}) => {
  const passwordRef = useRef<HTMLInputElement | null>(null)

  const passwordconfirmRef = useRef<HTMLInputElement | null>(null)
  useImperativeHandle(
    ref,
    () => ({
      values: {
        password: passwordRef?.current?.value ?? '',
        passwordConfirm: passwordconfirmRef.current?.value ?? '',
      },
    }),
    [],
  )

  return (
    <>
      <label>PassWord</label>
      <PassWordInput type="password" ref={passwordRef} />
      <label>PassWordConfirm</label>
      <PassWordInput type="confirmation" ref={passwordconfirmRef} />
    </>
  )
}

export default PassWordGroup

비제어 컴포넌트를 사용하기 어려운 상황

그러나 이 비제어 컴포넌트를 사용 시, 어려움이 존재합니다. 예를 들어 유효성 검사를 input값이 바뀔 시점마다 하고 싶다면?? 해당 방식이 적합하지 않을 수 있습니다.

결국 랜더링을 적게 가져가고 싶다면 비제어 컴포넌트를 쓰는 게 맞는 것 같지만 동시에 유효성 검사도 할 수 없을까..?라는 고민이 생기게 됩니다. 이를 더 적은 코드로 효과적으로 해결 가능한 form 라이브러리들이 많이 존재하는 것 같습니다!