최근 구직 면접 중 리액트의 composition pattern에 관한 얘기를 나누게 되었습니다.
선호하시는 리액트 패턴이 있으실까요?
이에 주저하지 않고 Compound Components 패턴을 가장 선호한다고 답했습니다. 이 패턴은 다음과 같은 장점이 있습니다.
// ex
<Modal
isVisible={isVisible}
setIsVisible={setIsVisible}
>
<Modal.Header>헤더</Modal.Header>
<Modal.Body>blablabla</Modal.Body>
</Modal>
위 코드를 만약 일반적인 방법으로 구현했다면
// ex
<Modal
isVisible={isVisible}
setIsVisible={setIsVisible}
header={'헤더'}
content={'blablabla'}
/>
와 같은 형태가 되어 props으로 모든 데이터를 넘겨줘야하고,
무엇보다도 부모 컴포넌트 내에서 자식 컴포넌트에게 데이터를 넘겨주며 렌더링 해주어야 합니다.
이후 자식컴포넌트 props를 수정하게 될 경우 부모컴포넌트의 수정 또한 불가피해지게 됩니다.
// ex
<Modal isVisible={isVisible}>
<Modal.Header>헤더</Modal.Header>
<Modal.Body>blablabla</Modal.Body>
<Modal.Footer>blablabla</Modal.Body>
</Modal>
// or
<Modal isVisible={isVisible}>
<Modal.Body>blablabla</Modal.Body>
</Modal>
마크업 구조가 자유로워 진다는 것이 이 패턴의 장점이자 아쉬운 점입니다. 자유롭기에 아래와 같이 의도치 않은 코드가 작동할 수 있습니다.
// 스타일이나 로직상 필요한 wrapper 컴포넌트 없이 사용하기
<ModalHeader>헤더</Modal.Header>
// 자식 컴포넌트의 순서가 중요한 경우에도 무시하기
<Modal isVisible={isVisible}>
<Modal.Footer>blablabla</Modal.Body>
<Modal.Body>blablabla</Modal.Body>
<Modal.Header>헤더</Modal.Header>
</Modal>
// 자식 안넘겨주기
<Modal isVisible={isVisible}>
</Modal>
// 의도하지 않은 자식 내려주기
<Modal isVisible={isVisible}>
ㅇㅅㅇ
</Modal>
이와 같은 단점들을 코드선에서 해결하지 못한다면, 개발자들간의 규칙을 숙지하는 것에 의존하게 되는 아쉬움이 생기고, 실수로 이어지기 쉽습니다.
아쉬운 방법이지만 부모와 자식 컴포넌트 코드의 합이 길지 않을 때 제법 유용한 방법입니다.
export에 제한을 두는 방법으로, 자바의 private, protected 클래스처럼 접근 범위를 지정할 수 없을까 하는 생각에 기인해본 방법입니다.
const ModalHeader = () => {}
const ModalBody = () => {}
const Modal = () => {}
Modal.Header = ModalHeader
Modal.Body = ModalBody
export default Modal
// 사용예시
<Modal>
// Unresolved component ModalHeader
<ModalHeader></ModalHeader>
<Modal.Header></Modal.Header>
</Modal>
자식 컴포넌트를 별도로 export 하지 않았으므로, 부모의 instance로써 자식에 접근하지 않는 이상 자식 컴포넌트에 접근할 수 없게 하였습니다. 하지만 이는 한 파일 안에서 모든 컴포넌트를 작성해야 하고, 코드 길이는 별개로 치더라도 여러 수정사항이 한 파일에 지속적으로 영향을 준다는 단점이 있습니다.
(범위가 제한된 export가 가능했으면 좋겠습니다..)
타입스크립트를 사용하고 있다면, props의 children에 primitive 타입, 혹은 아래와 같은 타입을 지정하므로써 이를 어느정도 막을 수 있습니다.
type Props = {
children?: ReactNode
}
가장 편리한 방법으로써 리액트 엘리먼트, primitive 타입, 프레그먼트등을 넘겨줄 수 있습니다. (가장 유연하여 편리하지만 타입 제한의 기능으로는 아쉽습니다)
type Props = {
children?: JSX.Element | JSX.Element[]
}
JSX.Element는 primitive 타입을 제외하고 하나의 리액트 엘리먼트를 나타내는 타입입니다. 다수의 리액트 엘리먼트를 허용하고 싶으면 위와같이 JSX.Element[] 타입을 추가하면 됩니다.
가장 바라는 점은 사실 다음과 같았습니다.
type ModalProps = {
children?: typeof ModalHeader | typeof ModalBoxy ...
}
// ...
return (
<Modal>
<Alert> // throw Error Alert는 children 타입이 아닙니다.
...
</Alert>
</Modal>
)
위와 children 타입으로 특정 자식 컴포넌트들로 제한할 수 있다면 컴파운드 패턴의 아쉬운 점들을 해결할 수 있을 것 같습니다. 타입스크립트가 이를 지원해준다면 컴파운드 패턴의 자유성에서 비롯한 오사용 케이스들을 잡아주면서 더욱 매력적인 패턴이 될 것 같습니다. 하지만 오래전부터 이러한 아쉬움에 대한 요구가 있어왔지만 아직까지 진행된 바 없습니다.
typescript 단에서 컴포넌트 타입 체크가 되지 않으므로, 자식컴포넌트 타입에 대한 엄격한 제한이 필요하다면 React.Children의 함수들로 다음과 같이 작업할 수 있겠습니다.
import {Children, ReactNode} from "react";
import ChildComponent1 from './ChildComponent1'
type Props = {
children: ReactNode
}
const ParentComponents = ({children}: Props) => {
// 리액트 내장함수로써 children을 순회하는 함수
Children.forEach((children), (child) => {
if (child.type !== ChildComponent1) {
throw Error('허용된 children 타입이 아닙니다')
}
})
// ...
return (
<div>
{children}
</div>
)
}
위 코드로 자식컴포넌트 타입을 체크할 수 있었습니다. typescript가 아니기에 정적 타이핑으로는 불가하지만, 데브 환경 제약을 두고 비교 로직을 수행하는 식으로 작업하면 제법 쓸만할 것 같습니다.
위 예시 외에도 React.Children내 함수들을 사용하여 자식들의 갯수를 제한하거나, 순서를 체크하거나, 특정 자식 컴포넌트는 무조건 있어야 한다는 식의 코들를 작성하는 식으로써도 활용할 수 있겠습니다.
이상으로 저의 최애 리액트 패턴인 Compound 패턴의 장단점에 대해 살펴보고 개인적인 해결법을 적어보았습니다. typescript와 javascript의 발전으로 저의 우회법이 아닌 공식적인 타입 체크가 가능한 날이 왔으면 하는 바람입니다. 또 다른 해결법을 가지고 계시다면 댓글로 남겨주시면 감사하겠습니다. 긴 글 읽어주셔서 감사합니다.
(잡담)면접은 떨어졌습니다ㅎ;
https://github.com/microsoft/TypeScript/issues/21699?ref=arahansen.com
https://www.carlrippon.com/react-children-with-typescript/
https://react.dev/reference/react/Children