Compound 패턴
앱을 개발하다 보면 종종 서로를 참조하는 컴포넌트를 만들기도 한다. 컴포넌트들은 서로 상태를 공유하기도 하고 특정 로직을 함께 사용하기도 한다. 아마 이런 코드는 select
, 드롭다운 컴포넌트 또는 메뉴 컴포넌트에서 보았을 것이다. 컴파운드 컴포넌트 패턴은 여러 컴포넌트들이 모여 하나의 동작을 할 수 있게 해 준다.
Context API
아래 예제에서는 다람쥐 사진 목록을 보여주고 있다. 여기에 버튼을 추가하여 각각의 사진을 수정하거나 삭제하도록 하려고 한다. Flyout
컴포넌트를 구현하여 사용자가 메뉴를 누르면 토글할 수 있도록 할 수 있다.
Flyout
컴포넌트 내에서는 세가지 구현이 필요하다:
- 토글 버튼과 메뉴 리스트를 포함한
Flyout
래퍼 - 메뉴를 토글할 수 있는
Toggle
버튼 - 메뉴를 포함한
List
컴포넌트
React의 Context API 를 활용해 컴파운드 패턴을 활용하여 예제를 구현해 보자.
먼저 FlyOut
컴포넌트를 구현하자. 이 컴포넌트는 상태를 포함하고 또 자식 컴포넌트들이 받게 될 값을 FlyOutProvider
컴포넌트를 반환하고 있다.
const FlyOutContext = createContext()
function FlyOut(props) {
const [open, toggle] = useState(false)
const providerValue = { open, toggle }
return (
<FlyOutContext.Provider value={providerValue}>
{props.children}
</FlyOutContext.Provider>
)
}
위의 코드에서 메뉴가 열렸는지 여부를 나타내는 open
그리고 토글 가능한 toggle
함수를 상태로 포함한 FlyOut
컴포넌트를 만들었다.
이제 Toggle
컴포넌트를 만들어 보자. 이 컴포넌트는 사용자가 토글 버튼을 눌렀을 때 나타날 메뉴를 렌더링하고 있다.
function Toggle() {
const { open, toggle } = useContext(FlyOutContext)
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
)
}
Toggle
컴포넌트가 FlyOutContext
프로바이더에 접근할 수 있도록 해당 컴포넌트는 FlyOut
의 자식 컴포넌트로 렌더링해야 한다. 따라서 단순히 자식 컴포넌트로 렌더링 하면 되지만. 여기서는 FlyOut
컴포넌트의 Static property로 만들고 있다.
const FlyOutContext = createContext()
function FlyOut(props) {
const [open, toggle] = useState(false)
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
)
}
function Toggle() {
const { open, toggle } = useContext(FlyOutContext)
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
)
}
FlyOut.Toggle = Toggle
이렇게 하면 FlyOut
컴포넌트를 사용하는 쪽에서 토글 버튼이 필요한 경우라도 그냥 FlyOut
컴포넌트만 import하면 된다.
import React from 'react'
import { FlyOut } from './FlyOut'
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
</FlyOut>
)
}
토글 뿐만 아니라 메뉴가 화면에 나와야 한다. Toggle
과 같이 컨텍스트를 통해 상태를 가져와 처리할 수 있다.
function List({ children }) {
const { open } = React.useContext(FlyOutContext)
return open && <ul>{children}</ul>
}
function Item({ children }) {
return <li>{children}</li>
}
List
컴포넌트는 컨텍스트의 open
값에 따라 메뉴를 보여주거나 감추고 있다. 위의 두 컴포넌트도 마찬가지로 FlyOut
컴포넌트의 Static Property로 추가해 보자.
const FlyOutContext = createContext()
function FlyOut(props) {
const [open, toggle] = useState(false)
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
)
}
function Toggle() {
const { open, toggle } = useContext(FlyOutContext)
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
)
}
function List({ children }) {
const { open } = useContext(FlyOutContext)
return open && <ul>{children}</ul>
}
function Item({ children }) {
return <li>{children}</li>
}
FlyOut.Toggle = Toggle
FlyOut.List = List
FlyOut.Item = Item
지금까지 구현한 것들 모두 FlyOut
컴포넌트만 가지고 사용할 수 있다. 예제에서는 “수정” 메뉴와 “삭제” 메뉴를 제공해야 하므로. FlyOut.Item
을 두개 가진 FlyOut.List
컴포넌트를 사용하면 된다.
import React from 'react'
import { FlyOut } from './FlyOut'
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
)
}
FlyOutMenu
자체에는 아무런 상태를 가지고 있지 않다.
컴파운드 패턴은 컴포넌트 라이브러리를 만들 떄 유용하게 사용할 수 있다. 이런 패턴들을 Semantic UI 같은 오픈소스 라이브러리에서 이미 보았을 것이다.
React.Children.map
자식 컴포넌트들을 순회 처리 하는데에도 컴파운드 패턴을 사용할 수 있다.
React.cloneElement
를 사용하여 자식 컴포넌트를 복제하여 각각에게 open
과 toggle
메서드를 넘길 수 있다.
export function FlyOut(props) {
const [open, toggle] = React.useState(false)
return (
<div>
{React.Children.map(props.children, child =>
React.cloneElement(child, { open, toggle })
)}
</div>
)
}
모든 메뉴 컴포넌트들은 복제되며 open
, toggle
메서드를 전달받았다. 위에서 해당 값을 받기 위해 Context API를 사용했던 것에 비교하여 그냥 prop에서 두 값을 사용하면 된다.
장점
컴파운드 패턴은 동작 구현에 필요한 상태를 내부적으로 가지고 있는데 이 것을 사용하는 쪽에서는 드러나지 않아 걱정 없이 사용할 수 있다.
또 이 패턴을 사용하면 아래와 같이 자식 컴포넌트들을 일일히 import할 필요 없이 기능을 이용할 수 있다.
import { FlyOut } from './FlyOut'
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
)
}
단점
내부에서 React.Children.map
을 사용하고 있기 때문에 쓰는 쪽에서 자식 컴포넌트를 약속된 형태로 넘겨야 하는 제약이 생긴다.
export default function FlyoutMenu() {
return (
<FlyOut>
{/* This breaks */}
<div>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</div>
</FlyOut>
)
}
엘리먼트를 복제하는 경우. 복제 대상 컴포넌트가 기존에 갖고 있는 prop과 이름이 충돌될 수 있다. 이 경우 React.cloneElement
를 사용할 때 넘어간 값으로 해당 prop은 덮어써질 것이다.