Custom을 진행하기에 앞서서 ShadCn
에서 용례를 제공하는데, 그 용례에 따라서 내가 활용해서 코드를 작성해 봤다.
로그인 UI만 해도 딱 70줄이고. 회원가입 부분까지 생각하면 70줄이상이다.
그리고 코드를 보면
import
부분 또한 매우 지저분 해보이고, UI
부분 또한 내가 보기에 지저분해보이고 Clean 하다라는 느낌을 받지 못한다. import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from './ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from './ui/form';
import { Input } from './ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
const formSchema = z.object({
email: z.string().email().min(2, {
message: '이메일 쓰세요~~',
}),
password: z.string().min(6, '비밀번호는 최소 6자리 이상이어야 합니다.'),
});
type T_FormSchema = z.infer<typeof formSchema>;
const Sample = () => {
const form = useForm<T_FormSchema>({
resolver: zodResolver(formSchema),
});
const onSubmit = (values: T_FormSchema) => signIn();
return (
<Card>
<CardHeader>
<CardTitle>로그인 폼입니다.</CardTitle>
<CardDescription>로그인 하려면 이메일과 비밀번호를 입력해주세요</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>email</FormLabel>
<FormControl>
<Input placeholder="이메일 입력" {...field} />
</FormControl>
<FormDescription>input 상세설명</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>password</FormLabel>
<FormControl>
<Input placeholder="비번 입력" {...field} />
</FormControl>
<FormDescription>input 상세설명</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
</CardContent>
</Card>
);
};
export default Sample;
커스텀을 하기 위해서 Point는 어떻게 쪼갤 것인지다.
우선 Form
UI Component가 Card
UI Component 내에 있으므로 전반적으로 Layout을 감싸는
LayoutForm
이라는 이름의 Custom Component가 필요 할것 같다.
import { Card } from '@/components/ui/card';
import { Form } from '@/components/ui/form';
import { ComponentPropsWithoutRef, ReactNode } from 'react';
import { FieldValues, UseFormReturn } from 'react-hook-form';
interface I_LayoutFormProps<T extends FieldValues> extends ComponentPropsWithoutRef<'div'> {
form: UseFormReturn<T>;
children: ReactNode;
}
/**
*
* @explain FormProvider를 감싼 LayoutForm Component 입니다.
* @returns
*/
const LayoutForm = <T extends FieldValues>({ form, children, ...props }: I_LayoutFormProps<T>) => {
return (
<Card {...props}>
<Form {...form}>{children}</Form>
</Card>
);
};
export default LayoutForm;
react-hook-form
의 Form이 들어가 있는 LayoutForm이 완성된다.
T extends FieldValues
는 useForm을 호출하면서 확인해보자
const form = useForm<T_FormSchema>({
resolver: zodResolver(formSchema),
});
// type
const form: UseFormReturn<{
email: string;
password: string;
}, any, undefined>
위의 코드를 보면 객체 형태가 들어있는데 이 객체, 즉 schema 정의한 타입이 react-hook-form
에서 어떤 타입인지 찾아보면
FieldValues
타입이다.
이렇게 react-hook-form
에서 지원하는 type
과 Generic을 사용해서 만들어 봤다.
물론 이거 하나 만드는데 이틀 걸렸지만, 전반적으로 뿌듯했다.
import { ComponentPropsWithRef } from 'react';
import { Control, FieldPath, FieldValues, RegisterOptions } from 'react-hook-form';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '../../../ui/form';
import { Input } from '../../../ui/input';
interface I_ControlProps<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
> extends ComponentPropsWithRef<'input'> {
control: Control<TFieldValues>; // 반드시 내려줘야 해욤
name: TName; // 반드시 내려줘야 해욤
//...생략
}
/**
* @param props : Input의 className으로 CSS 바꾸고 싶으면 className='tailwind'로 내려주면 알아서 css 됩니다.
*/
const CustomInput = <T extends FieldValues, N extends FieldPath<T>>({
control,
name,
label,
// ...생략
inputType = 'text',
...props
}: I_ControlProps<T>) => {
// ...생략
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
// ... 생략
<FormControl>
<Input {...field} {...props} type={inputType} />
</FormControl>
<FormMessage className={messageCn} />
</FormItem>
)}
/>
);
};
export default CustomInput;
실제 프로젝트에서 내가 만든 Custom Input
인데,
Form Field에는 반드시 내려줘야 할것이 control
, name
이다.
따라서 control, name의 타입을 잘 커스텀 해줘야 하는데
react-hook-form
의 control 의 타입은 FieldValues
이고,
Name의 타입은 FieldPath<FieldValues>
으로 정의 되어 있기에 위와 같이 타입을 정의 해 주었다.
직접적으로 <form>...</form>
이 들어가는 곳이 ShadCn
의 CardContent
UI Component안이기에,
LayoutFormBody 라는 이름으로 커스텀 해줄 필요가 있어보여서 Custom을 하였다.
import { CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { ComponentPropsWithoutRef } from 'react';
interface I_FormBodyProps extends ComponentPropsWithoutRef<'div'> {
children: JSX.Element;
}
/**
* @explain LayoutFormBody에는 form, react-hook-form의 Field와 버튼이 children으로 들어갑니다.
*/
const LayoutFormBody = ({ className, children }: I_FormBodyProps) => {
return <CardContent className={cn(className)}>{children}</CardContent>;
};
export default LayoutFormBody;
ShadCn
의 form UI(React-hook-form)는 contextAPI를 사용하여 FormProvider로 하위에 값을 공유하는 형태이다.
여기서 contextAPI와 잘어울리는 pattern
이 Compound Pattern인 것을 공부 하였기에
내가 만든 CustomInput
과 Button
을 Compound Pattern
식으로 바꿔주었다.
import CustomInput from '@/components/common/form/input-text/CustomInput';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ComponentPropsWithoutRef } from 'react';
interface I_FormProps extends ComponentPropsWithoutRef<'form'> {
children: React.ReactNode;
}
/**
*
* @explain AuthForm에는 react-hook-form에 필요한 것들을 Component Pattern으로 주입해서 LayoutFormBody안에 넣으면 됩니다.
* @returns
*/
const AuthForm = (props: I_FormProps) => {
const { className, children } = props;
return (
<form {...props} className={cn(className)}>
{children}
</form>
);
};
AuthForm.input = CustomInput;
AuthForm.button = Button;
export default AuthForm;
zod를 사용하여 useForm을 관리하고 사용하는데 UI Component에 적는 것은 별로 보기에 가독성이 좋아보이지 않아보였다.
그래서 useAuth
라는 훅으로 관리 하기로 생각하였다.
우선 타입과 같은 경우
// types/form/form.d.ts
import { DefaultValues, FieldValues } from 'react-hook-form';
import { z } from 'zod';
/**
* 등록 폼에 사용될 커스텀 훅 타입입니다.
*/
export interface I_CustomUseHookFormProps<T extends FieldValues> {
schema: z.ZodType<T>; // 진짜...
defaultValues: DefaultValues<T>;
}
위와 같이 정의하였다. ( 진짜 저것만 3일 걸렸다... )
이렇게 정의 하고
useAuth라는 비즈니스 커스텀 훅을 생성하여 타입과 필요 한 것들을 정의 해주었다.
import { T_SignInSchema } from '@/components/auth/sign-in/validator/sign-in-validator';
import { T_SignUpSchema } from '@/components/auth/sign-up/validator/sign-up-validator';
import useAuthQuery from '@/hooks/server/auth/uesAuthQuery';
import { I_CustomUseHookFormProps } from '@/types/form/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FieldValues, useForm } from 'react-hook-form';
const useAuth = <T extends FieldValues>({ schema, defaultValues }: I_CustomUseHookFormProps<T>) => {
const form = useForm<T>({
defaultValues,
resolver: zodResolver(schema),
});
//... 생략
const { signIn, signUp } = useAuthQuery<T>({ form, callbackAuthFn });
const submitLoginHandler = (values: T_SignInSchema) => signIn(values);
const submitSignUpHandler = (values: T_SignUpSchema) => signUp(values);
return { form, submitLoginHandler, submitSignUpHandler };
};
export default useAuth;
이렇게 해놓으면 로그인 폼과, 회원가입 폼에서 사용할 수 있는 공통 폼이 된다.
useAuth<T_Schema>
를 사용하여 호출하면
내가 정의해놓은 스키마에 따라 form에 등록된 field
, name
, control
등등이 타입 추론이 된다.
진짜 너무 좋다...
각각을 커스텀 해주고 validator
폴더로 스키마를 빼주고 이것저것 잘 분리 해놓으면
import useAuth from '@/hooks/client/auth/useAuth';
import LayoutForm from '../../common/form/form-layout/LayoutForm';
import LayoutFormBody from '../../common/form/form-layout/layout-form-body/LayoutFormBody';
import AuthForm from '../auth-form/AuthForm';
import AccountManagement from './account-management/AccountManagement';
import { SIGN_IN_INPUTS, SignInSchema, T_SignInSchema } from './validator/sign-in-validator';
const DEFAULT_VALUES = {
email: '',
password: '',
};
const SignIn = () => {
// useAuth가 제일 마음에 든다.... 정말루 진짜루....
const { form, submitLoginHandler } = useAuth<T_SignInSchema>({
schema: SignInSchema,
defaultValues: DEFAULT_VALUES,
});
return (
<LayoutForm form={form} className="w-[33.2rem] bg-transparent border-0 shadow-none">
<LayoutFormBody>
<AuthForm onSubmit={form.handleSubmit(submitLoginHandler)} className="flex flex-col gap-10">
{SIGN_IN_INPUTS.map(input => (
<AuthForm.input control={form.control} {...input} />
))}
<AuthForm.button type="submit" variant={'auth'}>
로그인
</AuthForm.button>
<AccountManagement />
</AuthForm>
</LayoutFormBody>
</LayoutForm>
);
};
export default SignIn;
48줄 밖에 안되는
이렇게 깔끔한... 로그인 Page Component가 완성 된다.
react-hook-form, zod의 각 타입을 활용해서 커스텀을 만들어 보았습니다. 커스텀을 할 때 1주일 정도가 걸린것 같은데, 자주 사용하고 추천하는 라이브러리의 타입을 가지고 내가 "원하는" 결과로 만드는 것이 요번 Custom 하는데 가장큰 의의와 결과물입니다. typescript와 라이브러리의 타입과 친해지면서 코드의 깔끔해 지는 것이 가장 큰 만족감이 듭니다.