회사에서 A님과 PR에 대해 얘기하던 도중, A님이 "제어권 역전 기법을 사용하려다, 적용하기 힘들어서 컨텍스트 API를 적용했다"는 얘기를 해주셨다. 제어권 역전 기법이 뭔지 궁금해서 더 물어봤고, 설명을 들었다. 이후, 이 기법을 내 코드에도 적용할 수 있을 것 같아서 시도해봤다.
기존 코드는 다음과 같은 구조를 가지고 있었다.
function DataProviderRegisterPage() {
// react-hook-form 을 이용해 데이터 제공자 양식 상태를 관리하는 녀석.
const { register, formState, setValue, watch, handleSubmit, control } =
useForm(...);
useFieldArray(...);
// tanstack query를 이용해 API를 요청하는 녀석
const { mutate, isLoading } = useMutation(...);
return (
<form
// submit 이벤트 시, 데이터 제공자를 등록한다.
onSubmit={handleSubmit((data) => {...})}
>
<DataProviderForm
// useForm 반환값을 prop으로 받고 있다.
register={register}
setValue={setValue}
watch={watch}
errors={errors}
// ...
/>
<DataProviderContractForm
// useForm 반환값을 prop으로 받고 있다.
register={register}
setValue={setValue}
watch={watch}
errors={errors}
control={control}
// ...
/>
<button>Submit</button>
</form>
);
}
DataProviderRegisterPage
컴포넌트의 useForm
훅의 반환값을 DataProviderForm
과 DataProviderContractForm
컴포넌트가 동일하게 받고 있다.
우선, form
태그를 DataProviderFormContainer
컴포넌트로 추상화했다.
function DataProviderFormContainer(props: DataProviderFormContainerProps) {
const { onSubmit, children } = props;
// 끌어내려진 상태들
const { register, formState, setValue, watch, handleSubmit, control } =
useForm(...);
useFieldArray(...);
const childrenList = Array.isArray(children) ? children : [children];
return (
<form onSubmit={handleSubmit(onSubmit)}>
{childrenList.map((child) => {
if (typeof child === "function") {
return child({
register,
formState,
setValue,
watch,
handleSubmit,
control,
});
}
return child;
})}
</form>
);
}
기존 코드에서 <form/>
관련 코드만 따로 떼어내서 새로운 추상화 컴포넌트인 DataProviderFormContainer
로 만들었다. 자연스럽게 DataProviderRegisterPage
컴포넌트에 있던 양식 관련 상태를 이 컴포넌트로 끌어내렸고, 고립시켰다. 그리고 만약 렌더링 가능한 자식 컴포넌트가 있다면, useForm
관련 값들을 prop 으로 주입해준다.
function DataProviderRegisterPage() {
const { mutate, isLoading } = useMutation(...);
function registerDataProvider(data) { ... }
return (
/* onSubmit 핸들러 함수를 주입해준다. */
<DataProviderFormContainer onSubmit={registerDataProvider}>
{/* 자식 컴포넌트도 외부에서 주입해주며, 추상화된 컨테이너는 어떤 자식이 올 지 신경쓰지 않는다. */}
{(childProps) => (
/* 익명함수 형태의 HOC를 이용해, 컨테이너로부터 여러가지 상태와 값들을 받아온다. */
<DataProviderForm
{...childProps}
key="data-provider-form"
defaultValues={defaultValuesForDataProvider}
/>
)}
{(childProps) => (
<DataProviderContractForm
{...childProps}
key="data-provider-contract-form"
isRegisterMode={true}
/>
)}
{/* 만약 컨테이너의 상태가 필요없다면, 컴포넌트를 바로 넣어준다. */}
<button>Submit</button>
</DataProviderFormContainer>
);
}
<form/>
요소를 추상화한 <DataProviderFormContainer/>
로 교체해 준 뒤, 필요한 prop을 주입해준다.
그리고 <form/>
요소의 자식들을 HOC 기법을 이용해 렌더링해준다.
하지만 DataProviderForm
, DataProviderContractForm
을 쓰는 다른 화면은 DOM 트리 구조가 매우 복잡해서 제어권 역전 기법을 동일하게 적용하기는 무리가 있었다. 😞
내가 생각하는 이 기법의 장단점은 다음과 같다.
현재 내 코드는 제어권 역전 기법으로 일반화의 효과를 크게 누리지 못한다. (자식 컴포넌트로 넣을 수 있는게 고만고만해서..)
children
에 익명함수 형태의 HOC가 포함되므로, Children API를 사용할 수 없다. (함수 형태의 자식은 Children API 내부에서 자동으로 걸러져서, UI가 렌더링되지 않는다.)
Children API를 사용하지 못하므로, 자식에게 일일이 key
속성을 할당해줘야 한다. (Children API 가 내부적으로 key 를 설정해준다.)
<Container>
{(childProps) => <A key="A" {...childProps} />}
{(childProps) => <B key="B" {...childProps} />}
{(childProps) => <C key="C" {...childProps} />}
</Container>
컨테이너의 “자식”들만 제어권 역전 및 의존성 주입이 가능하다. (깊이가 깊은 자식 컴포넌트가 있다면, 결국 그 자식 컴포넌트 내부에서 자손 컴포넌트까지 props drilling 이 발생할 수 있다.) (대체제로는 useRef, Context API 등이 있다.)
<Container>
{(childProps) => <A {...childProps} />}
</Container>
function A(props) {
return (
<B {...props} />
);
}
function B(props) {
return (
<C {...props} />
);
}
Compound Components In React - Smashing Magazine
Typescript(ing) React.cloneElement or how to type a child element with props injected by the parent