저번 시간에 Controller
에서 render
함수를 짤막하게 문장으로 작성해 봤었다.
비제어형 React-Hook-Form에서 어떻게 제어형 컴포넌트를 연결시키고 상호작용 할 수 있는지,
render함수 안에 컴포넌트(제어형)를 등록하고 Controller의 props를 받아 적용시키는 것이다. 따라서 내가 판단 해보았을 때 render props pattern
인 것 같다.
그렇다면 render props를 한번 만들어볼까??
각 블로그에서는 예시코드가 거의다 똑같았고, 내가 구현하고 싶은 것과 거리가 조금 있기에, 이번 코드는 FileController를 만드는데 초점을 맞춘 render props pattern이다.
interface I_RenderProps {
render: ({ state }: { state: number }) => JSX.Element;
}
function RenderProps({ render }: I_RenderProps) {
const [state, setState] = useState(1);
return render({ state });
}
함수 선언문으로 만들어준것은 호이스팅 하기 위함이다.(아래 마지막 코드를 보면 안다.)
render함수안에서 rendering 되는 컴포넌트가 의도대로 작동하는지 알기 위해서 state인자를 넣어줘봤다.
function Compo({ state }: { state: number }) {
return <div>state 값은 {state}</div>;
}
interface I_RenderProps {
render: ({ state }: { state: number }) => JSX.Element;
}
const SamplePage = () => {
return (
<div>
<h2>render props pattern</h2>
<RenderProps render={({ state }) => <Compo state={state} />} />
</div>
);
};
function RenderProps({ render }: I_RenderProps) {
const [state, setState] = useState(1); // 화면에 보여지냐?
return render({ state });
}
function Compo({ state }: { state: number }) {
return <div>state 값은 {state}</div>;
}
export default SamplePage;
값을 Compo
가 잘 받아서 보여준다.
혹시 이 글을 읽는 미래의 나 또는 다른 분들을 위해 render props pattern 관련 아직 참고 하지 못한 블로그를 나열하겠움
patterns-dev
아이디어와 방향성은 저번 글에서 이야기 한것과 같이
이 3가지 이다.
1번째를 토대로 render props
로 input컴포넌트와 previewImage 컴포넌트를 받아서 rendering 할것인데,
render함수의 인자값으로 Controller
컴포넌트와 똑같이 fieldState
,formState
, fieldState
를 명시해서 내려주고 이미지를 컨트롤 할 수 있는 메소드 또한 custom,또는 만들어서 인자값을 명시 해서 컴포넌트들이 사용 할 수 있게 해주도록 할 계획이다.
일딴 벤치 마킹할 Controller
컴포넌트를 다시 봐보자
//....생략
const form = useForm(...)
<Controller
name='sample'
control={form.control}
render={( {field, ... } ) => <ControlledComponent {...field} /> }
/>
name
과 control
이 들어가고 render props안의 인자값에는 field와, 여러 메소드들이 들어있게 하자
그렇다!!
useController
이다.useController에 name과 control을 props 전달해주면 똑같이 field,formstate등등을 반환 해준다.
useController
를 FileController에서 사용하기 위해서 타입 지정을 해줘야 한다.
interface I_Props <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends UseControllerProps<TFieldValues, TName>{
control:Control<TFieldValues>
name:TName
}
타입을 만들고 FileController를 정의해보자
//...생략
const FileController = <...>({control,name}:I_Props) => {
const {field,fieldState,formState} = useController({control,name})
}
Controller와 마찬가지로 field
, fieldState
, formState
를 반환한다.
register는 내가 다른 개발자와 협업을 염두해서 field를 custom하면 좋을 듯해서 render안에 들어가야 할 객체이다.
regitser안에는
{
type: 'file';
ref: RefCallBack;
name: TName;
register: UseFormRegisterReturn<TName>;
onChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
}
이러한 property가 있는데
file
로 하기 위한 property이다. react-hook-form
에서 예시로 언제나 나오는 단골이다. 등록할때 사용하는데, 나또한 등록할 때 사용하기 위해 넣었다. base64
base64는 내가 image url에 넣기 위해 반드시 필요한 dataURL이다.
remove함수
FileController Component안에서 만들 함수인데, 미리본 이미지를 취소 하고 싶을 때 이미지 취소를 하기 위한 용도이다.
기타
Controller
에서 필요한 각 메소드, 프로퍼티이므로 넣어두었다.
확장성을 염두해두고 넣어두었다. (언제 어떻게 확장할지 모르므로)
interface I_Props <...>{
//...
render : ({
register,
fieldState,
formState,
select,
remove,
base64,
}:I_CustomRegister<TFieldValues,TName> & {
fieldState: ControllerFieldState;
formState: UseFormStateReturn<TFieldValues>;
base64: string | null;
select: () => void;
remove: () => void;
}) => React.ReactElement
}
interface I_CustomRegister<TFieldValues extends FieldValues
,KName extends FieldPath<TFieldValues>>{
register: {
type: 'file';
ref: RefCallBack;
name: KName;
register: UseFormRegisterReturn<KName>;
onChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
}
}
함참 걸렸었는데, 지금보니 필요없는 것도 있네, 이렇게 타입을 지정한 후에
//...생략
const FileController = <...>({control,name}:I_Props<...>) => {
const {field,fieldState,formState} = useController({control,name})
const inputRef = useRef<HTMLInputElement | null>(null);
const { resetField, register } = useFormContext() // 나는 FormProvider를 사용하므로,
const [base64,setBase64] = useState<string|null>(null);
const onChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.[0]) {
setBase64(await getBase64(e.target.files[0]));
field.onChange(e.target.files[0]);
}
};
const handleRef: RefCallBack = (instance: HTMLInputElement) => {
field.ref(instance);
inputRef.current = instance;
};
//...
}
field.onChange
: A function which sends the input's value to the library. 이므로 form의 값으로 전달된다. resetField
& register
: 담아져있는 파일을 삭제 할 때 사용할 메소드 호출, 일반적으로 사용하는 register로 일관되게 코드를 사용하기 위해위의 코드처럼 각 메소드와 내가 필요 할것같은 것들을 선언 작성하였다.
const FileController = <...>({control,name}:I_Props<...>) => {
//...생략
return render({
register: {
name,
type: 'file',
onChange,
ref: handleRef // 입력 받은 요소를 React Hook Form에 등록
register: register(name),// props로 전달 받은 name -> 물론 register가 반환하는 것중 ref가 있지만 customRef가 적용 되게 하면 된다.
},
base64,
select: () => inputRef.current?.click(), // 파일 트리거
remove: () => {
if (inputRef.current) {
inputRef.current.value = '';
resetField(name);
setBase64(null);
}
},
fieldState,
formState,
})
}
4. 최종 코드 ( 내가 사용했던 코드
import { getBase64 } from '@/lib/utils';
import { ChangeEvent, useRef, useState } from 'react';
import {
Control,
ControllerFieldState,
FieldPath,
FieldValues,
RefCallBack,
UseControllerProps,
UseFormRegisterReturn,
UseFormStateReturn,
useController,
useFormContext,
} from 'react-hook-form';
interface I_FileControllerProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends UseControllerProps<TFieldValues, TName> {
name: TName;
control: Control<TFieldValues>;
render: ({
register,
fieldState,
formState,
select,
remove,
base64,
}: I_CustomRegister<TFieldValues, TName> & {
fieldState: ControllerFieldState;
formState: UseFormStateReturn<TFieldValues>;
base64: string | null;
select: () => void;
remove: () => void;
}) => React.ReactElement;
}
interface I_CustomRegister<TFieldValues extends FieldValues, KName extends FieldPath<TFieldValues>> {
register: {
type: 'file';
ref: RefCallBack;
name: KName;
register: UseFormRegisterReturn<KName>;
onChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
};
}
const FileController = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
control,
name,
render,
...props
}: I_FileControllerProps<TFieldValues, TName>) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const { resetField, register } = useFormContext();
const { field, fieldState, formState } = useController({ name, control });
const [base64, setBase64] = useState<string | null>(null);
const onChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.[0]) {
setBase64(await getBase64(e.target.files[0]));
field.onChange(e.target.files[0]);
}
};
const handleRef: RefCallBack = (instance: HTMLInputElement) => {
field.ref(instance);
inputRef.current = instance;
};
return render({
register: {
name,
type: 'file',
onChange,
ref:handleRef,
register: register(name),
},
base64,
select: () => inputRef.current?.click(),
remove: () => {
if (inputRef.current) {
inputRef.current.value = '';
resetField(name);
setBase64(null);
}
},
fieldState,
formState,
});
};
export default FileController;
FileController를 완성해보았다. 이제 나머지는 FileController안에 들어갈 PreviewImage 컴포넌트와 input File 컴포넌트를 만들어주자
FileController안에 들어갈 컴포넌트를 만들어주자
기존에 previewImage 컴포넌트는 Base64이 있으면 보여주고, 삭제 할수있
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { Fragment, HTMLAttributes } from 'react';
import X from '../../../../public/icons/x.svg';
interface I_PreviewImageProps extends HTMLAttributes<HTMLDivElement> {
base64: string | null;
remove: () => void;
imgClassName?: string;
}
const PreviewImage = ({ base64, remove, imgClassName }: I_PreviewImageProps) => {
return (
<Fragment>
{base64 && (
<div className="relative w-64 h-64">
<X
width="2rem"
height="2rem"
onClick={remove} // FileController에서 만든 메소드를 넣어주면 form의 값과, 이미지 데이터를 삭제 할 수 있다.
className="..."
/>
<Image
alt="preview-image"
src={base64} // FileController에서 나온 urlData
width={200}
height={200}
objectFit="cover"
className={cn('...', imgClassName)}
/>
</div>
)}
</Fragment>
);
};
export default PreviewImage;
//...생략
interface I_FileInputProps <...>{...}
const FileInput = <...>({
register,
// 생략...
profileImage = null,
...props
}: I_FileInputProps<...>) => {
const form = useFormContext(); // FormProvider로 감싸줬기 때문에 사용할 수 있다.
const hasPreviewImage = form.getValues(register.name); // 조건문을 사용하기 위해 만든 변수
return (
<FormItem className={`w-64 ${itemCn}`}>
{... 생략}
<FormControl>
<Input {...register} className="hidden" {...props} />
</FormControl>
<FormMessage />
</FormItem>
);
};
export default FileInput;
직접 커스텀한 register를 내려줘도 타입 문제 없고, React-Hook-Form에서 값을 추적, 변경, 사용 할 수 있는 것을 확인하였다.
<FileController
name="file"
control={form.control}
render={({ base64, register, remove, ...props }) => (
<Fragment>
<PetForm.previewImage remove={remove} base64={base64} imgClassName="" />
<PetForm.file register={register} />
</Fragment>
)}
/>
이렇게 FileController
를 만들어서 사용해봤다.
개발을 하면서 DX를 염두 해두고 FileController를 만들어 봤다.
클린한 코드를 짜기 위해서, 누구나 노력했지만, 나 또한, 협업을 염두해두고 공통으로 사용 할 수있는 컴포넌트를 만들어서
코드의 일관성과, 통일성에 기여 한 것 같아 뿌듯했다.
뿐만아니라
render props pattern을 알게 되었고, type과 관련하여 많은 시행착오를 겪으며 type지정을 할 때 적어도 React-Hook-Form관련 커스텀을 할 때 아직까지는 잘하지는 못하겠지만, 두려움은 많이 사그라든 것 같다.
저번달에 코드를 작성하고 메모장에 있는 글들로 작성하는데, 코드를 작성하면서 불필요하고 어?이걸 왜 썼을까 라는 의문점도 들면서 리팩토링을 해야겠다라는 생각도 들고
모처럼 진짜 제대로 된 모듈화 공통 컴포넌트를 만들었다는 느낌과 만족감이 물밀듯이 밀려온다.