-> 지난번에 OOP, FP를 비교해보면서 셀프 과제로 각각의 코딩 방식으로 유효성 검사가 섞은 회원가입 폼을 만들어보기로 했었는데 아직 완성은 아니지만, 함수형 프로그래밍 + react hook 방식을 섞어서 1차 완성을 해봤다(아직 태그 부분은 미적용)
아직은 이게 왜 좋은지, 그리고 사실 hook에 옮겨놓은건 아직 재사용을 할 수 있을정도인지는 모르겠다. 일단 중간 기록을 남겨두고, 나중에 완성을 한 뒤에 비교하기 위해 기록해본다.
: 여기는 다른 프로젝트에서도 가져다 쓸 수 있게 만들어 놓는 곳이다. 확실히 FP로 해놓으면 여러가지 단일 기능의 함수들이 그냥 모여있는 느낌이라.. 이게 사이즈가 커지면 어떻게 관리하는지는 아직은 모르겠다..
import { curry, Either, pipe } from "../functional";
import {
REGEX_WITHOUT_CHARACTERS,
NICKNAME_CHARACTER_ERROR_MESSAGE,
NICKNAME_LENGTH_ERROR_MESSAGE,
NICKNAME_WHITESPACE_ERROR_MESSAGE,
REGEX_CHECK_BLANK,
REGEX_EMAIL,
EMAIL_FORMAT_ERROR_MESSAGE,
REGEX_ALPHABET_UPPER_CASE,
PASSWORD_FORMAT_ERROR_MESSAGE,
REGEX_ALPHABET_LOWER_CASE,
REGEX_SPECIFIC_CHARACTERS,
PASSWORD_LENGTH_ERROR_MESSAGE,
TAG_CHARACTER_ERROR_MESSAGE,
REGEX_TAG,
INTRODUCTION_LENGTH_ERROR_MESSAGE,
} from "../constants/validation";
// 자기소개 검사: 공백 제외 15자 이상
export const isValidIntroduction = (intro: string): boolean => {
const trimmedLength = intro.replace(/\s/g, "").length;
return trimmedLength >= 15;
};
export const replaceAllBlack = (value: string) => value.replace(/\s/g, "");
export const checkStringLength = curry(
(from: number, to: number, message: string, value: string) =>
value.trim().length >= from && value.length <= to
? Either.right(value)
: Either.left(message)
);
export const checkSpeicalCharacter = curry(
(regex: RegExp, message: string, value: string) =>
regex.test(value) ? Either.right(value) : Either.left(message)
);
export const checkNicknameLength = checkStringLength(
3,
15,
NICKNAME_LENGTH_ERROR_MESSAGE
);
export const checkNicknameCharacter = checkSpeicalCharacter(
REGEX_WITHOUT_CHARACTERS,
NICKNAME_CHARACTER_ERROR_MESSAGE
);
export const checkNicknameWhiteSpace = checkSpeicalCharacter(
REGEX_CHECK_BLANK,
NICKNAME_WHITESPACE_ERROR_MESSAGE
);
export const checkValidEmail = checkSpeicalCharacter(
REGEX_EMAIL,
EMAIL_FORMAT_ERROR_MESSAGE
);
export const checkPasswordHasUpperCase = checkSpeicalCharacter(
REGEX_ALPHABET_UPPER_CASE,
PASSWORD_FORMAT_ERROR_MESSAGE
);
export const checkPasswordHasLowerCase = checkSpeicalCharacter(
REGEX_ALPHABET_LOWER_CASE,
PASSWORD_FORMAT_ERROR_MESSAGE
);
export const checkPasswordHasSpecificCharacters = checkSpeicalCharacter(
REGEX_SPECIFIC_CHARACTERS,
PASSWORD_FORMAT_ERROR_MESSAGE
);
export const checkPasswordLength = checkStringLength(
10,
20,
PASSWORD_LENGTH_ERROR_MESSAGE
);
export const checkTagCharacter = checkSpeicalCharacter(
REGEX_TAG,
TAG_CHARACTER_ERROR_MESSAGE
);
export const checkIntroductionLength = pipe(
replaceAllBlack,
checkStringLength(
15,
Number.MAX_SAFE_INTEGER,
INTRODUCTION_LENGTH_ERROR_MESSAGE
)
);
import {
alt,
curry,
Either,
Left,
pipe,
Right,
tap,
isValid as checkIsValid,
checkValidEmail,
checkNicknameLength,
checkNicknameCharacter,
checkNicknameWhiteSpace,
checkPasswordLength,
checkPasswordHasUpperCase,
checkPasswordHasLowerCase,
checkPasswordHasSpecificCharacters,
checkIntroductionLength,
} from "@myorg/utils";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FormErrors } from "src/types/validator";
export const useValidation = () => {
const [errors, setErrors] = useState<FormErrors>({
email: undefined,
nickname: undefined,
password: undefined,
introduction: undefined,
});
const createFieldValidator = useCallback(
(
field: keyof FormErrors,
setErrors: React.Dispatch<React.SetStateAction<FormErrors>>
) => {
const setFieldError = curry((type: string, message: string) => {
setErrors((prev) => ({ ...prev, [type]: message }));
});
const setFieldErrorMessage = setFieldError(field);
const resetFieldErrorMessage = () => setFieldErrorMessage("");
const checkAndSetErrorMessage = curry(
(validators: Array<(value: string) => Either>, value: string) =>
(
validators.reduce(
(acc, validator) => acc.chain(validator),
Either.right(value) as Right
) as Right | Left
).orElse(setFieldErrorMessage)
);
const createCheckField = (validators: Array<(value: string) => Either>) =>
pipe(
tap(resetFieldErrorMessage),
alt(
checkIsValid,
checkAndSetErrorMessage(validators),
resetFieldErrorMessage
)
);
return { setFieldErrorMessage, resetFieldErrorMessage, createCheckField };
},
[]
);
const emailValidator = useMemo(
() => createFieldValidator("email", setErrors),
[createFieldValidator]
);
const nicknameValidator = useMemo(
() => createFieldValidator("nickname", setErrors),
[createFieldValidator]
);
const passwordValidator = useMemo(
() => createFieldValidator("password", setErrors),
[createFieldValidator]
);
const introductionValidator = useMemo(
() => createFieldValidator("introduction", setErrors),
[createFieldValidator]
);
const checkEmail = useMemo(
() => emailValidator.createCheckField([checkValidEmail]),
[emailValidator]
);
const checkNickname = useMemo(
() =>
nicknameValidator.createCheckField([
checkNicknameLength,
checkNicknameCharacter,
checkNicknameWhiteSpace,
]),
[nicknameValidator]
);
const checkPassword = useMemo(
() =>
passwordValidator.createCheckField([
checkPasswordLength,
checkPasswordHasUpperCase,
checkPasswordHasLowerCase,
checkPasswordHasSpecificCharacters,
]),
[passwordValidator]
);
const checkIntroduction = useMemo(
() => introductionValidator.createCheckField([checkIntroductionLength]),
[introductionValidator]
);
const isFormValid = Object.values(errors).every(
(message: string) => message === ""
);
useEffect(() => {
console.log(errors);
}, [errors]);
return {
checkPassword,
checkNickname,
checkEmail,
checkIntroduction,
errors,
isFormValid,
setErrors,
};
};
import { useState } from "react";
import styled from "@emotion/styled";
import { useSkipFirstRender } from "@myorg/ui";
import { useValidation } from "@hooks/useValidation";
interface FormData {
email: string;
nickname: string;
password: string;
tags: string[];
introduction: string;
}
const Container = styled.div`
width: 450px;
margin: 2rem auto;
padding: 2rem;
box-sizing: border-box;
background: #1a1a1a;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
color: #fff;
`;
const Title = styled.h2`
font-size: 1.5rem;
font-weight: bold;
text-align: center;
margin-bottom: 2rem;
color: #fff;
`;
const FormGroup = styled.div`
margin-bottom: 1.5rem;
box-sizing: border-box;
`;
const Label = styled.label`
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #fff;
`;
const Input = styled.input<{ hasError?: boolean }>`
width: 100%;
height: 40px;
box-sizing: border-box;
background: #2a2a2a;
border: 1px solid ${(props) => (props.hasError ? "#ff4444" : "#3a3a3a")};
border-radius: 4px;
font-size: 1rem;
color: #fff;
&::placeholder {
color: #666;
}
&:focus {
outline: none;
border-color: ${(props) => (props.hasError ? "#ff4444" : "#4a4a4a")};
}
`;
const TextArea = styled.textarea<{ hasError?: boolean }>`
box-sizing: border-box;
width: 100%;
background: #2a2a2a;
border: 1px solid ${(props) => (props.hasError ? "#ff4444" : "#3a3a3a")};
border-radius: 4px;
font-size: 1rem;
color: #fff;
min-height: 120px;
resize: vertical;
&::placeholder {
color: #666;
}
&:focus {
outline: none;
border-color: ${(props) => (props.hasError ? "#ff4444" : "#4a4a4a")};
}
`;
const ErrorMessage = styled.p`
color: #ff4444;
font-size: 0.875rem;
margin-top: 0.5rem;
`;
const TagInputContainer = styled.div`
display: flex;
gap: 0.5rem;
`;
const TagButton = styled.button`
padding: 0.5rem 1rem;
box-sizing: border-box;
width: 100px;
height: 40px;
background: #333;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
&:hover {
background: #444;
}
`;
const TagList = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
`;
const TagItem = styled.div`
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #fff;
`;
const TagRemoveButton = styled.button`
background: none;
border: none;
color: #ff4444;
cursor: pointer;
padding: 0 0.25rem;
font-size: 1.2rem;
line-height: 1;
&:hover {
color: #ff0000;
}
`;
const SubmitButton = styled.button<{ disabled: boolean }>`
width: 100%;
padding: 0.75rem;
background: ${(props) => (props.disabled ? "#222" : "#333")};
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
&:hover {
background: ${(props) => (props.disabled ? "#222" : "#444")};
}
`;
export default function SignUp() {
const [formData, setFormData] = useState<FormData>({
email: "",
nickname: "",
password: "",
tags: [],
introduction: "",
});
const [tagInput, setTagInput] = useState("");
const {
checkPassword,
checkNickname,
checkEmail,
errors,
isFormValid,
setErrors,
checkIntroduction,
} = useValidation();
useSkipFirstRender(() => {
checkEmail(formData.email);
}, [formData.email, checkEmail]);
useSkipFirstRender(() => {
checkNickname(formData.nickname);
}, [formData.nickname, checkNickname]);
useSkipFirstRender(() => {
checkPassword(formData.password);
}, [formData.password, checkPassword]);
useSkipFirstRender(() => {
checkIntroduction(formData.introduction);
}, [formData.introduction, checkIntroduction]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleTagSubmit = (e: React.FormEvent) => {
e.preventDefault();
// if (!checkTagCharacter(tagInput)) {
// setErrors((prev) => ({
// ...prev,
// tag: "태그는 3-10자의 영문, 숫자, 한글만 사용 가능합니다",
// }));
// return;
// }
if (formData.tags.includes(tagInput)) {
setErrors((prev) => ({
...prev,
tag: "이미 존재하는 태그입니다",
}));
return;
}
setFormData((prev) => ({
...prev,
tags: [...prev.tags, tagInput],
}));
setTagInput("");
setErrors((prev) => ({ ...prev, tag: undefined }));
};
const removeTag = (tagToRemove: string) => {
setFormData((prev) => ({
...prev,
tags: prev.tags.filter((tag) => tag !== tagToRemove),
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isFormValid) {
console.log("Form submitted:", formData);
}
};
return (
<Container>
<Title>회원가입</Title>
<form onSubmit={handleSubmit} noValidate>
<FormGroup>
<Label>이메일</Label>
<Input
type="email"
name="email"
data-testid="email-input"
hasError={!!errors.email}
value={formData.email}
onChange={handleChange}
/>
{errors.email && (
<ErrorMessage data-testid="email-error">
{errors.email}
</ErrorMessage>
)}
</FormGroup>
<FormGroup>
<Label>닉네임</Label>
<Input
type="text"
name="nickname"
data-testid="nickname-input"
hasError={!!errors.nickname}
value={formData.nickname}
onChange={handleChange}
/>
{errors.nickname && (
<ErrorMessage data-testid="nickname-error">
{errors.nickname}
</ErrorMessage>
)}
</FormGroup>
<FormGroup>
<Label>비밀번호</Label>
<Input
type="password"
name="password"
data-testid="password-input"
hasError={!!errors.password}
value={formData.password}
onChange={handleChange}
/>
{errors.password && (
<ErrorMessage data-testid="password-error">
{errors.password}
</ErrorMessage>
)}
</FormGroup>
<FormGroup>
<Label>태그</Label>
<TagInputContainer>
<Input
type="text"
value={tagInput}
data-testid="tag-input"
hasError={!!errors.tag}
onChange={(e) => setTagInput(e.target.value)}
placeholder="태그 입력"
/>
<TagButton
type="button"
data-testid="tag-submit"
onClick={handleTagSubmit}
>
추가
</TagButton>
</TagInputContainer>
{errors.tag && (
<ErrorMessage data-testid="tag-error">{errors.tag}</ErrorMessage>
)}
<TagList>
{formData.tags.map((tag, index) => (
<TagItem key={index} data-testid={`tag-item-${index}`}>
<span>{tag}</span>
<TagRemoveButton
data-testid={`tag-remove-${index}`}
type="button"
onClick={() => removeTag(tag)}
>
×
</TagRemoveButton>
</TagItem>
))}
</TagList>
</FormGroup>
<FormGroup>
<Label>자기소개</Label>
<TextArea
name="introduction"
data-testid="introduction-input"
hasError={!!errors.introduction}
value={formData.introduction}
onChange={handleChange}
/>
{errors.introduction && (
<ErrorMessage data-testid="introduction-error">
{errors.introduction}
</ErrorMessage>
)}
</FormGroup>
<SubmitButton
type="submit"
data-testid="submit-button"
disabled={!isFormValid}
>
가입하기
</SubmitButton>
</form>
</Container>
);
}
컴포넌트는 일단 분리하지 않고 하나에 만들었다. 일단 오늘은 여기까지 진행해보고, FP로 하는게 왜 좋은지, 개선점은 없는지 오히려 가독성이 안좋지는 않나 등등을 알아보고 다시 포스팅해보고자 한다. 재사용성 좋게 리팩토링도 해보자.