본문 바로가기

공부 정리

23.01.10: React-hook-form 이용한 form 구현(React storybook, typescript)

반응형
구현 조건
1. styled 를 활용해 Wrapper 를 만들어주세요. (width: 500px, height: 700px, Wrapper border 속성을 통해서 구분을 주어야합니다.)
2. 사용자로부터 다음의 정보를 입력받을 예정입니다. (id, name, pwd, gender(M, F), email, phone, address, description) 3. description 은 선택 입력입니다. 하단에 체크박스를 클릭하면 descipriton 필드가 노출됩니다. 체크박스를 해제하면 안보입니다.
4. phone, email 은 각각의 형식에 맞게 입력을 받아야합니다. 나머지 필드는 반드시 입력해야 합니다.
5. 비밀번호는 확인을 통해서 입력한 비밀번호가 일치하는지 체크를 해야합니다.
6. 하단에 두개의 버튼이 있습니다. 좌측 버튼을 클릭 시 입력된 내용이 reset 됩니다.
7. 우측 버튼을 클릭 시 입력된 정보를 submit 합니다. submit 버튼을 누르면 console 에서 입력된 정보를 확인할 수 있습니다. 8. submit 버튼은 모든 validation 을 통과해야 활성화가 됩니다.

 

구현 코드

 

import React, { useMemo, useCallback } from 'react';
import { ComponentMeta, Story } from '@storybook/react';
import StoryFormRow, { IFormRowProps } from './FormRow';
import { Wrapper } from './FormRow.style';
import { useForm } from 'react-hook-form';
import Button from '../../Button/Button';
import Radio from '../InputControl/Radio';
import { Div } from './FormRow.style';
import InputText from '../../Form/InputControl/InputText';
import { Checkbox } from '../../Form/InputControl';
import { migratorRadioStyle } from '../InputControl/Radio/Radio.style';
import { useState } from '@storybook/addons';
import { migratorCheckboxStyle } from '../InputControl/Checkbox/Checkbox.style';
import { Size } from '../../../common/enum';
import { errorStyle } from '../InputControl/InputText/InputText.style';

export default {
  title: 'Component/FormRow',

  component: StoryFormRow,
} as ComponentMeta<typeof StoryFormRow>;

const FormRow: Story<IFormRowProps> = (args) => {
  return (
    <StoryFormRow label={'this is sample'} required>
      input
    </StoryFormRow>
  );
};

/**
 * 1월 9일 과제
 * 1. styled 를 활용해 Wrapper 를 만들어주세요. (width: 500px, height: 700px, Wrapper border 속성을 통해서 구분을 주어야합니다.)
 * 2. 사용자로부터 다음의 정보를 입력받을 예정입니다. (id, name, pwd, gender(M, F), email, phone, address, description)
 * ** description 은 선택 입력입니다. 하단에 체크박스를 클릭하면 descipriton 필드가 노출됩니다. 체크박스를 해제하면 안보입니다.
 * ** phone, email 은 각각의 형식에 맞게 입력을 받아야합니다.
 * ** 나머지 필드는 반드시 입력해야 합니다.
 * 3. 비밀번호는 확인을 통해서 입력한 비밀번호가 일치하는지 체크를 해야합니다.
 * 4. 하단에 두개의 버튼이 있습니다. 좌측 버튼을 클릭 시 입력된 내용이 reset 됩니다. 우측 버튼을 클릭 시 입력된 정보를 submit 합니다.
 * 5. submit 버튼을 누르면 console 에서 입력된 정보를 확인할 수 있습니다.
 * 6. submit 버튼은 모든 validation 을 통과해야지 활성화가 됩니다.
 *
 * ** 각각의 입력 필드들은 FormRow 컴포넌트를 통해서 래핑해주세요.
 */

// type은 component 밖에 선언함
export type FormData = {
  id: string;
  name: string;
  pwd: string;
  pwdConfirm: string;
  gender: string;
  email: string;
  phone: string;
  address: string;
  description: string | null;
};

const EJForm: Story<IFormRowProps> = (args) => {
  const options = useMemo(
    () => [
      { value: 'F', label: 'female' },
      { value: 'M', label: 'male' },
    ],
    [],
  );

  const { getValues, control, reset, formState, handleSubmit } = useForm<FormData>({
    mode: 'onChange',
    defaultValues: {
      id: '',
      name: '',
      pwd: '',
      pwdConfirm: '',
      gender: 'F',
      email: '',
      phone: '',
      address: '',
      description: '',
    },
  });
  const [isChecked, setIsChecked] = useState<boolean>(false);
  const checkHandler = useCallback(() => setIsChecked((isChecked) => !isChecked), []);

  const pwdValidationCheck = useCallback(() => {
    const pwd = getValues('pwd');
    const confirm = getValues('pwdConfirm');
    if (pwd === '' || confirm === '') {
      alert('비밀번호를 모두 입력해주세요');
    } else if (pwd !== confirm) {
      alert('비밀번호가 일치하지 않습니다');
    } else {
      alert('비밀번호가 일치합니다');
    }
  }, [getValues]);

  const resetHandler = useCallback(() => {
    reset();
    setIsChecked(false);
  }, []);

  const onSubmit = useCallback((data: FormData) => {
    console.log(getValues());
    reset();
    setIsChecked(false);
  }, []);
  console.log(getValues());
  return (
    <Wrapper onSubmit={handleSubmit(onSubmit)}>
      <StoryFormRow label={'ID'} required>
        <Div>
          <InputText
            inputSize={Size.S}
            control={control}
            name={'id'}
            placeholder={'ID'}
            type="text"
            rules={{ required: true }}
          />
        </Div>
      </StoryFormRow>
      <StoryFormRow label={'Name'} required>
        <Div>
          <InputText
            inputSize={Size.S}
            control={control}
            name={'name'}
            placeholder={'Name'}
            type="text"
            rules={{ required: true }}
          />
        </Div>
      </StoryFormRow>
      <StoryFormRow label={'Password'} required>
        <Div>
          <InputText
            inputSize={Size.S}
            control={control}
            name={'pwd'}
            placeholder={'Password'}
            type="password"
            rules={{ required: true }}
          />
        </Div>
      </StoryFormRow>
      <StoryFormRow label={'Password confirm'} required>
        <Div>
          <InputText
            inputSize={Size.S}
            control={control}
            name={'pwdConfirm'}
            placeholder={'Password confirm'}
            type="password"
            rules={{
              required: true,
            }}
          />{' '}
          &nbsp;
          <Button size={'large'} onClick={pwdValidationCheck}>
            비밀번호 확인
          </Button>
        </Div>
      </StoryFormRow>
      <StoryFormRow label={'Gender'} required>
        <Div>
          <Radio
            control={control}
            options={options}
            name="gender"
            radioStyle={migratorRadioStyle}
            rules={{ required: true }}
          />
        </Div>
      </StoryFormRow>
      <StoryFormRow label={'Email'} required helperMessage={formState.errors.email?.message || ''}>
        <Div>
          <InputText
            inputSize={Size.S}
            control={control}
            sx={errorStyle}
            name={'email'}
            placeholder={'Email'}
            type="email"
            rules={{
              required: true,
              pattern: {
                value: /^[0-9a-zA-Z]([-_\.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_\.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/,
                message: '올바른 이메일 형식이 아닙니다.',
              },
            }}
          />
        </Div>
      </StoryFormRow>
      <StoryFormRow label={'Phone'} required helperMessage={formState.errors.phone?.message}>
        <Div>
          <InputText
            inputSize={Size.S}
            control={control}
            name={'phone'}
            placeholder={'Phone'}
            type="tel"
            rules={{
              required: true,
              pattern: {
                value: /^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$/,
                message: '01X-123-1234 또는 01X-1234-1234 형식으로 입력해주세요',
              },
            }}
          />
        </Div>
      </StoryFormRow>
      <StoryFormRow label={'Address'} required>
        <Div>
          <InputText
            inputSize={Size.S}
            control={control}
            name={'address'}
            placeholder={'Address'}
            type="text"
            rules={{ required: true }}
          />
        </Div>
      </StoryFormRow>
      <StoryFormRow label={'Description'}>
        &nbsp; &nbsp;
        <Checkbox
          onClick={checkHandler}
          name="description"
          checked={isChecked}
          disabled={false}
          labelProps={{
            sx: migratorCheckboxStyle,
          }}
        />
        <Div>
          {isChecked && (
            <InputText
              fullWidth
              inputSize={Size.S}
              control={control}
              name={'description'}
              placeholder={'Enter description'}
              type="text"
            />
          )}
        </Div>
      </StoryFormRow>
      <div style={{ margin: '5px 0 5px 0' }}>
        <Button onClick={resetHandler}>Reset</Button>
        &nbsp;
        <Button type="submit" disabled={!formState.isValid}>
          Submit
        </Button>
      </div>
    </Wrapper>
  );
};

export const Basic = FormRow.bind({});
export const EJSampleForm = EJForm.bind({});

 

#  React hook form를 사용하는 이유

 

대부분의 경우 폼을 구현하는데 제어 컴포넌트를 사용하는 것이 좋다. 제어 컴포넌트에서 폼 데이터는 React 컴포넌트에서 다루어진다. 대안인 비제어 컴포넌트는 DOM 자체에서 폼 데이터가 다루어진다. 

출처 : 리액트 공식 사이트 

 

그럼에도 불구하고 비제어 컴포넌트를 쓰는 이유는 무엇인지, 일단 제어 컴포넌트와 비제어 컴포넌트에 대해서 알고나서 React hook form을 사용하는 이유를 이해 할 수 있을 것 같다.

 

제어 컴포넌트

 

function App = () => {
const [value, setValue] = useState("");

const changeHandler = (e) => {
  setValue(e.current.value)
}

return(
    <input onChange={changeHandler} value={value}/>
  )
}

 

사용자의 입력을 바탕으로 state를 관리하며 update하는 제어 컴포넌트는 입력할때마다 렌더링을 하여서 불필요한 리렌더링 혹은 API를 호출하게 된다. 이런 문제점을 해결 할 수 있는 아래와 같은 방법들이 있다.

 

1. Throttling: 마지막 함수가 호출된 후 일정 시간이 지나기 전 다시 호출되지 않도록 함
2. Debouncing: 연이어 호출되는 함수들 중 마지막 또는 맨 처음 함수만 호출
더 자세히 알기

 

제어컴포넌트는 유효성 검사, 실시간 필드 구독, 조건에 따라 버튼을 활성화 또는 비활성화 하는 경우 사용하기 용이하다.

 

비제어컴포넌트

 

import React, { useRef } from 'React';

function App = () => {
  const valueRef = useRef(null);
  
  return (
    <input ref={valueRef}/>
  )

}

export default App;

 

비제어 컴포넌트는 state로 값을 관리하지 않아서 값이 바뀔때마다 리렌더링, API 호출이 되지 않는다. 따라서 성능상 이점이 있다.

비제어 컴포넌트에서 DOM에서 접근하기 위해서는 ref라는 객체를 제공한다. 그리고 ref 객체의 current 프로퍼티의 값을 변경한다.

값을 업데이트해도 컴포넌트가 리렌더링되지 않아 렌더링 횟수를줄여준다. 

일반적으로 모든 form 요소에서 상태의 동기화가 필요하지 않으며, form 요소가 증가할수록 모든 컴포넌트에 Throttling 이나 Debouncing을 걸기는 힘들다. 만약 값이 트리거 된 이후에만 갱신이 되어도 문제가 없으면, ref를 사용하는것이 불필요한 렌더링을 방지하는데 도움이 될 수 있다. 이러한 비제어 컴포넌트를 사용하여 렌더링을 최적화하는 라이브러리가 react-hook-form이다.

# React Hook Form : 비제어 컴포넌트

 

특징

  • Hooks API
  • 비제어 컴포넌트 기반: 제어 컴포넌트에 비해 성능이 우수하다
  • 폼을 위한 컴포넌트나 필드를 위한 컴포넌트가 없다. <form/> <input/> 에 ref를 사용해 폼을 구성할 수 있다.
  • 유효성 검증 위한 내부 기능 포함, 필요하면 yup(유효성 검증 라이브러리) 사용 가능
  • 타입스크립트로 작성된 프로젝트

 

useForm()

 

쉽게 form을 관리하기 위한 cusetom hook. 하나의 개체를 선택적 인수로 사용한다.

 

APIs

* getValues()

 

getValues: (payload?: string | string[]) => Object

 

 

watch와 getValues의 차이점은 getValues는 re-render를 유발하거나 input의 변화를 감지하지 않는다는 점이다. 

 

rules

  • disabled input은 undefined로 반환됨. 사용자가 input 상태를 변화시키는 것을 막고, field value를 유지하고자 한다면 readOnly나 <fieldset/> 을 disable 시킬수 있다.
  • 초기 렌더링 이전에 useForm으로 부터 defaultValues가 반환됨

코드예제 

import React from "react";
import { useForm } from "react-hook-form";

type FormInputs = {
  test: string
  test1: string
}

export default function App() {
  const { register, getValues } = useForm<FormInputs>();

  return (
    <form>
      <input {...register("test")} />
      <input {...register("test1")} />

      <button
        type="button"
        onClick={() => {
          const values = getValues(); // { test: "test-input", test1: "test1-input" }
          const singleValue = getValues("test"); // "test-input"
          const multipleValues = getValues(["test", "test1"]);
          // ["test-input", "test1-input"]
        }}
      >
        Get Values
      </button>
    </form>
  );
}

 

* reset()

 

전체 form state, 필드 reference, subscriptions를 리셋시켜줌. 선택적 인수가 있으며 부분적으로 form state를 리셋함

 

rules

  • Controller 제어 컴포넌트의 값을 리셋하려면 defaultValues를 useForm에 전달해 줘야 함
  • API에 defaultValues가 제공되지 않을 시 HTML native reset API가 호출되어 form을 리셋함
  • useForm의 useEffect가 호출되기 전에 reset을 호출하는 것을 피해야 함. reset이 form 상태 초기화 업데이트 신호를 보내기 전에 useForm을 구독할 준비가 되어야 하기 때문
  • reset은 submit 이후 useEffect 안에서 실행되어지는 것이 좋다!
useEffect(() => {
  reset({
    data: 'test'
  })
}, [isSubmitSuccessful])

 

코드예제

 

import { useForm } from "react-hook-form";

interface UseFormInputs {
  firstName: string
  lastName: string
}

export default function Form() {
  const { register, handleSubmit, reset, formState: { errors } } = useForm<UseFormInputs>();
  const onSubmit = (data: UseFormInputs) => {
    console.log(data)
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>First name</label>
      <input {...register("firstName", { required: true })} />

      <label>Last name</label>
      <input {...register("lastName")} />

      <input type="submit" />
      <input
        type="reset"
        value="Standard Reset Field Values"
      />
      <input
        type="button"
        onClick={() => reset()}
        value="Custom Reset Field Values & Errors"
      />
    </form>
  );
}

 

* formState()

 

이 객체는 form에서 관리되는 state들의 정보를 포함 한다. 구현한 form application과 사용자의 interaction을 추척할 수 있도록 돕는 역할을 함. 

 

return (몇가지만 요약하겠음 - 더 많은 정보는 공홈 참고)

  • isDirty: boolean 값을 지닌다. 사용자가 input을 수정하는 경우 true. 모든 Input에 대한 defaultValues를 갖고있어야 form 이 dirty한지 참/거짓을 각각 판단할 수 있음
  • isValid: boolean 값을 지님. form이 error가 없으면 true로 지정됨. setError는 isValid에 아무런 연향을 주지 않는다. isValid는 전체 form 유효성 검사 결과를 통해서 파생되어짐
  • errors: field error의 한 객체. Error Message를 쉽게 다루기 위한 ErrorMessage 컴포넌트가 존재함
  • defaultValues: useForm의 defaultValues로 지정된 값 또는 reset API를 통해서 업데이트된 값들

rules

  • 성능 증진, 특정한 state가 구독되어지지 않으면 extra logic을 건너뛰기 위해서 formState는 Proxy로 감싸져있음. 따라서 렌더링 전에 state를 업데이트하려면 formState를 호출하는 것을 잊지 말아야 함
  • formState는 batch로 업데이트가 됨. useEffect를 통해서 formState를 구독할 경우 dependency array에 formState자체를 넣어야 함.
useEffect(() => {
  if (formState.errors.firstName) {
    // do the your logic here
  }
}, [formState]); // ✅ 
// ❌ formState.errors will not trigger the useEffect

 

  • formState를 구독할때 논리 연산자에 주의해야 함

 

// ❌ formState.isValid is accessed conditionally, 
// so the Proxy does not subscribe to changes of that state
return <button disabled={!formState.isDirty || !formState.isValid} />;
  
// ✅ read all formState values to subscribe to changes
const { isDirty, isValid } = formState;
return <button disabled={!isDirty || !isValid} />;

 

* handleSubmit()

 

이 함수는 form의 유효성 검증이 성공적이라면 form data를 받는다.

 

rules

  • handleSubmit으로 쉽게 비동기적인 form submit이 가능함
// It can be invoked remotely as well
handleSubmit(onSubmit)();

// You can pass an async function for asynchronous validation.
handleSubmit(async (data) => await fetchAPI(data))
  • form values에서 disabled input들은 undefined로 반환됨. 사용자가 input 상태를 변화시키는 것을 막고, field value를 유지하고자 한다면 readOnly나 전체 <fieldset/>을 disable 할 수 있다.
  • handleSubmit은 onSubmit callback안에서 발생되는 에러를 처리 해주지는 않는다. 따라서 비동기 요청에 try catch문을 활용을 권고하며 사용자를 위하여 에러를 잘 처리해야 함
const onSubmit = () => {
  // async request which may result error
  throw new Error("Something is wrong");
};

<>
  <form
    onSubmit={(e) => {
      handleSubmit(onSubmit)(e)
      // you will have to catch those error and handle them
      .catch(() => {});
    }}
  />
  // The following is a better approach
  <form
    onSubmit={handleSubmit(() => {
      try {
        request();
      } catch (e) {
        // handle your error state here
      }
    })}
  />
</>;

 

코드예제

 

import React from "react";
import { useForm } from "react-hook-form";

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

function App() {
  const { register, handleSubmit, formState: { errors }, formState } = useForm();
  const onSubmit = async data => {
    await sleep(2000);
    if (data.username === "bill") {
      alert(JSON.stringify(data));
    } else {
      alert("There is an error");
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label htmlFor="username">User Name</label>
      <input placeholder="Bill" {...register("username"} />

      <input type="submit" />
    </form>
  );
}

 

 

References

React-hook-form official website

React 비제어 컴포넌트

코딩병원 : 제어 컴포넌트 vs 비제어 컴포넌트

limewhale: 제어 컴포넌트와 비제어 컴포넌트의 차이점

반응형