react-hook-form register 속성 할당 이슈 및 troubleshootiong

송현섭 ·2024년 7월 13일
0

개별공부

목록 보기
39/44

  • 기존에 작성했던 category select 컴포넌트를 반환하는 함수에 react hook form 의 register를 등록할 필요성이 생겼다. 문제는 일반 컴포넌트라면 props 속성으로 넘겨줄 수 있겠으나 해당 렌더함수는 말그대로 함수를 호출해서 return값을 렌더링해서 화면에 표출하는 것이기에 register를 인수로 넘길 수 밖에 없었다.
    (클린코드, 최적화된 코드라면 사실 매개변수자리가 두자리인 것도 가급적 회피하라는데 역시 그건 어려운 듯 하다... 그 밖에 해당 함수에 인수로 register 객체를 넣는 게 옳은 방법인가 의구심이 들지만 현재 render 함수를 따로 변형시킬 생각은 없기에 이 방식을 일단은 택했다)




  • 변경완료 후의 코드. 인수로 들어간다는 점이 마음에 걸리지만, 내부에서 props로 풀어서 전달하는 건 같다. (물론 depth는 그만큼 늘어나지만)




  • props로 전달 받은 render 함수의 내부 로직. 원래 코드는 이랬다. 기존에는 ref 값으로 인수로 받은 isSub 상태의 boolean 에 따라 조건부로 ref 객체를 전달하고 있었다. 그리고 이 과정에서 이슈가 발생하는데...






이슈 1 - 한 컴포넌트에 두 개의 ref 속성이 들어가는 상황

  • 앞서 코드에서 보였듯이 isSub의 boolean 값에 따라 subCategory select를 참조하는 subRef의 할당 여부를 조건부로 전달하고 있었다. 즉 이미 ref 객체 하나가 해당 Custom Component로 전달되고 있던 것

  • 애초에 react 자체가 한 컴포넌트에 같은 이름의 속성이 중복되는 걸 허용하지도 않기에 처음에는 ref를 두 개 만들어서 각각 할당해주어야 하나 고민했다.
    물론 평소 방식대로 배열형식으로 여러개의 ref를 만든 다음 이걸 props로 전달해 실제 사용할 컴포넌트에서 쓸 수도 있으나 굳이 불필요하게 객체를 하나 더 만드는 것 같아서 다른 방안을 찾다가 이후 후술할 결합방식을 통해 이 문제를 해결했다.






이슈 2 - undefined 객체에 대한 구조분해할당은 불가능합니다

  • 앞의 결합방식을 적용하기 위해 registerParms 에 register 객체의 속성들을 풀어서 전달하는데 위의 오류를 만났다. 꽤 오래 씨름을 했으나 해결법은 간단했다. (사실 문법적 에러라 해결책을 찾았다고 보기는 어렵다만..)

  • 문제의 원인은 props로 넘겨올 때 {registerParms} 이런 식으로 구조분해할당이 된 상태를 다시 분해해서 값을 가져오려하다가 발생했다. 이미 객체를 풀었는데, 여기서 다시 구조분해를 하려고하니 undefined로 판단한 듯 하다.






  • 기존에 ref를 전달하던 방식에서 register 자체의 객체와 렌더링 함수 호출로 받은 subCategory를 참조하는 ref 객체를 받아서 다시 CustomSelect 로 전달하였다.
    생각해보면 CustomSelect도 사실 내가 리팩토링으로 분리한 컴포넌트로 그 안에서 다시 ref 속성에 제대로 값을 할당해줘야만 하고, 현재 return 문에서 굳이 ref를 쓸 필요도 없다고 생각해서 위처럼 작성했다.




  • 사실 처음엔 이렇게 작성했다. 깔끔하게 각 ref를 전달하고 CustomSelect 안에서 결합시키면 좋지 않을까 했는데...






이슈 3 - 제어 컴포넌트를 비제어로 변형시키려 합니다

  • 저렇게 전달하면 꼭 이 에러를 만났다. 아직 해결은 못했다. 다만 추측해보자면 나는 제어 컴포넌트 형식으로 값을 받고 있으나 브라우저가 판단하기에 이는 비제어 컴포넌트를 활용하고 있다고 판단해서 생긴 문제인 듯 하다.




비제어 컴포넌트

function UncontrolledForm() {
  return (
    <form action="/some-endpoint" method="post">
      <input name="exampleInput" defaultValue={2} min={2} required />
      <button>Submit</button>
    </form>
  );
}
  • 브라우저가 양식 값을 추적하도록 한다. 쉽게 말하자면 위의 코드처럼 form에 endPoint를 지정하고, input에 값을 입력해서 submit을 하게 되면 이 값은 React 환경이 아닌 DOM 자체에 직접 저장된다. 즉 내가 직접 꺼내서 확인하기 전까지는 그 상태를 바로 알 수 없어, 데이터 흐름 예측이 어렵다.
    좀 더 풀어보자면 React에서 바로 그 값을 활용하는게 아니라, 일단은 가져와야 한다는 것이다.

  • React에서 상태를 관리하는 게 아니기 때문에 react의 특징이라고도 할 수 있는 저장 후 Data의 동기화 또한 이루어지지 않는다.동기화가 이루어지지 않는만큼, 리렌더링 또한 발생하지 않는다.

  • 좀 더 깊게 파보니 비제어는 보통 useRef를 이용해서 DOM에 접근해 값을 가져오게 되는데, 이 useRef 객체가 heap 메모리에 저장되기에, 앱 자체가 종료되기 전까지는 항상 같은 객체를 제공한다고 한다.(참조값 주소가 같다는 의미)

  • 즉, 항상 메모리 주소가 같기에 react는 변화를 감지할 수 없고, 그렇기에 리렌더링 또한 발생하지 않는 것이다.




제어 컴포넌트

function parseForm(form: HTMLFormElement) {
  // parse, validate, and return form data
}

function ControlledForm() {
  const [form, setForm] = useState({ exampleInput: 2 });

  function handleSubmit(event) {
    event.preventDefault();

    const data = parseForm(event.target);

    fetch("/some-endpoint", { method: "post", data });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="exampleInput"
        value={form.exampleInput}
        onChange={(e) =>
          setForm((prev) => ({ ...prev, exampleInput: e.target.value }))
        }
        min={2}
        required
      />
      <button>Submit</button>
    </form>
  );
}
  • 제어는 이와 달리 react 에서 직접 상태값을 관리하고, 조작할 수 있다. 익숙한 state를 통해 직접 관리하기 때문에 항상 상태의 최신값을 유지하며, 변화가 감지되기 때문에 리렌더링이 발생한다. 리렌더링이 발생한다는 건 항상 동기화가 진행된다는 의미라고 볼 수 있다.

  • 바로바로 변하는 입력값에 대해 무언가 로직을 수행해야 할 경우, 제어 컴포넌트를 이용한다고 할 수 있다. 그렇기에 사용자 입장에서 보았을 때 입력상태에 따라 화면상태도 바뀌는 부분에 많이 활용되고 있다.
    (ex. input 입력 여부에 따라 disabled 되는 버튼)






제어된 양식에서는 입력 값을 정의되지 않은 것으로 설정해서는 안 됩니다!


  • 일단 위의 이론을 토대로 생각해보았을 때 내가 맞이한 이 에러는 입력값의 정의가 되지 않은 상태로 넘겨졌기 때문으로 보인다

const [exampleInput, setExampleInput] = useState();
// const [exampleInput, setExampleInput] = useState(undefined); <--- or this!

<input name="exampleInput" value={exampleInput} onChange={setExampleInput} />;
  • 실제로 관련 문서에서도 보면 제어 양식에서 값을 null, undefined 등으로 정의해두지 말라고 한다. 그렇지 않으면 react는 실시간으로 값이 읽히지 않기 때문에 해당 값을 비제어 형식이라고 판단하고,오류가 발생하게 된다고 한다.

    참고 출저: (https://www.fullstackfoundations.com/blog/react-forms-best-practices)






해결방안


  • 사실 해결방안이라고 하기는 뭐하다. 일단 원인을 찾지 못했고, 해결도 못했으니깐. 다만 오류를 피하는 대처는 취했기에 일단은 이정도로만 만족하려고 한다.

  • 결론만 말하자면 register 객체를 통으로 props에 넘겨버렸다.




  • 그리고 해당 컴포넌트 내부에서 구조분해할당하여 사용했다. 이러니까 에러는 발생하지 않았다.
    (이유는 모르겠다. 객체로 묶지 않고 rest로 풀어서 보낼 때 다른 props들과 겹치면서 뭔가 오류가 난 걸지도...)
  • 위 코드를 보면, 현재 registerParms 객체에서 꺼낸 ref와 같이 전달한 subRef를 하나의 ref 속성에 할당한 것을 볼 수 있다. 필자도 처음 알게 된 이 방식은 ref의 콜백함수를 활용해서 구현할 수 있었다.




Ref 콜백함수


  • ref 객체는 내부에 콜백함수를 넣어줄 수 있는데 이 때 이 콜백함수에 props로 ref가 할당되어 참조하려는 해당 요소에 대한 값을 전달할 수 있다.
  • 즉 위 코드를 기준으로 보자면 el 매개변수가 곧 CustomSelector 컴포넌트에 대한 값이고, registerRef와 subRef에 각각 해당 요소를 할당해주게 되면서, ref 값을 참조할 수 있게 되는 것이다.


    +) 추가로 알게 된 사실이 있는데, ref 는 할당되어있는 컴포넌트가 완전히 렌더링 된 이후 호출된다는 것이다. 이 부분이 처음은 뭔가 와닿지 않았으나 한 블로그에서 정말 좋은 예시를 보고, 금방 이해하게 되었다.
function App() {
  const ref = React.useRef(null)

  React.useEffect(() => {
    // 🚨 이게 실행될 때 ref.current는 항상 null입니다.
    ref.current?.focus()
  }, [])

  return <Form ref={ref} />
}

const Form = React.forwardRef((props, ref) => {
  const [show, setShow] = React.useState(false)

  return (
    <form>
      <button type="button" onClick={() => setShow(true)}>
        show
      </button>
      // 🧐 ref가 input에 부착되어 있지만 조건부로 렌더링됩니다.
      // 그러므로 위쪽의 effect가 실행될 때, ref는 비어있을 겁니다.
      {show && <input ref={ref} />}
    </form>
  )
})
  • 위 코드를 보면 컴포넌트가 마운트 된 이후 안전하게 ref 객체에서 DOM에 접근해 해당 값에 포커싱을 할 것으로 추측된다. 그러나 아래 return 문을 보면 ref 가 달린 input은 조건부렌더링 된다.
    이는 즉 초기 렌더링 시에는 show 값이 false일 것임으로 input이 보이지 않고, 리렌더링 이후 보이게 되는데 문제는 useEffect 에 비어있는 의존성 배열로 인해 ref 로직은 처음 한 번만 실행되기에 결국 정상적으로 포커싱을 할 수 없게 된다.

  • 이런 상황에서 ref의 콜백을 이용할 수 있다. ref의 콜백으로 props를 전달하면 이 props는 ref가 참조하는 컴포넌트가 완전히 렌더링 되었을 때 내부로 전달되기에 완벽히 마운팅된 상태에서 안전하게 값을 받을 수 있게 된다.




// callback-ref-with-use-callback
const ref = React.useCallback((node) => {
  node?.focus()
}, [])

return <input ref={ref} defaultValue="Hello world" />

+) 다만 react는 매 렌더링 때마다 저 ref 콜백함수를 생성한다고 한다. 그래서 위처럼 useCallback 같은 기능을 이용해 함수 선언 이후 변화가 없다면 메모리에 캐싱해두었다가 꺼내서 사용하도록 하여 최적화를 진행하는 편이 좋다.

참고 블로그







이슈 4 - register 등록했지만...값이 들어오지 않는다

  • 이제 register 도 다 할당했고, form에 담긴 data를 Submit 하면 딱 깔끔하게 data가 들어올 것이라 기대했으나.. 값은 들어오지 않았다...




  • 앞서 useForm 세팅할 때 defaultValue 를 이렇게 공백으로 주었는데, 콘솔 로그상 공백이 나왔다는건 값이 변하지 않았다는 것, 즉 register가 제대로 먹히질 않는다는 것..!

  • 필자는 Category 항목을 React-Select 라는 셀렉트 라이브러리를 이용해서 값 설정을 해주고 있는데 아마 이 때문에 값이 제대로 들어가지 않은 듯 했다. 찾아보니 공식문서에서도 react-select, antd 같은 외부 라이브러리를 이용할 때는 순수 그 자체로는 해당 값들을 react-hook-form 이 컨트롤 할 수 없다고 한다.
    (아마도 각 라이브러리들은 들어오는 값을 내부로직으로 따로 처리하게 되어있기에 여기 접근하지 못하는 게 아닌가 추측)


    react-hook-form 공식문서 설명: https://www.react-hook-form.com/api/usecontroller/controller/






React-hook-Form 에서 제공하는 Controller를 사용하자


  • 그래도 방법은 있었다. react-hook-form 에서 자체적으로 제공하는 Controller를 이용해서 라이브러리 내부에서 해결되는 값에 접근하여 이를 조작이 가능했다.

  • 현재 useForm 을 import 한 상위컴포넌트에서 CUstomSelect로 controller 를 넘겨줘야 했는데, 부모에서 자식으로 바로 넘기면 되는 거지만 가급적 깔끔하게 해결하고 싶었다.
    또 그런 식으로 props를 넘겨준다면 다른 컴포넌트에서 똑같이 공통으로 사용하고있는 CountSelect, SizeSelect 또한 props를 일일히 넘겨줘야 하는데 이 부분이 비효율적이라 생각해서 대책을 찾아보았고, react-hook-form 에서 제공하는 FormProvider 로 이 문제를 해결할 수 있었다.




  • FormProvider 는 react의 ContextAPI 나 recoil 같은 타 전역상태관리 라이브러리 처럼 Provider로 useForm의 메서드를 공유할 컴포넌트를 감싸주면 된다.



  • 이후 useForm 훅을 methods 변수에 담은 다음 그대로 Provider 에 할당해주면 모든 준비는 끝난다.



  • 사용하고자 하는 컴포넌트에서 useFormContext를 호출하여 useForm의 모든 메서드들 (handleSubmit, register...)을 가져와서 쓸 수 있으며, 이 메서드들은 상위에 import 한 useForm과 그 상태를 공유할 수 있다.

  • 제공하는 Controller 컴포넌트로 연결하고자 하는 외부의 컴포넌트를 감싸준 후, Controller 에 control를 할당해주면 컴포넌트끼리 연결된 상태가 되어 상태값을 전달할 수 있게 된다.

  • 여기서 기존에 useForm 에서 props로 전달해서 쓰던 값들이 많아서 제법 난항을 겪었는데 결과적으로는 연결에 성공했다. 기존과 바뀐 점이 있다면 일단 register는 등록할 필요가 없는 듯 하다. Controller에 달린 name 이 아마 formData의 value의 key값이 되어 값의 여부를 인식하는 듯 하다.
    그래도 subRef 객체는 연결해야만 하기에 ref에 담아주었고 (안 하면 main Category 선택 시 sub Category 값 초기화를 못함) onChange 에도 기존에 쓰던 props로 넘긴 onChange를 그대로 할당했다.


  • 재밌는 점은 나의 커스텀 함수만 onChange에 바인딩하면 formData에 값이 제대로 담기지 않는다. 결국에는 저 render 속성의 field 가 모든 컨트롤 옵션을 포함하고 있기에 내 로직을 유지하기 위해 커스텀함수를 바인딩하면서, field 에서 제공하는 onChange도 바인딩 해줘야만 한다.

    이 부분에서 나름 고민을 좀 했다. 사실 onChange에 내 커스텀함수만 올리고 해당 메서드 내부에서 받아오는 select 값을 setValue 메서드로 formData에 담아줄 수는 있으나 이 방법은 익숙했기 때문에 다른 방식을 채택했다.




  • 연결이 끝난 후 테스트해보면 이제는 제대로 formData에 값이 담기는 것을 확인 가능하다.
    (객체 자체가 값으로 들어가고 있는데 저부분은 변경해야 할 듯)



    +a) react-hook-form 의 자주 쓰이는 고급 메서드들을 상세하게 설명해주는 고마운 글을 찾았다. 굉장히 정리를 잘 해 놓으셔서 이해하기도 쉽다. 혹 공식문서 독해에 어려움을 느끼신다면 여기서 한 번 훑어보시길

    React-Hook-Form 메서드 정리 블로그
profile
막 발걸음을 뗀 신입

0개의 댓글