오래 미뤄왔던 블로그를 다시 끄적끄적 하려고합니다..! Vue를 개발하면서 React의 디자인 패턴을 Vue에서도 사용하면 좋겠다는 생각이 들어 React에서 자주 사용하는 패턴을 Vue로 옮겨보려고합니다. 오늘은 그 중에서도 가장 많이 사용되는 compound component pattern을 옮겨보겠습니다!!
Compound Component Pattern은 React, Vue와 같은 라이브러리에서 하나의 컴포넌트를 여러 개의 하위 컴포넌트로 나누되, 상호 의존성을 자연스럽게 유지하기 위한 디자인 패턴입니다.
<RadioGroup name="fruit">
<Radio value="apple" onChange={...} checked={true} />
<Radio value="banana" onChange={...} checked={false} />
</RadioGroup>
위는 Compound Component 패턴을 적용하지 않았을 때의 모습입니다. 위 방식으로 구현한다면 Radio 컴포넌트에 onChange, checked를 일일이 넘겨줘야 해서 props drilling 문제가 발생할 수 있습니다. 이러한 반복적인 props를 전달하는 상황이 생길 때 Compound Component 패턴이 해결책이 될 수 있습니다.
Compound Component의 핵심이 부모를 통해 자식에게 상태를 '보이지 않게 전달한다'이기 때문입니다. 부모에게 필요한 상태, 메서드를 전달하고 하위 요소에 전달하는 방식으로 구현합니다. 이를 위해 React는 Context API를 Vue에서는 provide/inject 기능을 사용합니다.
import { createContext, useContext, type ReactNode } from "react";
/**
* RadioGroup 내부에서 Radio들이 공유하는 상태(Context)
* - name: input name 속성 (같은 그룹임을 표시)
* - value: 현재 선택된 라디오의 값
* - onChange: 선택이 변경될 때 실행되는 함수
*/
interface RadioGroupContextProps {
onChange:(value: string) => void;
name: string;
value: string;
}
interface RadioGroupProps extends RadioGroupContextProps {
children: ReactNode // 하위 Radio Component
className?: string;
defaultValue?: string;
}
const RadioGroupContext = createContext<RadioGroupContextProps | null>(null);
export function RadioGroup({name, value, onChange, children, className}: RadioGroupProps) {
return (
<RadioGroupContext.Provider value={{name, value, onChange}}>
<fieldset className={className}>
{children}
</fieldset>
</RadioGroupContext.Provider>
)
}
export const useRadioGroup = () => {
const radioGroupContext = useContext(RadioGroupContext);
if(!radioGroupContext) throw Error('Radio는 Group안에서만 사용할 수 있습니다.');
return radioGroupContext;
}
Radio Group에서는 Context API를 통해 부모 요소에서 받은 Props를 하위 요소에게 전달하는 역할을 수행하며 공통 로직에 대한 처리를 담당합니다.
import { type ReactNode } from "react";
import { useRadioGroup } from "./group/RadioGroup";
interface RadioProps {
disabled?: boolean;
className?: string;
defaultChecked?: boolean;
children: ReactNode;
value: string;
}
function Radio({ children, value, disabled, defaultChecked=false }:RadioProps) {
const group = useRadioGroup();
return (
<label>
<input
type="radio"
value={value}
name={group.name}
defaultChecked={defaultChecked}
disabled={disabled}
checked={group.value !== undefined ? value === group.value : undefined}
onChange={(e) => group.onChange && group.onChange(e.target.value)}
/>
{children}
</label>
);
}
export default Radio;
Radio 컴포넌트에서는 Context API에서 받은 값과 Props로 받은 요소들을 조합하여 컴포넌트 값을 바인딩합니다. 이런 방식으로 구현하면 Radio 컴포넌트에 많은 컴포넌트가 생기는 현상을 방지할 수 있어 가독성이 높아지고 확장성이 향상됩니다.
import { useState } from 'react'
import { RadioGroup } from './components/group/RadioGroup'
import Radio from './components/Radio'
function App() {
const [fruit, setFruit] = useState('');
return (
<>
<RadioGroup name="tools" value={fruit} onChange={(value:string) => setFruit(value)}>
<Radio value="email">이메일</Radio>
<Radio value="phone">전화</Radio>
</RadioGroup>
</>
)
}
export default App
<script setup lang="ts">
import { provide } from 'vue';
// Context 타입 정의
export interface RadioGroupProvideProps {
name: string;
value: string
onChnage:(value: string) => void
}
// Props 정의
interface RadioGroupProps {
name: string;
value: string
className?: string
}
interface RadioGroupEmits {
(e: 'change',value:string): void;
}
const props = defineProps<RadioGroupProps>()
const emits = defineEmits<RadioGroupEmits>()
// inject의 키와 동일해야함.
provide<RadioGroupProvideProps>('RadioGroup', {
name: props.name,
value: props.value,
onChnage:(value: string) => emits('change', value)
})
</script>
<template>
<fieldset :class="className">
<slot />
</fieldset>
</template>
리액트와 비슷하게 Vue 또한 부모 컴포넌트에서 props 외에 데이터를 전달하는 방법이 있습니다. Provide/inject 문법입니다. 부모 요소에서 provide를 설정하고 inject로 가져오는 방식입니다. 이 때, 각 매개변수의 첫번째는 식별자입니다. provide/inject의 키 값을 동일하게 맞춰야 사용할 수 있습니다. 때문에 보통 변수로 Symbol 변수로 선언하고 다른 파일에서 import 하는 방식으로 자주 사용합니다.
<script setup lang="ts">
import { inject } from 'vue';
import type { RadioGroupProvideProps } from './group/RadioGroup.vue';
interface RadioProps {
value: string;
disabled?: boolean;
className?: string;
}
const group = inject<RadioGroupProvideProps>('RadioGroup');
const props = withDefaults(defineProps<RadioProps>(), {
disabled: false,
className: ''
})
// Context 가져오기 (없으면 에러)
if (!group) {
throw new Error('Radio는 RadioGroup 안에서만 사용할 수 있습니다.');
}
</script>
<template>
<label :class="props.className">
<input
type="radio"
:value="value"
:name="group.name"
:checked="props.value === group.value"
:disabled="disabled"
@change="() => group.onChnage(props.value)"
/>
<slot />
</label>
</template>
자식 컴포넌트는 React와 매우 흡사합니다. inject로 부모 요소의 값을 받는다 외에는 동일하게 보여집니다.
<script setup lang="ts">
import { ref } from 'vue';
import RadioGroup from './components/group/RadioGroup.vue';
import Radio from './components/Radio.vue';
// React의 useState와 동일
const fruit = ref<string>('');
</script>
<template>
<RadioGroup
name="tools"
:value="fruit"
:onChange="(value: string) => (fruit = value)"
v-slot="{Radio}"
>
<Radio value="email">이메일</Radio>
<Radio value="phone">전화</Radio>
</RadioGroup>
</template>
선언 방식 또한 문법의 차이일 뿐 방식은 동일합니다.
오늘은 React와 Vue의 Compound Component Pattern을 직접 구현하였습니다. 부모에서 자식 요소에게 데이터를 전달하는 방식 자체는 Vue가 더 편한것처럼 보이지만 타입의 props, emits 등 다양한 선언으로 인해 타입과 코드의 길이가 좀 더 늘어나는 것 같습니다. 글 읽어주셔서 감사합니다!