useInput 커스텀 훅에서 에러 메세지 관리하기

Shyuuuuni·2023년 1월 31일
27

📚 Tech-Post

목록 보기
7/10
post-thumbnail

useInput 커스텀 훅

피그마-전체

최근 진행중인 프로젝트에서 위와 같이 화면을 구성하려고 설계했다. 보이는 것 처럼 Input Box 여러개를 사용해야 했고, 각각을 컴포넌트로 구현하려면 input 태그를 선언할 때 마다 useState 훅을 호출해서 입력 값 상태를 선언하고, value props에 그 상태값을 넘기고, onChange props로 상태를 변경해주는 로직을 반복해야 했다.

import React, { useState } from "react";

export default function Sample() {
  const [input, setInput] = useState("");

  return (
    <input value={input} onChange={(e) => setInput(e.target.value)} />
  )
}

이렇게 반복되는 로직을 깔끔하게 처리하는 방법이 바로 커스텀 훅이다.

  • 입력 값 상태 (input)
  • 상태 변경을 적용하는 콜백함수 (changeHandler)

이렇게 두가지 로직을 재사용 할 수 있도록 커스텀 훅으로 구현했다.

커스텀 훅

import { useState } from "react";

interface UseInputProps {
  initialValue?: string;
}

export default function useInput({ initialValue = "" }: UseInputProps) {
  const [input, setInput] = useState(initialValue);

  const changeHandler = (e) => {
    setInput(e.target.value);
  };

  return { input, changeHandler };
}

컴포넌트

import useInput from "hooks/useInput";
import React from "react";

export default function Sample() {
  const { input, changeHandler } = useInput({});

  return <input value={input} onChange={changeHandler} />;
}

에러 메세지 관리하기

에러-메세지

위와 같이 커스텀 훅으로 로직을 분리하고 나서, 이메일을 입력해주세요.이메일 형식으로 입력해주세요. 같은 에러 메세지를 어떻게 구현할까 고민했다.

결과적으로, 에러 메세지도 useInput 커스텀 훅으로 관리하기로 했는데, 가장 큰 이유는 에러 메세지는 입력 값 상태와 아주 밀접하게 관련되어 있기 때문이다.

입력 값에 따라서 에러인지 검증하고, 검증 결과에 따라 에러 상태를 판단하고, 에러 상태에 따라서 에러 메세지를 결정하는 부분을 커스텀 훅으로 처리할 수 있을 것으로 생각했다.

그래서 최종적으로 작성한 코드를 보면,

커스텀 훅

import { useState } from "react";
import EventType from "types/Event";

type InputErrorHandler = (error: string) => string;

export interface UseInputProps {
  initialValue?: string;
  validate?: (value: string, errorHandler: InputErrorHandler) => string;
  errorHandler?: InputErrorHandler;
}

export default function useInput({
  initialValue = "",
  validate = () => "",
  errorHandler = () => "",
}: UseInputProps) {
  const [input, setInput] = useState(initialValue);
  const [errorMessage, setErrorMessage] = useState("");

  const isError = 0 < errorMessage.length;

  const changeHandler: EventType<"input", "onChange"> = (e) => {
    setInput(e.target.value);
    setErrorMessage(validate(e.target.value, errorHandler));
  };

  const handleInputError = (error: string) => {
    setErrorMessage(errorHandler(error));
  };

  const resetValue = () => {
    setInput("");
    setErrorMessage("");
  };

  return {
    input,
    isError,
    errorMessage,
    changeHandler,
    handleInputError,
    resetValue,
  };
}

먼저 useInput 훅을 호출할 때 validateerrorHandler 값으로 함수를 받아오도록 구현했다.

  • errorHandler 함수는 error(string)에 따라 에러 메세지(string)를 반환하는 함수다.
  • errorHandler 함수에서 반환한 값(string)으로 setErrorMessage 함수를 호출해서 에러 메세지를 업데이트한다.
  • validate 함수는 input(string)에 따라 에러 상태를 판단하고, 에러 상태에 따라 errorHandler를 호출하여 반환하는 함수다.

그 이외에도,

  • 에러 상태를 확인하는 isError
  • 모든 값을 초기화하는 resetValue 등 함수를 추가했다.

여기서 handleInputError 함수에 조금 신경써서 구현했는데, 이 함수는 입력 에러를 발생시키는 함수로, 실제 구현을 보면 단순히 errorHandler를 호출해서 에러 메세지를 업데이트 한다.

validate 함수와 분리한 이유는 validateonChange 이벤트가 발생할 때 마다 실행되도록 구현했고, handleInputError 함수는 커스텀 훅의 반환값으로 내보내서, 사용자가 원하는 시점에 에러를 발생시킬 수 있도록 구현했다는 점이다.

예를 들어, 아이디가 존재하는지에 따라 에러 메세지를 표시해야 하는 경우를 생각해보면, validate 하나로는 onChange 이벤트가 발생할 때 마다 서버에 검증 요청을 보내야 한다.

그래서 외부에서 에러 이벤트를 호출할 수 있는 인터페이스를 제공하도록 구현했다.

컴포넌트

export default function CredentialsLogin() {
  const {
    input: email,
    errorMessage,
    changeHandler: idChangeHandler,
    handleInputError,
  } = useInput({
    initialValue: "",
    errorHandler: (error) => {
      switch (error) {
        case "EmailNotFoundError":
          return "존재하지 않는 이메일입니다.";
        case "EmailFormError":
          return "이메일 형식으로 입력해주세요.";
        case "EmptyEmailError":
          return "이메일을 입력해주세요.";
        default:
          return "";
      }
    },
  });
  const dispatch = useContext(HistoryDispatch);

  const formSubmitHandler: EventType<"form", "onSubmit"> = async (e) => {
    e.preventDefault();

    if (isEmptyString(email)) {
      return handleInputError("EmptyEmailError");
    }
    if (!isEmail(email)) {
      return handleInputError("EmailFormError");
    }

    try {
      const body = { email };
      const isProperEmail: boolean = await apiRequest.post(
        "/v1/auth/verify-email",
        body,
      );
      if (isProperEmail) {
        dispatch?.({ type: "push", page: "login" });
      }
    } catch (e) {
      if (isAxiosError(e) && e.code === "401") {
        return handleInputError("EmailNotFoundError");
      }
      alert(e); /* 알 수 없는 에러 */
    }
  };

  const showRegisterPageHandler = () => {
    dispatch?.({ type: "push", page: "register" });
  };

  return (
    <form className={styles["credentials-login"]} onSubmit={formSubmitHandler}>
      <div className={styles["title"]}>이메일을 입력해주세요</div>
      <div className={styles["subtitle"]}>이메일만 있어도 가입할 수 있어요</div>
      <div className={styles["input"]}>
        <input
          value={email}
          type={"text"}
          placeholder={"이메일을 입력해주세요"}
          onChange={idChangeHandler}
        />
        <p className={styles["error-message"]}>{errorMessage}</p>
      </div>
      <button type="submit">다음</button>
      <div className={styles["register"]}>
        <p onClick={showRegisterPageHandler}>회원가입</p>
      </div>
    </form>
  );
}

위에 코드가 조금 긴데, 실제 프로젝트에서 어떻게 사용했는지 하나하나 잘라서 설명해보면,

const {
  input: email,
  errorMessage,
  changeHandler: idChangeHandler,
  handleInputError,
} = useInput({
  initialValue: "",
  errorHandler: (error) => {
    switch (error) {
      case "EmailNotFoundError":
        return "존재하지 않는 이메일입니다.";
      case "EmailFormError":
        return "이메일 형식으로 입력해주세요.";
      case "EmptyEmailError":
        return "이메일을 입력해주세요.";
      default:
        return "";
    }
  },
});

여기서는 실시간으로 검증할 필요가 없어서 validate 함수를 따로 전달하지 않았다. 그리고 errorHandler 함수를 보면 설명한데로 error(string) 에 따라서 표시할 에러 메세지를 반환한다.

const formSubmitHandler: EventType<"form", "onSubmit"> = async (e) => {
  e.preventDefault();

  if (isEmptyString(email)) {
    return handleInputError("EmptyEmailError");
  }
  if (!isEmail(email)) {
    return handleInputError("EmailFormError");
  }
  // ...
  
  try {
    // ...
  } catch (e) {
    if (isAxiosError(e) && e.code === "401") {
      return handleInputError("EmailNotFoundError");
    }
    alert(e); /* 알 수 없는 에러 */
  }
}

그리고 useInput 훅에서 반환받은 handleInputError 함수를 submit 이벤트에서 호출하도록 사용했다.

여기서는 submit 이벤트 발생 시

  1. 비어있는 문자열이면 "이메일을 입력해주세요."
  2. 이메일 형식이 아니면 "이메일 형식으로 입력해주세요."
  3. 서버에 해당 이메일이 존재하지 않으면 "존재하지 않는 이메일입니다."

를 반환하도록 사용했다.

결과

결과적으로 오류 메세지를 함께 처리할 수 있는 useInput 커스텀 훅을 구현했다.

커스텀 훅을 사용할 때 마다 어느정도의 로직을 추상화 할 지 항상 고민이 된다. 이번에 구현한 커스텀 훅에서도 에러가 발생했을 때 에러 메세지만 바꾸는 게 아니라 콜백 함수를 호출한다거나 하는 방식으로도 구현할 수 있었고, 아예 useErrorMessageInput 처럼 에러 메세지만 처리하는 전용 커스텀 훅으로 구현할 수 있었다.

프로젝트에 딱 필요한 로직만 커스텀 훅으로 뺄 지, 아니면 조금 더 범용적으로 사용할 수 있도록 구현해야 할 지 등 이런저런 생각이 드는데, 항상 정답이 없어서 어려운 것 같다.

앞으로도 계속 고민하고 공유하면서 정답에 가까워지도록 노력하자. 👍

profile
배짱개미 개발자 김승현입니다 🖐

4개의 댓글

comment-user-thumbnail
2023년 1월 31일

OAuth 글도 흥미롭게 읽었었는데, 이번 글도 잘봤습니다. :+1:

1개의 답글
comment-user-thumbnail
2023년 2월 7일

const changeHandler: EventType<"input", "onChange"> = (e) => {
setInput(e.target.value);
setErrorMessage(validate(input, errorHandler));
};

부분은 setInput 으로 e.target.value 를 업데이트 하고 validate 에는 아직 setInput으로 업데이트 되기 전의 input 이 들어가게 되는데 의도하신게 맞는지 궁금해요~

1개의 답글