리액트 디자인 패턴 2가지

Namu CHO
13 min readSep 13, 2023

렌더 프롭스 패턴, 컴파운드 컴포넌트 패턴

이미지 출처: https://aglowiditsolutions.com/blog/react-design-patterns/

https://www.udemy.com/course/the-ultimate-react-course

이 글은 허가를 받고 위 강의 중 섹션 28. Advanced React Patterns 강의 내용을 베이스로 작성한 글입니다.

1. 렌더 프롭스 패턴 Render Props pattern

컴포넌트가 어떤 것을 어떻게 렌더링할지에 대한 주도권을 갖는 패턴입니다.

대표적인 예시로 리스트를 렌더링하는 컴포넌트 코드를 작성해야 하는 상황이 있습니다.

렌더 프롭스 패턴을 사용하지 않을 경우 아래와 같은 코드로 요구사항을 구현할 수 있습니다.

// 렌더 프롭스 패턴이 아닌 코드


// ...

function RpsList({ list }) {
return (
<ul className="list">
// RpsList에서 직접 map을 이용하여 아이템 리스트를 렌더링합니다.
{list.map((data) => {
return <RpsItem key={data.companyName} data={data} />;
})}
</ul>
);
}

export default function RenderPropsPattern() {
return (
<div>
<div>render props pattern</div>
<RpsList list={DUMMY_COMPANY_LIST} />
</div>
);
}

위의 코드를 렌더 프롭스 패턴을 이용하여 작성하면 아래와 같습니다.

// ...

function RpsList({ render, list }) {
return <div>{<ul className="list">{list.map(render)}</ul>}</div>;
}

export default function RenderPropsPattern() {
return (
<div>
<div>render props pattern</div>
<RpsList
list={DUMMY_COMPANY_LIST}
// 데이터와 함께 어떻게 데이터를 렌더할지 알려주는 렌더 함수도 함께 프롭스로 넘겨줍니다.
render={(data) => {
return (
<RpsItem
key={data.companyName}
data={data}
render={(data) => {
return <RpsItem key={data.companyName} data={data} />;
}}
/>
);
}}
/>
</div>
);

이제 RpsList는 어떻게 UI를 렌더링할지 모르고 (내부에 렌더링 하는 코드를 가지고 있지 않고) RpsList를 ‘사용'하는 유저 컴포넌트가 요구한 렌더 방법에 따라 UI를 렌더링합니다.

따라서 렌더 프롭스 패턴은 Inversion of control를 충족하는 패턴입니다.

또한 이제 RpsList에 다른 데디터이터와 렌더 함수를 넘겨주기만 하면 다른 리스트를 렌더링 할 수 있으므로 렌더 프롭스 패턴은 재사용성 향상에 도움이 됩니다.

2. 컴파운드 컴포넌트 패턴 Compound component pattern

마치 Select과 Option 컴포넌트들과 같이 부모와 자식컴포넌트의 조합으로 이루어진 복잡한 컴포넌트를 작성할 때 사용하는 패턴입니다.

이때 부모와 자식 컴포넌트 간의 상태 공유를 할 때 React Context API를 사용하고, 실제로 컴파운드 컴포넌트 패턴은 React Context API의 가장 좋은 예시 중 하나로 꼽힙니다.

컴파운드 컴포넌트 패턴으로 코드를 작성하기 위해서는 기본적으로 4단계의 과정을 거칩니다.

1. 컨텍스트를 생성합니다.
2. 부모 컴포넌트 코드를 작성합니다.
3. 자식 컴포넌트 코드를 작성합니다.
4. 자식 컴포넌트를 부모컴포넌트의 프로퍼티로 할당합니다.

먼저 간단히 카운터를 컴파운드 컴포넌트 패턴을 이용하여 만들어보겠습니다.

위의 4단계를 통해 카운터 컴포넌트를 만들어보겠습니다.

// Counter code

import { createContext, useContext, useState } from "react";

// 1. 컨텍스트를 생성합니다.
const CounterContext = createContext();

// 2. 부모 컴포넌트 코드를 작성합니다.
function Counter({ children }) {
const [count, setCount] = useState(0);
const increase = () => setCount((c) => c + 1);
const decrease = () => setCount((c) => c - 1);

return (
<CounterContext.Provider value={{ count, increase, decrease }}>
<span>{children}</span>
</CounterContext.Provider>
);
}

// 3. 자식 컴포넌트 코드를 작성합니다.
function Count() {
const { count } = useContext(CounterContext);
return <span>{count}</span>;
}

function Label({ children }) {
return <span>{children}</span>;
}

function Increase({ icon }) {
const { increase } = useContext(CounterContext);
return <button onClick={increase}>{icon}</button>;
}

function Decrease({ icon }) {
const { decrease } = useContext(CounterContext);
return <button onClick={decrease}>{icon}</button>;
}

// 4. 자식 컴포넌트를 부모컴포넌트의 프로퍼티로 할당합니다.
Counter.Count = Count;
Counter.Label = Label;
Counter.Increase = Increase;
Counter.Decrease = Decrease;

export default Counter;

이제 만든 카운터 컴포넌트를 사용해보겠습니다.

// App.js

export default function App() {
return (
<div className="App">
<Counter>
<Counter.Label>완전 플렉서블한 카운터가 완성되었습니다.</Counter.Label>
<Counter.Decrease icon="-" />
<Counter.Count />
<Counter.Increase icon="+" />
</Counter>
</div>
);
}
// 이런 식으로도 사용할 수 있습니다.

export default function App() {
return (
<div className="App">
<Counter>
This is the Count Value: <Counter.Count />
<p>
<Counter.Label>
완전 플렉서블한 카운터가 완성되었습니다.
</Counter.Label>
</p>
<Counter.Decrease icon={<span>Decrease</span>} />
<Counter.Increase icon={<span>Increase</span>} />
</Counter>
</div>
);
}

카운터의 라벨, 버튼 위치, 스타일링 등 매우 유연하게 변경하여 사용할 수 있습니다.

이제 조금 더 복잡한 예시로 컴파운드 컴포넌트 패턴을 이용하여 모달을 만드는 법을 알아보겠습니다.

모달 컴포넌트의 경우 모달 안의 컨텐츠, visibility 등의 상태를 컴포넌트 자체에서 관리할 수 있도록 상태를 추상화하여 가지게 하는 것이 좋기 때문에 컴파운드 컴포넌트 패턴을 사용하여 만드는 예시로 매우 적합합니다.

// 모달 컴포넌트 코드를 작성합니다.

import { cloneElement, createContext, useContext, useState } from "react";
import { createPortal } from "react-dom";

// 1. 컨텍스트를 생성합니다.
const ModalContext = createContext();

// 2. 부모 컴포넌트 코드를 작성합니다.
function Modal({ children }) {
const [openName, setOpenName] = useState("");

const close = () => setOpenName("");
const open = setOpenName;

return (
<ModalContext.Provider value={{ openName, close, open }}>
{children}
</ModalContext.Provider>
);
}

// 3. 자식 컴포넌트 코드를 작성합니다.
function Open({ children, opens: opensWindowName }) {
const { open } = useContext(ModalContext);

return cloneElement(children, { onClick: () => open(opensWindowName) });
}

function Window({ children, name }) {
const { openName, close } = useContext(ModalContext);

if (name !== openName) return null;

return createPortal(
<div>
<div>
<button onClick={close}>X</button>

<div>{cloneElement(children, { onCloseModal: close })}</div>
</div>
</div>,
document.body
);
}

// 4. 자식 컴포넌트를 부모컴포넌트의 프로퍼티로 할당합니다.
Modal.Open = Open;
Modal.Window = Window;

export default Modal;

카운터와 비슷한 형태로 코드를 작성하였습니다.

카운터 예시와 다른 점은

카운터의 경우 클릭 등의 이벤트가 모두 카운터 컴포넌트 내에서 일어났다면,

모달의 경우 모달의 visibility를 visible로 만드는 트리거를 일으키는 UI가 모달 밖에 있다는 것입니다.

따라서 이 외부에 있는 UI에 온클릭 이벤트를 전달해주는 방법이 필요한데, 그 방법이 바로 cloneElement입니다.

function Open({ children, opens: opensWindowName }) {
const { open } = useContext(ModalContext);

// children을 복사하여 원하는 온클릭 이벤트를 추가한뒤 리턴합니다.
return cloneElement(children, { onClick: () => open(opensWindowName) });
}

이제 위 Open 자식 컴포넌트 덕에 모달을 보여줄 수 있게 되었습니다.

export default function App() {
return (
<div className="App">
<Modal>
{/* 아무 이벤트가 없는 버튼을 넘기는 것을 확인 할 수 있습니다.
이 버튼 엘리먼트를 Open 컴포넌트에서 cloneElement를 통하여
온클릭이벤트를 붙인 엘리먼트를 리턴하여 사용합니다. */}
<Modal.Open opens="modal-form">
<button>모달 열기</button>
</Modal.Open>
<Modal.Window name="modal-form">
{/* 모달 안에 보여줄 폼 컴포넌트입니다. */}
<Form />
</Modal.Window>
</Modal>
</div>
);
}

모달을 보여주는 (여는) 것과 마찬가지로

이제 모달 안에 보여줄 폼 컴포넌트에게 모달을 숨기는 (닫는) 기능도 전달해줘야 합니다.

이 또한 마찬가지로 cloneElement를 이용하여 구현할 수 있습니다.

return createPortal(
<div>
<div>
<button onClick={close}>X</button>
{/* 자식 컴포넌트에 onCloseModal 프롭을 주입해줍니다. */}
<div>{cloneElement(children, { onCloseModal: close })}</div>
</div>
</div>,
document.body
);
}
export default function Form({ onCloseModal }) {
return (
<form>
<h1>모달 용 테스트 폼입니다.</h1>
<input placeholder="test input text" />
{/* 프롭스로 전달받은 onCloseModal을 이용하여 모달의 상태를 바꿉니다. */}
<button onClick={onCloseModal}>submit</button>
</form>
);
}

마무리

이렇게 리액트 패턴 2가지에 대해 알아보는 시간을 가졌습니다.

위의 두가지 패턴을 이용하여 코드를 작성하면 더 재활용성이 높은 코드를 얻을 수 있다는 장점을 얻을 수 있을 것입니다.

개인적으로는 컨텍스트 API의 실용적인 예시를 알게 되고 사용해본 적 없는 cloneElement에 대해 알게 되어 매우 유용한 시간이었습니다.

또한 매번 모달을 만들 때 모달을 호출하는 컴포넌트에 state를 선언하여 const [isModalOpened, setIsModalOpened] … 의 반복적인 코드를 사용하여 여기 저기 같은 역할을 하는 코드의 양이 늘어나고

모달의 상태관리가 각각 흩어져 에러가 날 경우 확인해야 하는 코드의 양이 많아지는 문제를 가지고 있었는데 컴파운드 컴포넌트 패턴을 알게 된 덕분에 기존의 문제들을 해결할 수 있는 귀중한 시간이었습니다.

강의에서는 모달 뿐만 아니라 테이블, 메뉴 컴포넌트도 만드는 법을 보여줄 뿐만 아니라 다른 알찬 67시간의 강의가 있으므로 관심이 있으신 분들은 해당 강의를 구매하는 것을 추천드립니다.

--

--