React Native) custom form hook으로 react hook form 대체하기

2ast·2024년 2월 6일
1

React에서 form을 관리할 때 불편한 점

ReactNative에서 사용자 입력을 받는 TextInput을 구현하는 방법은 간단하다. 보여줄 텍스트를 state로 선언하면 된다.

const [text, setText] = useState('')

return <TextInput value={text} onChangeText={setText}/>

이렇게 작성해주면 TextInput의 값이 바뀔때마다 setText가 실행되고, 자연스럽게 text state에 input value가 쌓이게 된다.
만약 특정 버튼을 눌렀을 때 TextInput에 포커스를 주고 싶다면 이렇게 ref를 선언해서 연결해주면 된다.

const [text, setText] = useState('')
const ref = useRef<TextInput>(null);

return <View>
  <TextInput ref={ref} value={text} onChangeText={setText}/>
  <Pressable onPress={ref.current.focus} style={styles.button}/>
</View>

만약 Text에 validation을 주고 싶다면 errorMessage state를 선언한 뒤 값이 바뀔때마다 판단해서 setState해주면 된다.

const [text, setText] = useState('')
const ref = useRef<TextInput>(null);
const [errorMessage, setErrorMessage] = useState('')

useEffect(()=>{
  if(validationRegex.test(text)){
    setErrorMessage('')
  } else {
    setErrorMessage('올바른 값을 입력해 주세요.')
  }
  
},[text])

return <View>
  <TextInput ref={ref} value={text} onChangeText={setText}/>
  <Pressable onPress={ref.current.focus} style={styles.button}/>
</View>

이처럼 하나의 TextInput을 활용하기 위해서 useState, useRef, useEffect 등 수많은 훅이 호출되고 코드가 비대해진 것을 확인할 수 있다. 일찍이 이런 보일러 플레이트를 줄여보고자 다짐에서는 useValidationText라는 customHook을 만들어 사용하고 있었다.

const {
  text,
  setText,
  errorText,
  setErrorText,
  inputRef,
  focusInput
} = useValidationText({[
  {regex: Regex.phone, message: '올바른 연락처를 입력해 주세요.'},
]);

하지만 useValidationText는 모든 문제를 해결해주지 못했다.

useValidationText의 한계

input form의 갯수가 많지 않은 다짐앱의 특성상 useValidationText만으로 대부분의 기능 구현에 큰 무리는 없었으나, 최근 수많은 form을 구현해야하는 '다짐파트너' 앱 리뉴얼을 작업하면서 useValidationText의 한계를 느꼈다. useValidationText는 기본적으로 단일 input에 적용가능한 값들을 반환하도록 구성되어 있다. 때문에 만약 input 5개가 필요한 뷰라면 useValidationText도 5번 호출해야하고, 이것만으로 꽤나 많은 줄의 코드를 만들어낸다.

react-hook-form이 바로 이런 문제를 해결해주는 라이브러리다. react-hook-form을 사용하면 짧은 코드로 수많은 form data를 처리할 수 있게 해준다. 하지만 다음과 같은 이유로 나는 react-hook-form을 그대로 사용하는 대신 직접 useDgForm hook을 만들어 사용하기로 했다.

  1. 디자인 시스템 설계에 대한 고민들에서도 짧게 언급했듯이, useValidationText는 DgTextInput이라는 Component와 세트로 사용중인 hook이었다. react hook form을 그대로 사용하는 것보다는 DgTextInput과 궁합이 맞는 훅을 만들고 싶었다.
  2. 개인적인 취향이지만, 나는 외부 라이브러리 설치에 조금 엄격한 편이다.내가 제대로 동작원리를 알 수 없는 라이브러리에 의존하는것이 영 찜찜하기도하고, 직접 구현 가능한 능력과 여유가 허락한다면 직접 구현해보는 것은 즐거우면서도 성장에 도움이 되기 때문이다.

useDgForm

usage

useDgForm은 다음과 같이 사용할 수 있다. (타입을 조금 신경써준 덕분에 자동완성까지 지원한다.)

  const {
    formValue,
    setFormValue,
    errorMessage,
    getInputRefCallback,
    focusInput,
  } = useDgForm({
    name: { initialText: "홍길동" },
    phone: {
      validations: [
        { regex: Regex.phone, message: "올바른 전화번호를 입력해 주세요." },
      ],
    },
    etc: {},
  });

  useEffect(() => {
    focusInput("name");
  }, []);

  return (
    <View>
      <DgTextInput
        ref={getInputRefCallback("name")}
        value={formValue.name}
        onChangeText={setFormValue.name}
      />
      <DgTextInput
        value={formValue.phone}
        onChangeText={setFormValue.phone}
        errorMessage={errorMessage.phone}
      />
      <DgTextInput value={formValue.etc} onChangeText={setFormValue.etc} />
    </View>
</View>

구현

export type DgFormParams<T extends string> = Record<
  T,
  {
    validations?: {regex: RegExp; message: string}[];
    initialText?: string;
  }
>;

export const useDgForm = <T extends string>(params: DgFormParams<T>) => {
          
  // params로 받은 object로부터 formKey를 추출한다.
  const formKeys = Object.keys(params) as T[];

  // formKeys를 reduce하여 object형태로 textState를 만든다.
  const [form, setForm] = useState<Record<T, string>>(
    formKeys.reduce((acc, key) => {
      acc[key] = params[key].initialText ?? '';
      return acc;
    }, {} as Record<T, string>),
  );
  // form state와 동일하다
  const [errorText, setErrorText] = useState<Record<T, string>>(
    formKeys.reduce((acc, key) => {
      acc[key] = '';
      return acc;
    }, {} as Record<T, string>),
  );

  // formKeys 길이만큼 ref를 동적으로 할당해주기 위해 reduce로 선언하고 있다.
  const inputRef = useRef<Record<T, TextInput | null>>(
    formKeys.reduce((acc, key) => {
      acc[key] = null;
      return acc;
    }, {} as Record<T, TextInput | null>),
  );

  
  const setFormError = formKeys.reduce((acc, key) => {
    acc[key] = (value: string) => {
      setErrorText(prev => ({...prev, [key]: value}));
    };
    return acc;
  }, {} as Record<T, (value: string) => void>);

  // formValue가 params로 받은 정규식에 부합하는지 체크하는 함수다
  const checkValidation = (k: T, text: string) => {
    const validations = params[k].validations;
    if (validations?.length) {
      validations?.forEach(validation => {
        if (validation?.regex.test(text)) {
          setFormError[k]('');
        } else {
          setFormError[k](validation?.message);
        }
      });
    } else {
      setFormError[k]('');
    }
  };

  // formKeys로 부터 object형태로 setFormValue를 만든다.
  const setFormValue = formKeys.reduce((acc, key) => {
    acc[key] = (value: string) => {
      //호출될 때마다 checkValidation을 실행하여 validate 여부를 판단한다.
      checkValidation(key, value);
      setForm(prev => ({...prev, [key]: value}));
    };
    return acc;
  }, {} as Record<T, (value: string) => void>);

  const focusInput = (key: T) => inputRef?.current?.[key]?.focus();
  const blurInput = (key: T) => inputRef?.current?.[key]?.blur();
        
  // 동적 생성된 ref를 바인딩 해주기 위해 TextInput에 callback으로 넘겨야 하므로 currying을 이용해 getCallback Fn을 만들어 주었다.
  const getInputRefCallback = (key: T) => (elem: TextInput) =>
      (inputRef.current[key] = elem)

  return {
    formValue: form,
    setFormValue,
    formError: errorText,
    setFormError,
    focusInput,
    blurInput,
    inputRef,
    getInputRefCallback,
  };
};

위 구현 코드를 보면 알 수 있듯이, useValidationText에서 받던 params를 obejct형태로 받고, 또 이로부터 formKeys를 추출하여 reduce를 활용해 모든 반환하는 객체를 obejct로 생성해서 내보낼 뿐임을 알 수 있다.

여기서 조금 눈여겨볼 부분은 checkValidation을 setFormValue에서 직접 호출함으로 useEffect를 사용하지 않아도 됐던 점과 ref를 getInputRefCallback이라는 함수를 이용해 바인딩해준다는 점이다.
useCallback과 useMemo등 memoization hook 뿐만 아니라 최근들어서 useEffect 덜어내기를 의식적으로 도전하고 있는데 생각보다 useEffect 없어도 구현 가능한 케이스가 많고 코드적으로도 깔끔해지는 기분이 들어서 만족하고 있다.(강추)
또한, ref를 TextInput의 ref에 직접 넘겨줬던 과거와는 다르게, 이번에는 object 형태로 만든 ref를 적절하게 바인딩해주기 위해 getInputRefCallback이라는 함수를 사용해서 ref에 callback을 넘겨줘야 한다. 이때 깔끔한 사용을 위해 인자를 받아 함수를 반환하는 형태로 구현했다.

 <DgTextInput ref={getInputRefCallback('name')} .../>

마치며

생각보다 간단하게 useValidiationText를 확장하여 다중 폼에 적용 가능한 useDgForm hook을 만들어 사용하고 있다. 일단 외부 라이브러리를 사용하지 않아도 충분히 편하게 개발이 가능했다는 부분이 마음에 들었다. 그렇다고 완벽하다고 생각하지는 않는다. useDgText는 그 반환값을 직접 하나하나 뽑아서 props로 넘겨야하지만 react-hook-form을 보면 register라는 함수로 이부분을 축약하고 있다.

//ex
<input {...register("exampleRequired", { required: true })} />

그리고 maxLength나 required같은 input option을 hook단에서 제어하는 기능도 아직 없다. 최소한의 기능을 담아서 구현한 부분이기는 하지만 실제로 사용하다보니 이런 부분이 조금 아쉽게 느껴지기도 해서 조만간 이쪽을 보완해볼 수도 있겠다는 생각이 들었다.
그리고 조금 다른 방향이지만 formValue, setFormValue와 같이 data의 종류로 묶지 않고 실제로 사용할 때 name.value, name.onChangeText와 같은 형태로 사용할 수 있도록 formKey를 기준으로 묶어볼까하는 생각도 들어서 다음에 기회가 된다면 시도해볼까 싶다. 끗!

.
.
.
그래서 간략하게 register를 만들어봤다.

const register = (key: T) => ({
    ref: getInputRefCallback(key),
    value: form[key],
    onChangeText: setFormValue[key],
    errorMessage: errorText[key],
  });

const {register} = useDgForm({code:{...}})

return  (
  <DgInput {...register('code')}/>
)
profile
React-Native 개발블로그

2개의 댓글

comment-user-thumbnail
2024년 2월 13일

DG 붙이니까 다짐인지 동국인지 헷갈리네

1개의 답글