

최근 진행중인 프로젝트에서 위와 같이 화면을 구성하려고 설계했다. 보이는 것 처럼 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)} />
)
}
이렇게 반복되는 로직을 깔끔하게 처리하는 방법이 바로 커스텀 훅이다.
이렇게 두가지 로직을 재사용 할 수 있도록 커스텀 훅으로 구현했다.
커스텀 훅
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 훅을 호출할 때 validate와 errorHandler 값으로 함수를 받아오도록 구현했다.
errorHandler 함수는 error(string)에 따라 에러 메세지(string)를 반환하는 함수다.errorHandler 함수에서 반환한 값(string)으로 setErrorMessage 함수를 호출해서 에러 메세지를 업데이트한다.validate 함수는 input(string)에 따라 에러 상태를 판단하고, 에러 상태에 따라 errorHandler를 호출하여 반환하는 함수다.그 이외에도,
isErrorresetValue 등 함수를 추가했다.여기서 handleInputError 함수에 조금 신경써서 구현했는데, 이 함수는 입력 에러를 발생시키는 함수로, 실제 구현을 보면 단순히 errorHandler를 호출해서 에러 메세지를 업데이트 한다.
validate 함수와 분리한 이유는 validate 는 onChange 이벤트가 발생할 때 마다 실행되도록 구현했고, 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 이벤트 발생 시
"이메일을 입력해주세요.""이메일 형식으로 입력해주세요.""존재하지 않는 이메일입니다."를 반환하도록 사용했다.

결과적으로 오류 메세지를 함께 처리할 수 있는 useInput 커스텀 훅을 구현했다.
커스텀 훅을 사용할 때 마다 어느정도의 로직을 추상화 할 지 항상 고민이 된다. 이번에 구현한 커스텀 훅에서도 에러가 발생했을 때 에러 메세지만 바꾸는 게 아니라 콜백 함수를 호출한다거나 하는 방식으로도 구현할 수 있었고, 아예 useErrorMessageInput 처럼 에러 메세지만 처리하는 전용 커스텀 훅으로 구현할 수 있었다.
프로젝트에 딱 필요한 로직만 커스텀 훅으로 뺄 지, 아니면 조금 더 범용적으로 사용할 수 있도록 구현해야 할 지 등 이런저런 생각이 드는데, 항상 정답이 없어서 어려운 것 같다.
앞으로도 계속 고민하고 공유하면서 정답에 가까워지도록 노력하자. 👍
OAuth 글도 흥미롭게 읽었었는데, 이번 글도 잘봤습니다. :+1: