React에서 Form Validation을 해보자 (ft. Custom Hook)

Meredith·2021년 11월 14일
0

React

목록 보기
1/1
post-thumbnail

단계적으로 validation logic을 구축해본다.
useState() 뿐만 아니라 useReducer()도 사용해본다.

Form validation (폼 유효성 검사)

  • user가 올바른 입력값을 전송하도록 여러 조건을 체크하고 잘못된 점이 있으면 알려주는 것

  • 회원가입이나 기업의 입사지원서 작성시 흔히 볼 수 있음

Form은 언제 validate해야 할까?

  • form이 submit 됐을 때 (onSubmit event)
  • user가 input에서 focus를 잃었을 때 (onBlur event)
  • 모든 keystroke (onChange event)

Empty input 처리 방법

  • was touched state: 입력칸을 클릭했는지 여부
  • lost focus: input field에 입력하다가 다 지우고 빈칸으로 놔둔 경우

예제와 함께 form validation을 추가해보자🧐

    <form>
      <div className='control-group'>
        <div className='form-control'>
          <label htmlFor='name'>First Name</label>
          <input type='text' id='name' />
        </div>
        <div className='form-control'>
          <label htmlFor='name'>Last Name</label>
          <input type='text' id='name' />
        </div>
      </div>
      <div className='form-control'>
        <label htmlFor='name'>E-Mail Address</label>
        <input type='text' id='name' />
      </div>
      <div className='form-actions'>
        <button>Submit</button>
      </div>
    </form>
  • submit button은 모든 input field가 valid할때만 활성화되도록 한다.
  • 해당 input field가 잘못되면 적절한 CSS와 함께 안내 문구도 아래에 함께 표시할 것

Ver.1

  • input field 하나하나 전부 event handler를 추가하기로 한다.
  • field 입력값과 input 클릭여부는 useState로 관리한다.
  • 입력된 값에 맞게 validation을 한다. (e.g. 일반 텍스트값: empty string인지?, 이메일: '@'을 포함하고 있는지?)
  • input field에 접근한 적은 있는데 입력된 값이 invalid할 경우 CSS class를 변경하여 색을 빨간색으로 바꿔준다.
  • 입력값이 모두 valid할 경우에만 button을 활성화한다 - disabled attribute 이용

아래는 first name만 적용한 코드이다.

const [enteredFirstName, setEnteredFirstName] = useState("");
  const [nameInputIsTouched, setNameInputIsTouched] = useState(false);

  const enteredFirstNameIsValid = enteredFirstName.trim() !== "";
  const firstNameInputIsInvalid = nameInputIsTouched && !enteredFirstNameIsValid;

  const formIsValid = enteredFirstNameIsValid;

  const firstNameInputBlurHandler = (event) => {
    setNameInputIsTouched(true);
  };

  const firstNameInputChangeHandler = (event) => {
    setEnteredFirstName(event.target.value);
  };

  const submitFormHandler = (event) => {
    event.preventDefault();

    if (!enteredFirstNameIsValid) return;

    console.log(enteredFirstName);

    setEnteredFirstName("");
    setNameInputIsTouched(false);
  };

  const nameInputClasses = firstNameInputIsInvalid
    ? "form-control invalid"
    : "form-control";

rendering 부분

<form onSubmit={submitFormHandler}>
      <div className="control-group">
        <div className={nameInputClasses}>
          <label htmlFor="name">First Name</label>
          <input
            type="text"
            id="name"
            value={enteredFirstName}
            onBlur={firstNameInputBlurHandler}
            onChange={firstNameInputChangeHandler}
          />
          {firstNameInputIsInvalid && (
            <p className="error-text">First name must not be empty.</p>
          )}
        </div>
        <div className="form-control">
          <label htmlFor="name">Last Name</label>
          <input type="text" id="name" />
        </div>
      </div>
      <div className="form-control">
        <label htmlFor="name">E-Mail Address</label>
        <input type="text" id="name" />
      </div>
      <div className="form-actions">
        <button disabled={!formIsValid}>Submit</button>
      </div>
    </form>

지금은 input field가 3개밖에 없어서 last name, email 코드를 first name처럼 추가해도 상관은 없겠지만...
만약에 엄청 많아지게 된다면 계속 복붙복붙하는 반복적인 작업의 연속이고 또 코드도 엄청 길어지게 될것이다.
그래서 validation하는 부분을 따로 빼서 custom hook으로 만들어두고 재사용을 해볼 것이다.

Ver.2

  • custom hook인 useInput()을 만든다.
  • Object destructuring으로 각 input field별 naming을 한다.

Custom Hook이란?

  • stateful logic을 재사용가능한 function으로 빼는 것
  • hook name은 무조건 use로 시작해야 한다.
  • 일반적인 function과 다르게 custom hook 안에선 React state나 React hook들을 사용할 수 있다.
  • 아무거나 return 가능하다. (변수 한 개라던지, Object 라던지)
  • useEffectuseMemo처럼 argument를 넘기고 parameter로 받을 수 있다.
  • custom hook을 호출하면 호출한 component 안에 hook의 state가 tie되기 때문에 logic만 공유되고 state는 따로 관리할 수 있다.

방법

  • 우선 src/hooks/use-input.js 경로를 가지는 파일을 camel case naming으로 생성하고 validate하는 logic을 그대로 use-input.js로 가져온다.

  • 이 logic은 특정 input이 아닌 모든 경우를 처리할 것이기 때문에 함수 이름을 일반적인 이름으로 바꿔준다.

const [enteredValue, setEnteredValue] = useState("");
const [isTouched, setIsTouched] = useState(false);

const enteredValueIsValid = validation(enteredValue);
const hasError = isTouched && !enteredValueIsValid;

const onBlurHandler = (event) => {
  setIsTouched(true);
};

const onChangeHandler = (event) => {
  setEnteredValue(event.target.value);
};
  • 가져오고 싶은 logic은 원하는 대로 가져오면 된다.

    • form submit 이후 state를 reset하는 logic도 함수로 처리한다.
      const reset = () => { setEnteredValue(""); setIsTouched(false); };
    • 나는 className도 hook 안에서 해결하고 싶어서 가져왔다.
      const inputClasses = hasError ? "form-control invalid" : "form-control";
  • state 관리는 이제 hook 안에서만 하게 된다.

  • 하지만 validation logic은 input마다 다르기 때문에, 이 부분은 form에서 넘어온 함수형 parameter를 이용한다.

const useInput = (validation) => {
  
  ...
  
  const enteredValueIsValid = validation(enteredValue);
  
  ...
  
}
  • 이렇게 만들어진 변수와 함수는 return하여 꺼내쓸 수 있게 하자.
return {
    value: enteredValue,
    isValid: enteredValueIsValid,
    hasError,
    onBlurHandler,
    onChangeHandler,
    reset,
    inputClasses,
  };

적용

  • hook을 form 안에서 사용할 차례이다.

  • Object destructuring으로 각각 input별로 변수를 정의하면 된다. 기본 형태는 아래와 같으며
    const {} = useInput((value) => value.trim() !== '');

  • 실제 적용시 이런 모습이다.

const {
    value: enteredFirstName,
    isValid: enteredFirstNameIsValid,
    hasError: firstNameInputHasError,
    onBlurHandler: firstNameBlurHandler,
    onChangeHandler: firstNameChangeHandler,
    reset: resetFirstNameInput,
    inputClasses: firstNameInputClasses,
  } = useFormInput((value) => value.trim() !== "");
  • 깔끔하게 hook을 부르는 것 만으로 모든 validation이 처리되었다!

Ver.3

  • 이번엔 useState()가 아닌 useReducer()로 state를 관리해본다.

  • state가 더 많고 복잡할때 유용한 방법이다.

useState() vs. useReducer()

useState()

  • main 상태관리 도구
  • 개별 state나 data
  • update가 한정되어 있고 state update가 쉬울때

useReducer()

  • useState()를 너무 많이 써야할 때
  • 관련된 state나 data
  • state update가 복잡할 때

  • 뭐가 맞고 뭐가 틀리다는 없으므로 상황에 맞게 사용하면 된다.
  • 지금의 validation logic에는 state가 많지 않기 때문에 useState()로도 충분하지만 연습용으로 useReducer()로도 해보기로 한다.

useReducer()의 기본 구조

const [(state snapshot), (dispatch 함수)] = useReducer( (reducer 함수), (initial state), (init 함수) );

  • state snapshot: useState()에서 첫번째 인자와 같은 의미. 쉽게 말해 현재 state 상태 (값)을 담고있다.

  • dispatch 함수: state update를 유도한다.

  • reducer 함수

    • action이 fetch되면 자동으로 trigger된다.
    • 가장 최근의 state snapshot을 return한다.
    • 구조는 (prevState, action) => newState 이다.
      action 값에 따라 state를 반환한다.
  • initial state: state의 초기 상태를 넣어준다.

  • init 함수 (optional)

    • lazy initialization을 하고 싶을때 넘겨주는 인자이다.
    • reducer 밖으로 initial state를 처리하는 logic을 빼고 싶을때 사용한다.
    • action의 응답에 따라 state를 나중에 reset하고 싶을 때 편하다.

(공식 문서 참고)

이론만 보면 이해하기 어려우니 실제 적용된 예시를 보자.
ver 2의 logic을 useReducer()로 바꿔본다.

  • useState()에서 관리되는 state는 initialState로 들어간다.
const initialInputState = {
  value: "",
  isTouched: false,
};
  • action의 type을 정의하기 위해 뭐가 있는지 살펴본다.
    state를 변경시키는 logic을 살펴보면 된다.
    • "INPUT": onChangeHandler에서 value값이 event.target.value로 변한다.
    • "BLUR": onBlurHandler에서 isTouched 값이 변경된다.
    • "RESET": state가 초기화된다.
  • 이를 바탕으로 dispatch로 함수들을 바꾼다.
  • reducer 함수에서 state 값이 바뀌면 type만 정의해서 보내면 된다.
  const onBlurHandler = () => {
    dispatch({ type: "BLUR" });
  };

  const onChangeHandler = (event) => {
    dispatch({
      type: "INPUT",
      value: event.target.value,
    });
  };

  const reset = () => {
    dispatch({ type: "RESET" });
  };
  • 위에서 정의한 세 type으로 Reducer 함수를 구성하면 된다.
    이때 변경되지 않는 값은 기존 state값을 그대로 넘겨주어야 한다.
const inputStateReducer = (state, action) => {
  if (action.type === "INPUT")
    return {
      value: action.value,
      isTouched: state.isTouched,
    };
  else if (action.type === "BLUR")
    return { value: state.value, isTouched: true };
  else if (action.type === "RESET") return { value: "", isTouched: false };

  return inputStateReducer;
};
  • 마지막으로 useState()의 state를 사용하던 코드를 intialState.(state 이름)으로 변경해주면 된다.
const enteredValueIsValid = validation(inputState.value);
const hasError = inputState.isTouched && !enteredValueIsValid;

...

return {
	value: inputState.value,
	...
}

최종본

import { useReducer } from "react";

const initialInputState = {
  value: "",
  isTouched: false,
};

const inputStateReducer = (state, action) => {
  if (action.type === "INPUT")
    return {
      value: action.value,
      isTouched: state.isTouched,
    };
  else if (action.type === "BLUR")
    return { value: state.value, isTouched: true };
  else if (action.type === "RESET") return { value: "", isTouched: false };

  return inputStateReducer;
};

const useInput = (validation) => {
  const [inputState, dispatch] = useReducer(
    inputStateReducer,
    initialInputState
  );

  const enteredValueIsValid = validation(inputState.value);
  const hasError = inputState.isTouched && !enteredValueIsValid;
  const inputClasses = hasError ? "form-control invalid" : "form-control";

  const onBlurHandler = () => {
    dispatch({ type: "BLUR" });
  };

  const onChangeHandler = (event) => {
    dispatch({
      type: "INPUT",
      value: event.target.value,
    });
  };

  const reset = () => {
    dispatch({ type: "RESET" });
  };

  return {
    value: inputState.value,
    isValid: enteredValueIsValid,
    hasError,
    onBlurHandler,
    onChangeHandler,
    reset,
    inputClasses,
  };
};

export default useFormInput;

결과

마무리

  • form validation을 하는 방법을 단계적으로 구성하는 방법을 정리해보았다.
  • 관리해야 하는 input field가 많아짐에 따라 custom hook을 사용하여 hook만 호출한다면 reusable한 코드 사용으로 인해 컴포넌트를 가볍게 관리할 수 있을 것이다.
  • useState()가 메인 상태관리 도구이지만 상황에 따라 useReducer()를 사용하여 state를 관리하는 방법도 있다.
  • 이렇게 custom hook을 사용해도 되지만 다른 form library를 사용하여도 된다. (e.g. Formik)
profile
노력하는 주니어 프론트엔드 개발자입니다 :)

0개의 댓글