[ 공모전 ] Form Custom : Shad/Cn (70줄이하) 2/2

최문길·2024년 6월 20일
0

공모전

목록 보기
9/46

Custom을 진행하기에 앞서서 ShadCn에서 용례를 제공하는데, 그 용례에 따라서 내가 활용해서 코드를 작성해 봤다.

Origin ShadCn Form

로그인 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;


Custom을 하기에 앞서서

커스텀을 하기 위해서 Point는 어떻게 쪼갤 것인지다.

우선 Form UI Component가 Card UI Component 내에 있으므로 전반적으로 Layout을 감싸는
LayoutForm이라는 이름의 Custom Component가 필요 할것 같다.

1. LayoutForm (Custom)

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이 완성된다.

FieldValues

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을 사용해서 만들어 봤다.
물론 이거 하나 만드는데 이틀 걸렸지만, 전반적으로 뿌듯했다.

2. CustomInput

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-formcontrol 의 타입은 FieldValues 이고,
Name의 타입은 FieldPath<FieldValues> 으로 정의 되어 있기에 위와 같이 타입을 정의 해 주었다.

3. LayoutFormBody (CardContent Custom)

직접적으로 <form>...</form> 이 들어가는 곳이 ShadCnCardContent 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;

4. Compound Pattern

ShadCn 의 form UI(React-hook-form)는 contextAPI를 사용하여 FormProvider로 하위에 값을 공유하는 형태이다.
여기서 contextAPI와 잘어울리는 pattern이 Compound Pattern인 것을 공부 하였기에

내가 만든 CustomInputButtonCompound 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;

5. 비즈니스 로직을 커스텀 해보자

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 등등이 타입 추론이 된다.

진짜 너무 좋다...

Final

각각을 커스텀 해주고 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와 라이브러리의 타입과 친해지면서 코드의 깔끔해 지는 것이 가장 큰 만족감이 듭니다.


참고
stackoverflow

0개의 댓글

관련 채용 정보