Logo

양식(form) UI에 React Hook 적용하기

양식(form)은 사용자로 부터 데이터를 입력 받기 위해 웹애플리케이션에서 필수적인 요소임에도 불구하고, 리액트(React)로 직접 구현을 해보면 생각보다 고려해야 부분이 많다는 것을 느끼게 됩니다. 이번 포스팅에서는 React Hook를 이용해서 좀 더 깔끔하게 양식 UI를 구현하는 방법에 대해서 알아보겠습니다.

상태 관리 코드 분리하기

기본적으로 하나의 <form/> 여러 개의 <input/>, <select/>, <textarea/>으로 구성됩니다. 이렇게 사용자가 입력해야 값이 많아질수록 양식은 내부적으로 많은 상태를 가지게 되고 그에 따라 컴포넌트의 복잡도가 증가하게 됩니다.

과거에는 React 컴포넌트로 양식의 복잡한 상태 관리를 코드를 분리하기 위해서 HOC(High Order Component)나 Render Prop이 많이 사용되었는데요. 이 두가지 방법은 모두 결국 추가적인 컴포넌트를 필요로 하기 때문에 여전히 JSX 코드를 읽기 어려워진다는 단점이 있었습니다.

하지만 리액트 훅(React Hook)이 등장한 이후로는 이러한 복잡한 코드를 컴포넌트가 아닌 함수로 분리해낼 수 있게 되어 양식 컴포넌트를 구현하는 것이 훨씬 간편해졌습니다.

Hook 적용 전 양식 컴포넌트

예제 코드로 전형적인 로그인 폼에 대한 React 컴포넌트를 작성해보았습니다. 입력값의 상태 관리와 이벤트 처리를 위한 코드가 의외로 상당히 많은 부분을 차지하고 있는 것을 알 수 있습니다.

import React, { useEffect, useState } from "react";
import validate from "./validate";
import "./styles.css";

function LoginForm() {
  const [values, setValues] = useState({ email: "", password: "" });
  const [errors, setErrors] = useState({});
  const [submitting, setSubmitting] = useState(false);

  const handleChange = (event) => {
    const { name, value } = event.target;
    setValues({ ...values, [name]: value });
  };

  const handleSubmit = async (event) => {
    setSubmitting(true);
    event.preventDefault();
    await new Promise((r) => setTimeout(r, 1000));
    setErrors(validate(values));
  };

  useEffect(() => {
    if (submitting) {
      if (Object.keys(errors).length === 0) {
        alert(JSON.stringify(values, null, 2));
      }
      setSubmitting(false);
    }
  }, [errors]);

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label>
        Email:
        <input
          type="email"
          name="email"
          value={values.email}
          onChange={handleChange}
          className={errors.email && "errorInput"}
        />
        {errors.email && <span className="errorMessage">{errors.email}</span>}
      </label>
      <br />
      <label>
        Password:
        <input
          type="password"
          name="password"
          value={values.password}
          onChange={handleChange}
          className={errors.email && "errorInput"}
        />
        {errors.password && (
          <span className="errorMessage">{errors.password}</span>
        )}
      </label>
      <br />
      <button type="submit" disabled={submitting}>
        로그인
      </button>
    </form>
  );
}

React Hook 선언하기

지금부터 양식의 상태 관리를 담당해 줄 useForm() 이라는 커스텀 React Hook을 작성하겠습니다. React Hook도 관행에 따라 함수의 이름을 use로 시작할 뿐 어디까지나 자바스크립트 함수입니다. 모든 입력란의 초기값, 제출된 입력값을 처리하는 로직, 입력값을 검증하는 로직을 인자로 받습니다.

function useForm({ initialValues, onSubmit, validate }) {
  // 여기에 코드 작성
}

export default useForm;

상태 관리

양식과 관련된 상태 관리를 위해서 React의 내장 Hook인 useSate()을 사용하겠습니다. 모든 입력값과 오류 메시지 그리고 제출 처리 중 여부를 저장할 상태 변수와 변경 함수를 정의합니다. 그리고 이 Hook을 사용하는 컴포넌트에서 상태를 읽을 수 있도록 리턴해줍니다.

import { useState } from "react";

function useForm({ initialValues, onSubmit, validate }) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [submitting, setSubmitting] = useState(false);

  return {
    values,
    errors,
    submitting,
  };
}

변경 이벤트 처리

양식 상의 모든 입력란에서 발생하는 변경(change) 이벤트를 처리할 수 있는 범용 함수를 작성합니다. 마찬가지로 이 Hook을 사용하는 컴포넌트에서 각 입력란에 이 이벤트 핸들러를 설정할 수 있도록 리턴해줍니다.

function useForm({ initialValues, onSubmit, validate }) {
  // ... 생략 ...

  const handleChange = (event) => {
    const { name, value } = event.target;
    setValues({ ...values, [name]: value });
  };

  return {
    values,
    errors,
    submitting,
    handleChange,
  };
}

제출 이벤트 처리

마지막으로 양식에서 발생하는 제출(submit) 이벤트를 처리할 수 있는 함수를 작성합니다. React의 내장 Hook인 useEffect()을 사용해서 에러가 없을 때만 인자로 넘어온 입력값을 처리하는 로직을 실행합니다. 마찬가지로 이 Hook을 사용하는 컴포넌트에서 제출 버튼에 이 이벤트 핸들러를 설정할 수 있도록 리턴해줍니다.

import { useEffect, useState } from "react";

function useForm({ initialValues, onSubmit, validate }) {
  // ... 생략 ...

  const handleSubmit = async (event) => {
    setSubmitting(true);
    event.preventDefault();
    await new Promise((r) => setTimeout(r, 1000));
    setErrors(validate(values));
  };

  useEffect(() => {
    if (submitting) {
      if (Object.keys(errors).length === 0) {
        onSubmit(values);
      }
      setSubmitting(false);
    }
  }, [errors]);

  return {
    values,
    errors,
    submitting,
    handleChange,
    handleSubmit,
  };
}

완성된 Hook

지금까지 작성한 useForm() Hook 함수는 전체 코드는 다음과 같습니다.

import { useEffect, useState } from "react";

function useForm({ initialValues, onSubmit, validate }) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [submitting, setSubmitting] = useState(false);

  const handleChange = (event) => {
    const { name, value } = event.target;
    setValues({ ...values, [name]: value });
  };

  const handleSubmit = async (event) => {
    setSubmitting(true);
    event.preventDefault();
    await new Promise((r) => setTimeout(r, 1000));
    setErrors(validate(values));
  };

  useEffect(() => {
    if (submitting) {
      if (Object.keys(errors).length === 0) {
        onSubmit(values);
      }
      setSubmitting(false);
    }
  }, [errors]);

  return {
    values,
    errors,
    submitting,
    handleChange,
    handleSubmit,
  };
}

export default useForm;

Hook 적용 후 양식 컴포넌트

지금까지 작성한 훅을 위에서 보여드렸던 양식 컴포넌트에 한 번 적용해보았습니다. 양식과 관련된 복잡한 코드가 분리되어 구현이 상당히 깔끔해진 것을 알 수 있습니다.

import React from "react";
import useForm from "./useForm";
import validate from "./validate";
import "./styles.css";

function LoginForm() {
  const { values, errors, submitting, handleChange, handleSubmit } = useForm({
    initialValues: { email: "", password: "" },
    onSubmit: (values) => {
      alert(JSON.stringify(values, null, 2));
    },
    validate,
  });

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label>
        Email:
        <input
          type="email"
          name="email"
          value={values.email}
          onChange={handleChange}
          className={errors.email && "errorInput"}
        />
        {errors.email && <span className="errorMessage">{errors.email}</span>}
      </label>
      <br />
      <label>
        Password:
        <input
          type="password"
          name="password"
          value={values.password}
          onChange={handleChange}
          className={errors.email && "errorInput"}
        />
        {errors.password && (
          <span className="errorMessage">{errors.password}</span>
        )}
      </label>
      <br />
      <button type="submit" disabled={submitting}>
        로그인
      </button>
    </form>
  );
}

양식 검증 코드

참고로 양식에 입력된 값을 검증하는 코드는 본 포스팅에 직접적으로 관련이 없어서 validate.js 파일에 별도로 작성하여 불러서 사용했습니다.

export default function validate({ email, password }) {
  const errors = {};

  if (!email) {
    errors.email = "이메일이 입력되지 않앗습니다.";
  } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(email)) {
    errors.email = "입력된 이메일이 유효하지 않습니다.";
  }

  if (!password) {
    errors.password = "비밀번호가 입력되지 않았습니다.";
  } else if (password.length < 8) {
    errors.password = "8자 이상의 패스워드를 사용해야 합니다.";
  }

  return errors;
}

전체 코드

본 포스팅에서 작성한 모든 코드는 아래에서 확인하고 직접 실행해보실 수 있습니다.

마치면서

이상으로 Hook을 사용해서 어떻게 양식 컴포넌트로 부터 상태 관리 로직을 분리해내는지에 대해서 알아보았습니다. 다음 포스팅에서는 이러한 기법을 내부적으로 사용하고 있는 라이브러리인 React Hook Form에 대해서 소개해드리겠습니다.