[ 공모전 ] FileController : render props 4/4

최문길·2024년 6월 21일
0

공모전

목록 보기
13/46

Render props Pattern

저번 시간에 Controller에서 render 함수를 짤막하게 문장으로 작성해 봤었다.
비제어형 React-Hook-Form에서 어떻게 제어형 컴포넌트를 연결시키고 상호작용 할 수 있는지,

render함수 안에 컴포넌트(제어형)를 등록하고 Controller의 props를 받아 적용시키는 것이다. 따라서 내가 판단 해보았을 때 render props pattern 인 것 같다.

그렇다면 render props를 한번 만들어볼까??
각 블로그에서는 예시코드가 거의다 똑같았고, 내가 구현하고 싶은 것과 거리가 조금 있기에, 이번 코드는 FileController를 만드는데 초점을 맞춘 render props pattern이다.

  1. 타입 정의하기
    나는 React Typescript를 사용하고 있기에 타입을 명시해줘야 한다. 따라서 타입을 정의 해주었다.
interface I_RenderProps {
  render: ({ state }: { state: number }) => JSX.Element;
}
  1. render 함수 반환할 부모 컴포넌트 작성하기
function RenderProps({ render }: I_RenderProps) {
  const [state, setState] = useState(1);
  return render({ state });
}

함수 선언문으로 만들어준것은 호이스팅 하기 위함이다.(아래 마지막 코드를 보면 안다.)
render함수안에서 rendering 되는 컴포넌트가 의도대로 작동하는지 알기 위해서 state인자를 넣어줘봤다.

  1. 부모컴포넌트(render 함수를 가진)에서 rendering 시킬 자식 component 만들기
function Compo({ state }: { state: number }) {
  return <div>state 값은 {state}</div>;
}

  1. 최종 코드
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

FileController를 만들기

idea와 방향성

아이디어와 방향성은 저번 글에서 이야기 한것과 같이

  1. Controller를 벤치마킹
  2. render props pattern
  3. useController 사용하기

이 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} /> }
  />

namecontrol이 들어가고 render props안의 인자값에는 field와, 여러 메소드들이 들어있게 하자

Controller에 name과 control이 반드시 들어가는데, Controller와 유사한 것이 있지 않은가?

그렇다!!

바로 useController 이다.

useController에 name과 control을 props 전달해주면 똑같이 field,formstate등등을 반환 해준다.

field,formstate,fieldState 만들어주기

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를 반환한다.

render 함수 정의(Custom)해주기

render 안에들어갈 것들

  • register : 객체 형태
  • base64 ( preview Image Url )
  • remove 함수 ( 이미지 삭제 & File 삭제 )
  • 기타 ( formState , fieldState , field 등... )

register

register는 내가 다른 개발자와 협업을 염두해서 field를 custom하면 좋을 듯해서 render안에 들어가야 할 객체이다.
regitser안에는

{
   type: 'file';
   ref: RefCallBack;
   name: TName;
   register: UseFormRegisterReturn<TName>;
   onChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
 }

이러한 property가 있는데

  • type : 'file' 은 input태그에 type을 file로 하기 위한 property이다.
  • ref : RefCallBack이라는 타입은 react-hook-form에서 지원하는 타입인데 비제어형일 때 값을 담는 역할이므로 필요하다.
  • name : name에는 내가 props로 내려준 name이 들어간다.
  • register : react-hook-form 에서 예시로 언제나 나오는 단골이다. 등록할때 사용하는데, 나또한 등록할 때 사용하기 위해 넣었다.
  • onChange : onChange는 파일을 getBase64함수( 이전 블로그 참조 ) 로 urlData를 읽어 state에 저장 하는 custom onChange이다.

base64
base64는 내가 image url에 넣기 위해 반드시 필요한 dataURL이다.

remove함수
FileController Component안에서 만들 함수인데, 미리본 이미지를 취소 하고 싶을 때 이미지 취소를 하기 위한 용도이다.

기타
Controller에서 필요한 각 메소드, 프로퍼티이므로 넣어두었다.
확장성을 염두해두고 넣어두었다. (언제 어떻게 확장할지 모르므로)



  1. 타입을 우선 지정해보자
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>;
    }
   }

함참 걸렸었는데, 지금보니 필요없는 것도 있네, 이렇게 타입을 지정한 후에


  1. custom할 메소드, value 정의하기
//...생략
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;
  		};
       //...
  }
  • onChange : file을 담아둘 onChange method를 커스텀
    - field.onChange : A function which sends the input's value to the library. 이므로 form의 값으로 전달된다.
  • resetField & register : 담아져있는 파일을 삭제 할 때 사용할 메소드 호출, 일반적으로 사용하는 register로 일관되게 코드를 사용하기 위해
  • base64 : 이미지urlData
  • handleRef : React-hook-Form에등록 위해

위의 코드처럼 각 메소드와 내가 필요 할것같은 것들을 선언 작성하였다.


  1. return 하기
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 컴포넌트를 만들어주자

PreviewImage , InputFile Component 만들기

FileController안에 들어갈 컴포넌트를 만들어주자

PreviewImage

기존에 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;

CustomInputFile

//...생략
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를 만들어서 사용해봤다.

  • 우리가 사용하는 라이브러리(ShadCn,React-hook-form)코드의 일관성이 눈에 띈다.

마무리

개발을 하면서 DX를 염두 해두고 FileController를 만들어 봤다.

클린한 코드를 짜기 위해서, 누구나 노력했지만, 나 또한, 협업을 염두해두고 공통으로 사용 할 수있는 컴포넌트를 만들어서
코드의 일관성과, 통일성에 기여 한 것 같아 뿌듯했다.

뿐만아니라
render props pattern을 알게 되었고, type과 관련하여 많은 시행착오를 겪으며 type지정을 할 때 적어도 React-Hook-Form관련 커스텀을 할 때 아직까지는 잘하지는 못하겠지만, 두려움은 많이 사그라든 것 같다.

저번달에 코드를 작성하고 메모장에 있는 글들로 작성하는데, 코드를 작성하면서 불필요하고 어?이걸 왜 썼을까 라는 의문점도 들면서 리팩토링을 해야겠다라는 생각도 들고

모처럼 진짜 제대로 된 모듈화 공통 컴포넌트를 만들었다는 느낌과 만족감이 물밀듯이 밀려온다.

0개의 댓글

관련 채용 정보