Search
⚠️

(작성 중) fix: FormProvider 사용 시 useEffect 내부에 있는 setError가 동작하지 않는 버그 수정

PR링크
레포
react-hook-form
상태
release 완료
유형
동작 수정
테스트코드 추가

레포지토리 설명

폼 데이터 관리 관리를 위한 도구들을 제공하는 react-hook-form

ISSUE 링크

12632
issues

PR링크

문제 상황

아래처럼 FormProvider 내부에 위치한 컴포넌트에서
export default function App() { // ... return ( <FormProvider {...methods}> <MyForm /> </FormProvider> ); }
JavaScript
복사
useForm에서 제공하는 setErroruseEffect내부에서 사용 시 에러메세지가 업데이트 되지 않는 문제점이 있었다.
function MyForm() { // ... useEffect(() => { // setError를 사용해도 에러 메세지가 추가되지 않음 setError("firstname", { type: "manual", message: "This is an error" }); }, [setError]); return ( // ... <input {...register("firstname")} placeholder="firstname" /> {errors.firstname && <p>errors.firstname.message</p>} // ... ); }
TypeScript
복사

원인 파악

useForm의 동작을 확인하기 위해 useForm.ts 파일의 내용을 확인해보았다. useForm 내부에서 _subscribe 함수를 사용해
// src/useForm.ts export function useForm< // ... React.useEffect( () => control._subscribe({ formState: control._proxyFormState, callback: () => updateFormState({ ...control._formState }), reRenderRoot: true, }), [control], ); // ...
TypeScript
복사
createFormControl.ts 파일에서 subscribe 함수는 _subjects 객체에 들어있고, _subjects 객체는 createSubject 함수를 통해서 생성되고 있다.
// src/logic/createFormControl.ts const _subscribe: FromSubscribe<TFieldValues> = (props) => _subjects.state.subscribe({ // ... }).unsubscribe;
TypeScript
복사
// src/logic/createFormControl.ts const _subjects: Subjects<TFieldValues> = { array: createSubject(), state: createSubject(), };
TypeScript
복사
createSubjectobserver 패턴을 이용해서 옵저버들을 관리하고 있다. subscribe 함수를 이용해 새로운 옵저버를 배열에 추가하고 이벤트 발생 시 next 함수를 이용해 모든 옵저버들에게 값을 전달하여 실행할 수 있도록 구성되어 있다.
// src/utils/createSubject.ts export default <T>(): Subject<T> => { let _observers: Observer<T>[] = []; const next = (value: T) => { for (const [index, observer] of _observers.entries()) { observer.next && observer.next(value); } }; const subscribe = (observer: Observer<T>): Subscription => { _observers.push(observer); return { unsubscribe: () => { _observers = _observers.filter((o) => o !== observer); }, }; }; const unsubscribe = () => { _observers = []; }; return { get observers() { return _observers; }, next, subscribe, unsubscribe, }; };
TypeScript
복사
const _subscribe: FromSubscribe<TFieldValues> = (props) => _subjects.state.subscribe({ next: ( formState: Partial<FormState<TFieldValues>> & { name?: InternalFieldName; values?: TFieldValues | undefined; }, ) => { if ( shouldSubscribeByName(props.name, formState.name, props.exact) && shouldRenderFormState( formState, (props.formState as ReadFormState) || _proxyFormState, _setFormState, props.reRenderRoot, ) ) { props.callback({ values: { ..._formValues } as TFieldValues, ..._formState, ...formState, }); } }, }).unsubscribe;
TypeScript
복사
React.useLayoutEffect( () => control._subscribe({ formState: control._proxyFormState, callback: () => updateFormState({ ...control._formState }), reRenderRoot: true, }), [control], );
TypeScript
복사

현재 로직의 문제점

현재 로직의 문제는 개별 컴포넌트에서 실행되는 useEffect의 콜백이
function MyForm() { // ... useEffect(() => { // setError를 사용해도 에러 메세지가 추가되지 않음 setError("firstname", { type: "manual", message: "This is an error" }); }, [setError]); return ( // ... ); }
TypeScript
복사

해결방안

앞서 설명한 모든 과정은 useForm 훅 내부에 있는 useEffect를 통해 실행되고 있다. 결국 useEffect의 실행순서는 React의 동작이므로 useEffect를 사용하는 한 순서는 동일할 것이고, 옵서버가 등록되지 않은 상태에서 setError가 실행될 것이다.
그래서, 기존의 useEffect 보다 옵서버의 실행순서를 더 빠르게 진행하기위해 useLayoutEffect를 사용해서 변경해 보았다.
// src/useForm.ts export function useForm< // ... React.useLayoutEffect( () => control._subscribe({ formState: control._proxyFormState, callback: () => updateFormState({ ...control._formState }), reRenderRoot: true, }), [control], ); // ...
TypeScript
복사
예상대로 부모(useFrom)의 effect가 자식(개별 컴포넌트)의 effect보다

추가 탐구

React 공식문서 useEffect 항목에서는 부모와 자식요소의 상황에서 어떤 effect가 먼저 실행되는지에 대한 구체적인 내용을 찾을 수는 없었다. 다만, 자식 컴포넌트에서의 useEffect가 부모요소의 useEffect 보다 일찍 실행되는 부분에 대해서는 과거 React의 이슈에서도 비슷한 내용을 찾을 수 있었다.
나와 비슷한 상황에서 부모요소가 먼저 랜더링 되는 순서에 따라 useEffect 또한 먼저 실행하고 싶은 상황에 대한 토론이 이어지고 있었다. 그 중에서 눈에 띄었던 것은 자식 컴포넌트의 useEffect가 먼저 실행되는 것에 착안해서 부모요소의 effect를 먼저 실행하기 위한 별도의 컴포넌트를 만들어 실행순서를 보장하는 방법이 흥미로웠다.(댓글 링크, gist 링크)
// Sometimes you want to run parent effects before those of the children. E.g. when setting // something up or binding document event listeners. By passing the effect to the first child it // will run before any effects by later children. export function Effect({ effect }) { useEffect(() => effect?.(), [effect]); return null; } // // Use it like this: // function Parent() { const stableEffect = useCallback(() => doStuff(), []); return ( <> <Effect effect={stableEffect} /> <SomeChild /> </> ); }
JavaScript
복사
또한, 커리어리 글을 보면서 useEffect의 실행 순서에 대해 더 정확히 이해할 수 있었다. render phase에서 부모 요소부터 실행되며 fiber 객체 내부 effect list queue에 담긴 effect 함수는 commit 단계에서 선입선출 로직에 따라 실행된다는 것이다.

느낀점

리액트의 라이프사이클의 중요성을 더 느끼게 되었다. 특정적인 구현이나 동작을 위해서는 동작원리에 대한 이해가 중요한 것 같다.
useEffect의 return 함수를 통해 unmount 시에 바로 unsubscribe 함수가 실행되도록 작성한 것도 새로운 부분이었다. 메모리 누수를 막기위해 불필요한 내용은 바로 삭제되도록 subscribe 함수를 void 형태로 작성하지 않고 바로 unsubscribe 함수가 리턴되도록 작성 된 부분이 보기 좋았다. 따로 신경을 쓰지 않아도 구독해제가 이루어지기 때문에 바람직하다는 생각이 들었다. 나도 프로그램을 작성할 때 메모리 누수에 대해 더 신경써야 겠다는 생각이 들었다.
React.useEffect(() => control._subscribe({ formState: control._proxyFormState, callback: () => { updateFormState({ ...control._formState }); }, reRenderRoot: true, }), [control], );
JavaScript
복사
const subscribe = (observer: Observer<T>): Subscription => { // ... return { unsubscribe: () => { _observers = _observers.filter((o) => o !== observer); }, }; };
JavaScript
복사