서론
컴포넌트를 개발하다보면 서로 독립적으로 선언된 컴포넌트가 관계를 갖는 경우가 있습니다.
html태그에서 select태그와 option태그의 관계를 예로 들 수 있습니다.
option은 select에 종속된 요소로 select요소의 클릭 상태를 공유받으며 이러한 상태 공유가 캡슐화되어 있습니다.
<select>
<option value="value1">key1</option>
<option value="value2">key2</option>
<option value="value3">key3</option>
</select>
이런 암묵적 관계의 표현을 리액트 컴포넌트에서 할 수 없을까요? 이러한 접근이 바로 Compond Pattern입니다. 이 패턴은 상위 컴포넌트와 하위 컴포넌트 간의 관계를 직관적으로 표현합니다.
아래 코드는 chakra-ui의 menu 컴포넌트 입니다. 첨부된 이미지의 UI를 상상해보세요!
<Menu>
<MenuButton as={Button} rightIcon={<ChevronDownIcon />}>
Actions
</MenuButton>
<MenuList>
<MenuItem>Download</MenuItem>
<MenuItem>Create a Copy</MenuItem>
<MenuItem>Mark as Draft</MenuItem>
<MenuItem>Delete</MenuItem>
<MenuItem>Attend a Workshop</MenuItem>
</MenuList>
</Menu>
menuList의 숨김 처리를 담당하는 isOpen같은 상태의 관리를 누가하고 있는 걸까요?
이렇게 상호 관계의 맥락을 함축적으로 표현할 수 있는 것이 Compond패턴의 특징입니다.
Compond 패턴에서는 부모 요소에서 컴포넌트의 상태를 관리하고 있고 자식 요소들이 상태를 구독하는 형태를 취하고 있습니다. (react에서 Compond 패턴의 구현을 위해 부모 요소와 자식요소와의 소통을 위해 ContextApi와 react.cloneElement를 이용해 상태를 전달할 수 있습니다.) Toggle과 Counter의 두 가지 예제를 통해 Compond 패턴으로 컴포넌트를 작성했을 때 어떤 점이 개선될 수 있는지 알아보도록 하겠습니다.
Example: Toggle
앞서 살펴본 Menu 컴포넌트와 유사한 Toggle 컴포넌트로 새로운 예제를 살펴보도록 하겠습니다.
아래와 같은 구조의 Toggle컴포넌트를 만드는 것이 목표입니다.
아래 구조에서 Toggle은 on/off의 상태를 관리하고 ToggleOn, ToggleOff,ToggleButton은 이 상태를 구독하고 있습니다.
자세한 동작이 궁금하시다면 샌드박스를 참고하세요!
function App() {
return (
<Toggle onToggle={(on) => console.log(on)}>
<ToggleOn>The button is on</ToggleOn>
<ToggleOff>The button is off</ToggleOff>
<ToggleButton />
</Toggle>
)
}
위 인터페이스에 대한 전체 코드입니다.
ToggleContext를 통해 하위 컴포넌트들이 하위 컴포넌트들이 상위 컴포넌트의 상태를 구독하고 있고 이것을 통해 컴포넌트 간의 관계를 명시하면서 표현력있는 코드를 작성할 수 있었습니다.
import * as React from 'react'
import { Switch } from '../switch'
const ToggleContext = React.createContext()
function Toggle(props) {
const [on, setOn] = React.useState(false)
const {onToggle} = props
const toggle = React.useCallback(() => setOn((oldOn) => {
onToggle(!oldOn)
return !oldOn
}), [])
const value = React.useMemo(() => ({ on, toggle }), [on])
return (
<ToggleContext.Provider value={value}>
{props.children}
</ToggleContext.Provider>
)
}
function useToggleContext() {
const context = React.useContext(ToggleContext)
if (!context) {
throw new Error(
`Toggle compound components cannot be rendered outside the Toggle component`,
)
}
return context
}
function ToggleOn({ children }) {
const { on } = useToggleContext()
return on ? children : null
}
function ToggleOff({ children }) {
const { on } = useToggleContext()
return on ? null : children
}
function ToggleButton(props) {
const { on, toggle } = useToggleContext()
return <Switch on={on} onClick={toggle} {...props} />
}
Example: Counter
이번엔 카운터 컴포넌트로 이야기를 해보겠습니다.
카운터 컴포넌트의 UI는 다음과 같습니다. 이 컴포넌트를 compound 패턴을 사용했을 때와 하나로 결합되었을 때를 비교해보겠습니다.
하나의 컴포넌트로 결합된 형태입니다. 디자인이 경직되어있는 형태죠.
다양한 요구사항에 대응하기 어렵고 기능이 추가될 수록 props가 증가하면서 관리하기 어려운 괴물 컴포넌트가 될 것입니다..
// 🚫 변화에 유연하지 못하고 각 디자인 use-case에 대응하기 위해 props가 늘어나게 된다.
return (
<Counter
label="Counter"
max={10}
iconDecrement={<Icon.Miunus/>}
iconIncrement={<Icon.Plus/>}
onChange={handleChange}
/>
)
아래는 Compound 패턴으로 리팩토링한 형태입니다. 각 내부 요소가 분리되어 있어 상황에 맞게 합성할 수 있고 배치를 변경하기 쉽습니다. 위 코드와의 차이점이 느껴지시나요? 변경에 훨씬 유연한 구조가 됐습니다. UI배치도 자유로울 뿐더러 각각의 하위 관심사가 하위 컴포넌트로 분리되어 기능의 확장이 발생하더라도 변경 범위가 제한적입니다. 새로운 요소가 추가된다고 하더라도 `Count.Somthing`이 추가되면 되겠네요!
// ✅ sub컴포넌트로 분리되어 있어 각 use-case에 유연하게 대응 가능하다
// ✅ 함수 조합 방식으로 커스터마이징이 쉽고 Counter 코드를 수정하지 않아도 된다.
return (
<Counter onChange={handleChange}>
<Counter.Decrement icon={<Icon.Miunus/>} />
<Counter.Label >Counter </Counter.Label>
<Counter.Counter max={10} />
<Counter.Increment icon={<Icon.Plus/>}/>
</Counter>
)
요구 조건에 따른 UI의 변화를 아래의 예제처럼 컴포넌트를 구성할 수 있습니다.
결론
Toggle, Counter 컴포넌트의 예제를 통해 Compound 패턴의 이점에 대해 알아봤습니다.
내부 상태를 contextAPI를 통해 부모 요소와 자식요소의 관계를 암묵적으로 표현할 수 있었고
UI 유연성을 얻을 수 있었습니다.
이를 통해 우리가 좋은 설계에 대해 이야기 할 때 빠지지 않고 이야기하는 확장에는 열려있고 변경에는 닫혀있는 변화에 유연하게 대처할 수 있는 구조로 개선할 수 있었습니다.
참고
https://patterns-dev-kr.github.io/design-patterns/compound-pattern/
https://kentcdodds.com/blog/compound-components-with-react-hooks
'react' 카테고리의 다른 글
[설계] 격리된 컴포넌트간의 통신 설계 (feat. Eventbus) (0) | 2024.06.28 |
---|---|
[Nextjs] next13에서 react-query의 필요성 (0) | 2024.06.27 |
[설계] 의존성 역전으로 변경에 유연한 설계하기 (0) | 2024.06.26 |
실무에서 가장 많이 실수하는 useEffect 사례 (0) | 2024.06.23 |
[설계] 대규모 React앱의 Multi Tab 통신 (0) | 2024.06.20 |