회사에서 shadcn/ui를 적극적으로 활용해서 앱을 개발하고 있다.
이미 만들어진 컴포넌트를 앱으로 가져와 직접 커스터마이징 할 수 있고,
use-react-form과 같은 편의성 좋은 기능도 함께 딸려오니 작업할 때 굉장히 편리하다.
각설하고
shadcn/ui를 이용해 간단한 signin 컴포넌트를 만든 것을 기록해보려 한다.
사실 form을 다루는 것은 정말 까다롭다.
각 종 유효성 검사, 에러메시지, submit 등 신경쓸 것이 많은 로직이 한번에 모이므로 생각없이 짜다가는 정말 작업의 늪에 매몰되어버릴 수 있다.
그래서 form을 제작할때는 미리 설계를 해놓고 작업을 하는 편이 좋다.
구조적 설계
Form을 만들 때, 복잡한 로직을 잘 이어붙이는 것도 상당히 껄끄럽지만
Form 내부의 컴포넌트를 어떻게 나눠야 하는가도 정말 쉽지 않다.
나의 경우, 일단은 만들어! 방식으로 하나 하나 이어붙이다가 어떻게든 기능을 구현했던 것 같다.
그러다보니 하나를 만들 때 시간이 오래 걸렸고, 다음 Form을 만들때도 Form의 재사용이 어려웠었다.
일단 만들기 전에 구조적인 설계를 하고 만들면 컴포넌트의 구조를 보다 명확히 할 수 있으므로,
컴포넌트의 재사용이 용이해질 것이고,
새로운 구조를 짜게 되더라도 이미 구조를 짜 본 경험이 있으므로 시간이 많이 단축될 것이다.
앞서 만들 Form은 아래와 같은 순서로 구성될 것이다.
Form은 shadcn/ui의 use-react-form을 이용할 것이나,
큰 틀에서 보았을때 React의 기본 기능만으로도 구현할 수 있을 것이다.
- Form의 스키마를 구성한다. (들어갈 필드 갯수, 필드 아이디, 유효성 검증 등)
- Form에서 유지해야 할 데이터를 Form Context로 만든다.
- Form에 삽입될 Field 컴포넌트를 구성한다. (Form Context를 이용해 데이터와 Field를 연결한다.)
- Form 컴포넌트를 조립한다. (Form Context를 이용해 데이터와 Form을 연결한다.)
- Page 컴포넌트에 Form 컴포넌트를 삽입한다. Form 컴포넌트는 Provider를 통해 Context를 제공받아야 한다.
Form 데이터를 굳이 전역 데이터로 관리할 이유는 없기에 Context를 사용했다.
필요에 따라 다르겠지만 Form 화면을 벗어났을 때 사용자가 이전에 입력했었던 데이터를 굳이 가지고 있을 필요가 없기 때문이다.
만약 작성한 글 임시 저장 기능을 넣어두려면 전역 상태 관리가 필요하거나 데이터베이스를 활용해야 할 것이다.
1. Form 스키마 구성
위와 같은 Form을 만들고 싶다고 가정해보자.
하나의 Form에 2개의 Input 필드와 Button이 있어야 할 것이다.
유효성 검증도 필요하다면 있을 수 있다.
일단 2개의 인풋 필드가 있으므로 인풋 필드에 대한 스키마를 구성해보자.
import * as z from 'zod';
const formSchema = z.object({
userId: z.string().min(2).max(50),
password: z.string().min(2).max(50),
});
zod를 사용해서 매우매우 간단하게 구현되었다.
만약 React만으로 구현하는 경우에는 주석으로 어떤 필드를 만들 것이고, 어떤 유효성 검증을 사용할 것인지 명시해두는 단계로 생략할 수 있을 것 같다. 유효성 검증에 대한 미리 정규표현식을 만들어두는 것도 좋을 것이다.
간단하지만 내가 뭘 만들지 정의하고 넘어가는 구간이므로 반드시 필요한 작업이라고 생각된다.
2. Form Context 작업
Form 컴포넌트와 여기에 속하는 모든 Field 컴포넌트들은 하나의 Form 데이터를 바라보고 있어야 한다.
Form 데이터를 중구난방으로 바라보게 된다면 Form 차원에서 Submit을 할 수 없기 때문이다.
따라서 앞서 만든 Form 스키마를 이용해 Form Data를 만들 것이고, 이 Form Data를 이용해 컨텍스트를 만들어
Page와 Form 컴포넌트, Field 컴포넌트에 각각 제공할 것이다.
Form Data가 될 Hook
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
const formSchema = z.object({
userId: z.string().min(2).max(50),
password: z.string().min(2).max(50),
});
const useSignInForm = () => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: 'onChange',
defaultValues: {
userId: '',
password: '',
},
});
const onSubmit = (
data: z.infer<typeof formSchema>,
onSuccess: () => void,
onError: () => void,
) => {
// 로그인 성공시 onSuccess 콜백 호출
// 기타 에러시 onError 콜백 호출
};
return {
form,
onSubmit,
};
};
Form Data Hook을 직접 만들면 onSubmit의 기능을 커스터마이징 할 수 있다. onSubmit의 파라미터로 onSuccess나 onError와 같은 콜백 함수를 받고, onSubmit 함수 내부의 api의 통신 성공 여부에 따라 onSuccess나 onError와 같은 콜백을 실행시켜줄 수 있다.
이 구조가 갖는 장점은 form Data Hook은 오로지 api 통신만 할 뿐, onSuccess나 onError가 되었을때의 동작은 알 필요가 없다는 것이다. 세부 동작은 onSubmit을 호출할 때 넘겨주는 콜백함수가 정할 것이기 때문이다.
즉, Form Data Hook은 Form 데이터 바인딩과 api 통신만 관여할 뿐이다. 성공이나 실패시 동작은 Form 컴포넌트에서 관리한다. 이를 통해 Form Data Hook에 과도한 로직이 몰리는 것을 방지할 수 있다.
Context 작성
// 이어서...
const SignInFormContext = createContext<ReturnType<
typeof useSignInForm
> | null>(null);
export const useSignInFormContext = () => {
const context = useContext(SignInFormContext);
if (!context) {
throw new Error('useSignInFormContext가 생성되지 않았습니다.');
}
return context;
};
export const SignInFormContextProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const { form, onSubmit } = useSignInForm();
return (
<SignInFormContext.Provider
value={{
form,
onSubmit,
}}
>
{children}
</SignInFormContext.Provider>
);
};
Form Data Hook을 Context의 데이터로써 이용한다.
이제 ContextProvider를 page 상위에 덮어주면,
page 하위 컴포넌트들은 Form Context를 사용할 수 있게 된다.
form 스키마 정의 및 context에 대한 전체 코드
// useSignInFormContext.tsx
'use client';
import { createContext, useContext } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
const formSchema = z.object({
userId: z.string().min(2).max(50),
password: z.string().min(2).max(50),
});
const useSignInForm = () => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: 'onChange',
defaultValues: {
userId: '',
password: '',
},
});
const onSubmit = (
data: z.infer<typeof formSchema>,
onSuccess: () => void,
onError: () => void,
) => {
// 로그인 성공시 onSuccess 콜백 호출
// 기타 에러시 onError 콜백 호출
};
return {
form,
onSubmit,
};
};
const SignInFormContext = createContext<ReturnType<
typeof useSignInForm
> | null>(null);
export const useSignInFormContext = () => {
const context = useContext(SignInFormContext);
if (!context) {
throw new Error('useSignInFormContext가 생성되지 않았습니다.');
}
return context;
};
export const SignInFormContextProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const { form, onSubmit } = useSignInForm();
return (
<SignInFormContext.Provider
value={{
form,
onSubmit,
}}
>
{children}
</SignInFormContext.Provider>
);
};
3. Form에 삽입될 컴포넌트 구성
앞에서 정의했듯 이번 Form에서 사용할 Field는 userId와 password로 단 두개이다.
그렇다면 두개의 필드를 생성하면 된다.
여기는 shadcn/ui의 Input 생성 방법을 그대로 따랐다.
// UserIdInputField.tsx
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useSignInFormContext } from '../hooks/useSignInFormContext';
export default function UserIdInputField() {
const { form } = useSignInFormContext();
return (
<FormField
control={form.control}
name='userId'
render={({ field }) => (
<FormItem>
<FormLabel>UserId</FormLabel>
<FormControl>
<Input placeholder='아이디 입력' {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
}
useSignInFormContext를 이용해 form Data를 활용중이라는 것을 기억하자.
위와 동일하게 password Field를 만들어준다.
// PasswordInputField.tsx
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useSignInFormContext } from '../hooks/useSignInFormContext';
export default function PasswordInputField() {
const { form } = useSignInFormContext();
return (
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder='패스워드 입력' {...field} />
</FormControl>
<FormDescription>This is your private password.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
}
이제 대부분의 작업이 끝났다.
4. Form 컴포넌트 만들기
이제 만들어놓은 컴포넌트들을 조립할 Form 컴포넌트를 생성해야 한다.
Form 컴포넌트는 form 내부에 들어가야할 컴포넌트들을 감싸는 역할과 동시에
onSubmit을 수행했을 때, 성공시 동작 로직과 실패시 동작 로직을 정해주는 역할을 수행할 것이다.
(예를들어 성공시 다른 화면으로 이동시키거나 실패시 토스트 메세지를 띄워주는 기능)
'use client';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import PasswordInputField from './fields/PasswordInputField';
import UserIdInputField from './fields/UserIdInputField';
import { useSignInFormContext } from './hooks/useSignInFormContext';
export default function SignInForm() {
const { form, onSubmit } = useSignInFormContext();
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(data => {
console.log(data);
onSubmit(
data,
() => console.log('성공시 콜백'),
() => console.log('실패시 콜백'),
);
})}
className='space-y-8'
>
<UserIdInputField />
<PasswordInputField />
<Button
type='submit'
>
Submit
</Button>
</form>
</Form>
);
}
Form 컴포넌트도 Form 컴포넌트 내부 자식들과 동일한 Form Data를 바라보아야 하므로 미리 만들어 둔 컨텍스트를 활용해준다.
이제 남은 것은 page.tsx에서 조립하는 일 뿐이다.
5. Page에 ContextProvider와 Form 컴포넌트 추가
import { SignInFormContextProvider } from '@/components/forms/auth/sign-in/hooks/useSignInFormContext';
import SignInForm from '@/components/forms/auth/sign-in/SignInForm';
export default function Page() {
return (
<SignInFormContextProvider>
<main className='flex min-h-[100vh] items-center justify-center bg-slate-300'>
<section className='w-[500px]'>
<SignInForm />
</section>
</main>
</SignInFormContextProvider>
);
}
모든 절차가 성공적으로 이뤄졌다면 정상적으로 동작할 것이다.
생각해볼 점
리액트로만 작업할 때 어떻게 해야할까?
useReducer과 Context를 적극적으로 활용할 수 있을 것 같다.
reducer만으로 입력과 전송은 쉽게 구현이 가능할 것 같다. 하지만 유효성 검사나 formDirty, 제출중인지 여부 등을 구현하려면 약간 시간이 필요할 것 같다.
생산성을 위해 사용할 수 있는 라이브러리는 사용하되, 라이브러리 없이 구현하려면 어떻게 해야하는지 사고하는 것 만으로도 충분한 도움이 되는 것 같은 느낌을 받는다.
'개발 일지' 카테고리의 다른 글
젠장할 소프트 네비게이션 (0) | 2023.11.07 |
---|---|
nextjs에서 react-quill 사용하기 (0) | 2023.11.06 |
(express, front) 회원가입 로직 개발 (이메일 인증, 중복 체크 등) (0) | 2023.08.08 |
Context와 Redux의 리렌더링에 관한 고찰 (0) | 2023.08.04 |
[클린코드] Header 코드 정리 (0) | 2023.07.14 |