최근 리액트로 프로젝트를 만드는 중인데, 혼자 진행하는 프로젝트이다보니 코드를 어떻게 더 이쁘게 짤 수 있을지, 더 나은 컴포넌트를 만들 수 있을지 고민을 지속하고 있었다.
그러던 중 Compound Component라는 개념에 대해 접하게 되었고 이를 활용하면 컴포넌트를 좀 더 우아하게 만들 수 있을 것 같아 여기에 공부한 내용을 정리하고 실제로 프로젝트에도 적용해보려고 한다
정확히는 Compound Component가 아닌 Compound Component Pattern이 맞다. 즉, 특정 컴포넌트를 말하는 것이 아닌 컴포넌트를 작성하는 디자인 패턴을 말한다.
요지는, 두 개 이상의 컴포넌트(주로 부모와 자식 컴포넌트)가 특정 작업을 수행하기 위해 함께 동작하고 있을 때, 부모 컴포넌트에게는 자식 컴포넌트와 통신을 좀 더 용이하게 하면서, 로직과 UI를 분리하는 리액트 디자인 패턴이다.
즉, 여러 개의 작은 컴포넌트들이 각각의 역할을 분담하도록 하고 이를 조립해 하나의 큰 컴포넌트를 만드는 것이다.
링크의 예시를 기준으로 정리해보려고 한다
위와 같은 이미지 목록의 각 이미지마다 FlyOut
컴포넌트를 추가해 유저가 이미지를 수정하고 삭제할 수 있도록 만드려고 한다.
FlyOut
컴포넌트는 다음과 같은 요소들을 가지고 있을 것이다.
FlyOut
wrapper : 토글 버튼과 리스트를 포함하는 wrapperToggle
button : List
를 토글List
: 메뉴 아이템을 포함위와 같은 동작을 하는 FlyOut
컴포넌트를 React의 Context API와 Compound component pattern을 활용해서 만들어보자
// FlyOut component
const FlyOutContext = createContext()
functoin FlyOut(props) {
const [open, toggle] = useState(false)
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{ props.children }
</FlyOutConext.Provider>
)
}
FlyOut
컴포넌트는 자식 컴포넌트에게 open
, toggle
value를 전달할 수 있다.
// Toggle component
function Toggle() {
const { open, toggle } = useContext(FlyOutContext)
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
)
}
Toggle
컴포넌트는 유저가 메뉴를 토글하기 위해 클릭할 수 있는 컴포넌트이다.
Toggle
컴포넌트가 FlyOutContext
provider에 접근할 수 있도록 하려면, FlyOut
컴포넌트의 자식 컴포넌트로 렌더해야 한다.
그러나, 자식 컴포넌트로 렌더링하는 방식 대신, Toggle
컴포넌트를 FlyOut
컴포넌트의 속성 중 하나로 만들 수 있다.
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
을 import 해서 사용할 수 있게 된다
import React from "react"
import { FlyOut } from "./FlyOut"
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
</FlyOut>
);
}
토글 동작을 통해 열리고 닫힐 List
컴포넌트 또한 필요하다
// List component
function List({ children }) {
const { open } = React.useContext(FlyOutContext)
return open && <ul>{ children }</ul>
}
function Item({ children }) {
return <li>{ children }</li>
}
List
컴포넌트는 open값에 따라 자식 컴포넌트를 렌더링한다
Toggle
컴포넌트와 같이 List
, Item
또한 FlyOut
컴포넌트의 속성으로 만든다
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 } = React.useContext(FlyOutContext)
return open && <ul>{ children }</ul>
}
function Item({ children }) {
return <li>{ children }</li>
}
FlyOut.Toggle = Toggle
FlyOut.List = List
FlyOut.Item = Item
위와 같은 코드를 통해 FlyOut
컴포넌트의 properties들을 사요할 수 있다.
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>
)
}
위와 같이 Compound Component Pattern과 React의 Context API를 함꼐 사용하여 컴포넌트 구조를 만들었다.
그러나 매번 context를 사용하기 보다, prop drilling을 피하기 위해 컴포넌트를 개선할 수 있는 상황에서 사용하면 좋은 방법이 될 것이다.
출처 : React Hooks: Compound Components
// Toggle.jsx
import { useRef, useState, useEffect, useCallback, createContext, useMemo, useContext } from 'react'
import { Switch } from './Switch'
// 1. 새로운 컨텍스트를 생성한다
const ToggleContext = createContext()
// 2. 컴포넌트가 마운트 된 후에 useEffect 콜백을 호출한다
function useEffectAfterMount(cb, dependencies) {
const justMounted = useRef(true)
useEffect(() => {
if (!justMounted.current) {
return cb()
}
justMounted.current = false
}, dependencies)
}
// 3. useState 훅을 사용하여 토글 상태를 관리한다.
// useCallback 훅을 사용하여 토글 함수를 메모이제이션한다
// 컴포넌트가 마운트 된 후에는 useEffectAfterMount 함수를 사용해
// 토글 상태가 변경될 때마다 props.onToggle 콜백을 호출한다
function Toggle(props) {
const [on, setOn] = useState(false)
const toggle = useCallback(() => setOn(oldOn => !oldOn), [])
useEffectAfterMount(
() => {
props.onToggle(on)
},
[on],
)
const value = useMemo(() => ({on, toggle}), [on])
return (
<ToggleContext.Provider value={value}>
{props.children}
</ToggleContext.Provider>
)
}
// 4. On, Off 컴포넌트는 Toggle 컴포넌트의 상태에 따라
// 자식 컴포넌트를 조건부로 렌더링한다
function On({children}) {
const {on} = useContext(ToggleContext)
return on ? children : null
}
function Off({children}) {
const {on} = useContext(ToggleContext)
return on ? null : children
}
function Button(props) {
const {on, toggle} = useContext(ToggleContext)
return <Switch on={on} onClick={toggle} {...props} />
}
Toggle.On = On
Toggle.Off = Off
Toggle.Button = Button
export default Toggle
생소했지만 막상 정리해보니 어려운 개념은 아니었다. 이제 실제 프로젝트에도 적용해보고 내 것으로 만들 차례이다
참고 문서
React Hooks: Compound Components
react.dev - Passing Data Deeply with Context
카카오 엔터테인먼트 기술 블로그 - 아토믹 디자인을 활용한 디자인 시스템 도입기
patterns.dev - Compound Pattern