레포지토리 설명
폼 데이터 관리 관리를 위한 도구들을 제공하는 react-hook-form
ISSUE 링크
PR링크
문제 상황
아래처럼 FormProvider 내부에 위치한 컴포넌트에서
export default function App() {
// ...
return (
<FormProvider {...methods}>
<MyForm />
</FormProvider>
);
}
JavaScript
복사
useForm에서 제공하는 setError를 useEffect내부에서 사용 시 에러메세지가 업데이트 되지 않는 문제점이 있었다.
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
복사
원인 파악
// 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
복사
createSubject 는 observer 패턴을 이용해서 옵저버들을 관리하고 있다. 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의 이슈에서도 비슷한 내용을 찾을 수 있었다.
// 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
복사