최근 진행중인 프로젝트에서 위와 같이 화면을 구성하려고 설계했다. 보이는 것 처럼 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
를 호출하여 반환하는 함수다.그 이외에도,
isError
resetValue
등 함수를 추가했다.여기서 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: