이슈 1 - 한 컴포넌트에 두 개의 ref 속성이 들어가는 상황
이슈 2 - undefined 객체에 대한 구조분해할당은 불가능합니다
앞의 결합방식을 적용하기 위해 registerParms 에 register 객체의 속성들을 풀어서 전달하는데 위의 오류를 만났다. 꽤 오래 씨름을 했으나 해결법은 간단했다. (사실 문법적 에러라 해결책을 찾았다고 보기는 어렵다만..)
문제의 원인은 props로 넘겨올 때 {registerParms}
이런 식으로 구조분해할당이 된 상태를 다시 분해해서 값을 가져오려하다가 발생했다. 이미 객체를 풀었는데, 여기서 다시 구조분해를 하려고하니 undefined로 판단한 듯 하다.
이슈 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} />;
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 등록했지만...값이 들어오지 않는다
앞서 useForm 세팅할 때 defaultValue 를 이렇게 공백으로 주었는데, 콘솔 로그상 공백이 나왔다는건 값이 변하지 않았다는 것, 즉 register가 제대로 먹히질 않는다는 것..!
필자는 Category 항목을 React-Select 라는 셀렉트 라이브러리를 이용해서 값 설정을 해주고 있는데 아마 이 때문에 값이 제대로 들어가지 않은 듯 했다. 찾아보니 공식문서에서도 react-select, antd 같은 외부 라이브러리를 이용할 때는 순수 그 자체로는 해당 값들을 react-hook-form 이 컨트롤 할 수 없다고 한다.
(아마도 각 라이브러리들은 들어오는 값을 내부로직으로 따로 처리하게 되어있기에 여기 접근하지 못하는 게 아닌가 추측)
react-hook-form 공식문서 설명: https://www.react-hook-form.com/api/usecontroller/controller/
사용하고자 하는 컴포넌트에서 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를 그대로 할당했다.