[React]Compound component, HoC, React Portal

김건휘·2024년 11월 19일
0

React

목록 보기
13/19
post-thumbnail

이번시간에는 Compound component, HoC, React Portal에 대해서 알아보는 시간을 가져보도록 하겠습니다.

📌Compound component

React에서 구성 요소들 간의 유연한 관계를 정의하고, 하나의 상위 컴포넌트와 하위 컴포넌트들이 함께 작동하도록 설계하는 디자인 패턴이다. 여러 컴포넌트들이 모여 하나의 동작을 할 수 있게 해 준다.

✅예시

React의 Context API 를 활용해 컴파운드 패턴을 활용하여 드롭다운을 만들어보도록 하겠습니다.

✅전체 코드

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

// 1. Dropdown Context 정의
const DropdownContext = createContext(null);

const useDropdown = () => {
  const context = useContext(DropdownContext);
  if (!context) {
    throw new Error("Dropdown components must be used within a Dropdown.");
  }
  return context;
};

// 2. Dropdown 컴포넌트 정의
const Dropdown = ({ children }) => {
  const [isOpen, setIsOpen] = useState(false);

  const toggle = () => setIsOpen((prev) => !prev);

  return (
    <DropdownContext.Provider value={{ isOpen, toggle }}>
      <div style={{ position: "relative", display: "inline-block" }}>
        {children}
      </div>
    </DropdownContext.Provider>
  );
};

// 3. Dropdown.Toggle 컴포넌트 정의
const DropdownToggle = ({ children }) => {
  const { toggle } = useDropdown();

  return (
    <button
      onClick={toggle}
      style={{ cursor: "pointer", padding: "0.5rem 1rem" }}
    >
      {children}
    </button>
  );
};
DropdownToggle.displayName = "Dropdown.Toggle";

// 4. Dropdown.Menu 컴포넌트 정의
const DropdownMenu = ({ children }) => {
  const { isOpen } = useDropdown();

  return isOpen ? (
    <div
      style={{
        position: "absolute",
        top: "100%",
        left: 0,
        backgroundColor: "white",
        border: "1px solid #ccc",
        borderRadius: "4px",
        padding: "0.5rem",
        zIndex: 1000,
        minWidth: "150px",
      }}
    >
      {children}
    </div>
  ) : null;
};
DropdownMenu.displayName = "Dropdown.Menu";

// 5. Dropdown.Item 컴포넌트 정의
const DropdownItem = ({ children, onClick }) => (
  <div
    onClick={onClick}
    style={{
      padding: "0.5rem",
      cursor: "pointer",
      borderBottom: "1px solid #eee",
    }}
  >
    {children}
  </div>
);
DropdownItem.displayName = "Dropdown.Item";

// Dropdown 컴포넌트에 서브 컴포넌트 연결
Dropdown.Toggle = DropdownToggle;
Dropdown.Menu = DropdownMenu;
Dropdown.Item = DropdownItem;

export default Dropdown;

하나씩 차근차근 살펴보도록 하자.

✅Context 정의

// 1. Dropdown Context 정의
const DropdownContext = createContext(null);

const useDropdown = () => {
  const context = useContext(DropdownContext);
  if (!context) {
    throw new Error("Dropdown components must be used within a Dropdown.");
  }
  return context;
};
  1. dropdown의 기본동작을 위한 context를 정의한다.
  2. 열고 닫는 동작을 해야되고, 내부 컴포넌트가 열고닫는 상태에 접근할수 있어야하기 때문에 context를 만들어 하위 컴포넌트가 사용할수있게 한다.
  • DropdownContext: React Context를 생성하여 Dropdown 컴포넌트의 상태(isOpen)와 동작(toggle)을 공유.
  • useDropdown: Context를 사용하는 커스텀 훅으로, Context 값이 없으면 에러를 발생시켜 Dropdown 내부에서만 컴포넌트가 사용되도록 강제한다.
// 2. Dropdown 컴포넌트 정의
const Dropdown = ({ children }) => {
  const [isOpen, setIsOpen] = useState(false);

  const toggle = () => setIsOpen((prev) => !prev);

  return (
    <DropdownContext.Provider value={{ isOpen, toggle }}>
      <div style={{ position: "relative", display: "inline-block" }}>
        {children}
      </div>
    </DropdownContext.Provider>
  );
};

자식 컴포넌트들에게 Dropdown의 열고 닫히는 상태(open)와 동작(toggle)을 전달해주는 컴포넌트이다. Context Provider를 통해서 자식요소에게 isopen, setIsOpen, 상태를 접근하고, 변경 가능하게 한다.

// 3. Dropdown.Toggle 컴포넌트 정의
const DropdownToggle = ({ children }) => {
  const { toggle } = useDropdown();

  return (
    <button
      onClick={toggle}
      style={{ cursor: "pointer", padding: "0.5rem 1rem" }}
    >
      {children}
    </button>
  );
};
DropdownToggle.displayName = "Dropdown.Toggle";

Toggle 컴포넌트는 요소를 감싸서 해당 요소를 통해서 메뉴를 트리거 할 수 있게 하는 역할을 한다.

const DropdownMenu = ({ children }) => {
  const { isOpen } = useDropdown();

  return isOpen ? (
    <div
      style={{
        position: "absolute",
        top: "100%",
        left: 0,
        backgroundColor: "white",
        border: "1px solid #ccc",
        borderRadius: "4px",
        padding: "0.5rem",
        zIndex: 1000,
        minWidth: "150px",
      }}
    >
      {children}
    </div>
  ) : null;
};
DropdownMenu.displayName = "Dropdown.Menu";

DropdownMenu 컴포넌트는 메뉴 리스트 아이템을 렌더링하는 틀 역할을한다.

  • isOpen 상태가 true일 때 Dropdown 메뉴를 렌더링.
// 5. Dropdown.Item 컴포넌트 정의
const DropdownItem = ({ children, onClick }) => (
  <div
    onClick={onClick}
    style={{
      padding: "0.5rem",
      cursor: "pointer",
      borderBottom: "1px solid #eee",
    }}
  >
    {children}
  </div>
);
DropdownItem.displayName = "Dropdown.Item";
  • Dropdown 메뉴의 개별 항목을 렌더링
  • onClick 이벤트를 받아 클릭 동작을 처리
Dropdown.Toggle = DropdownToggle;
Dropdown.Menu = DropdownMenu;
Dropdown.Item = DropdownItem;
  • Dropdown.Toggle, Dropdown.Menu, Dropdown.Item을 Dropdown 컴포넌트의 정적 프로퍼티로 연결.
  • Dropdown.Toggle, Dropdown.Menu, Dropdown.Item처럼 사용 가능. => 가독성 good

✅사용 예시

import Dropdown from './Dropdown';

const App = () => {
  const handleItemClick = (item) => {
    alert(`You selected: ${item}`);
  };

  return (
    <Dropdown>
      <Dropdown.Toggle>Open Menu</Dropdown.Toggle>
      <Dropdown.Menu>
        <Dropdown.Item onClick={() => handleItemClick('Item 1')}>Item 1</Dropdown.Item>
        <Dropdown.Item onClick={() => handleItemClick('Item 2')}>Item 2</Dropdown.Item>
        <Dropdown.Item onClick={() => handleItemClick('Item 3')}>Item 3</Dropdown.Item>
      </Dropdown.Menu>
    </Dropdown>
  );
};

export default App;

✅default

✅isOpen시

✅아이템 클릭시

✅장점

  • 재사용성: Dropdown, Toggle, Menu, Item이 분리되어 다른 상황에서도 재사용 가능.
  • 유연성: Context를 사용해 부모-자식 컴포넌트 간 상태를 공유. => Prop Drilling 문제 해결
  • 확장성: 각 컴포넌트가 명확히 분리되어 읽기 쉽고 확장하기 쉬움.

✅단점

  • 하위 컴포넌트 구조 의존성
    Dropdown.Toggle, Dropdown.Menu, Dropdown.Item은 반드시 Dropdown 내부에서 사용되어야만 동작한다. 그렇기 때문에 다른 컨텍스트에서 독립적으로 사용하기 어렵습니다.
    예를 들어, Dropdown.Menu를 별도의 위치로 이동시켜 렌더링하려면 구조를 크게 변경해야 한다.

🧐어떠한 경우에 많이 사용될까?

  • 탭, 모달, 드롭다운과 같이 상태UI가 밀접하게 연결된 복합적인 인터페이스를 설계할 때 유용하게 사용 가능.

📌Higher-Order Component (HOC)

HOC (Higher-Order Component)는 하나의 컴포넌트를 입력으로 받아 확장된 새로운 컴포넌트를 반환하는 함수이다. 컴포넌트의 로직을 재사용하거나 기능을 추가할 때 유용하게 사용된다.

컴포넌트는 props를 UI로 변환하는 반면에, 고차 컴포넌트는 컴포넌트를 새로운 컴포넌트로 변환한다.

🧐어떠한 경우에 사용하면 유용할까?

1. 공통 로직의 재사용
여러 컴포넌트에서 동일한 로직이 필요할 때 HOC를 사용하면 코드를 중복하지 않고 공통된 로직을 재사용할 수 있다.

  • 인증 관련 로직(예: 사용자가 로그인했는지 확인).
  • API 요청 로직(데이터 fetching 및 에러 처리).
  • 특정 상태 관리(예: toggle 상태 관리).

2. 권한 부여 및 조건부 렌더링
특정 사용자 권한에 따라 컴포넌트를 렌더링하거나 제한하고 싶을 때.

  • 관리자 전용 페이지 접근 제어.

3. 로깅 또는 디버깅
컴포넌트의 동작을 추적하거나 디버깅 정보를 출력하고 싶을 때.

  • 컴포넌트가 받은 props를 콘솔에 출력.

4. 스타일링 관련 확장
특정 스타일링이나 테마를 주입하고 싶을 때.

  • 다크 모드, 특정 테마 적용.

5. 컴포넌트의 기능 확장
컴포넌트에 새로운 기능(예: 이벤트 처리, 상태 관리)을 추가하고 싶을 때.

  • 마우스 이벤트 핸들링.

✅코드 예시

Higher-Order Component (HoC) 패턴을 사용하여 리다이렉트 로직을 구현한 예제이다. 인증된 사용자만 특정 컴포넌트를 볼 수 있도록 보호하며, 인증되지 않은 사용자는 로그인 페이지로 리다이렉트되도록 하였다. 아래 예제는 로그인시 access-token이 담겨있으면, Home 페이지에 접근 가능하고 그렇지 않은 경우 Login 페이지로 리다이렉트 시킨다.

withAuthRedirect - 고차 컴포넌트

import { useEffect } from "react";
import { useNavigate } from "react-router-dom";

const withAuthRedirect = (WrappedComponent) => {
  const WithAuthRedirect = (props) => {
    const navigate = useNavigate();

    const redirectPath = "/login";

    const isAuthenticated = Boolean(localStorage.getItem("token"));

    useEffect(() => {
      if (!isAuthenticated) {
        console.log(
          `인증이 유효하지 않습니다. ${redirectPath}으로 리다이렉트 됩니다`
        );
        navigate(redirectPath);
      }
    }, [isAuthenticated, navigate, redirectPath]);

    if (!isAuthenticated) {
      return null; // 인증되지 않으면 아무것도 렌더링하지 않음
    }

    return <WrappedComponent {...props} />;
  };

  WithAuthRedirect.displayName = `WithAuthRedirect(${
    WrappedComponent.displayName || WrappedComponent.name || "Component"
  })`;

  return WithAuthRedirect;
};

export default withAuthRedirect;
  • 인증 상태 확인: localStorage에서 token을 확인하여 인증 상태(isAuthenticated)를 판별합니다.
  • 리다이렉션 기능: 인증되지 않은 경우 useNavigate를 사용하여 /login 페이지로 리다이렉트합니다.
  • 컴포넌트 보호: 인증되지 않은 사용자는 WrappedComponent가 렌더링되지 않고 대신 null이 반환됩니다.

Home 컴포넌트

const Home = () => {
  return <h1>Welcome to the Home!</h1>;
};

export default Home;

HoC를 사용하여 보호된 컴포넌트(ProtectedHome)를 생성

import withAuthRedirect from "./hoc/withAuthRedirect";
import Home from "./Home";

const ProtectedHome = withAuthRedirect(Home);

export default ProtectedHome;

Login 컴포넌트

import { useNavigate } from "react-router-dom";

const Login = () => {
  const navigate = useNavigate();

  const handleLogin = () => {
    // 토큰 저장 (예: 인증 성공 시)
    localStorage.setItem("token", "user-auth-token");

    // 로그인 후 대시보드로 리다이렉트
    navigate("/home");
  };

  const handleLogout = () => {
    // 토큰 제거 (로그아웃)
    localStorage.removeItem("token");

    // 로그아웃 후 로그인 페이지로 리다이렉트
    navigate("/login");
  };

  return (
    <div>
      <h1>Login Page</h1>
      <button onClick={handleLogin}>Login</button>
      <button onClick={handleLogout}>Logout</button>
    </div>
  );
};

export default Login;

Login버튼 클릭시, 로컬스토리지에 토큰을 담아서 Home페이지로 navigate한다.

✅로그인 성공시


Home 페이지로 navigate 잘되는걸 확인할 수 있다

✅로컬스토리지에 토큰이 담기지 않을시


Home 페이지로 navigate되지 못하고, Login페이지에 머물러 있음

✅결론

HOC는 컴포넌트 간에 공통 로직을 공유하거나, 컴포넌트의 기능을 동적으로 확장해야 할 때 유용하다. 하지만 HOC의 사용이 복잡도를 증가시킬 수 있으므로, 필요에 따라 Custom Hook과 비교하여 적절히 선택하는 것이 중요하다.

📌React Portal

Portals는 컴포넌트의 렌더링 위치를 변경할 수 있게 해주는 React의 기능이다. 일반적으로 React 컴포넌트는 부모 DOM 계층 구조 내에서 렌더링되지만, Portals를 사용하면 DOM 계층 구조의 다른 위치에 컴포넌트를 렌더링할 수 있다.

https://ko.react.dev/reference/react-dom/createPortal

✅createPortal의 사용법

ReactDOM.createPortal은 두 가지 인수를 받는다:

  1. 렌더링할 React 노드 (React 컴포넌트나 JSX)
  2. 렌더링할 DOM 요소 (HTML DOM 노드)

✅코드 예시

import React from 'react';
import ReactDOM from 'react-dom';

function Modal({ children }) {
  return ReactDOM.createPortal(
    <div className="modal">
      {children}
    </div>,
    document.getElementById('modal-root') // 여기로 렌더링
  );
}

function App() {
  return (
    <div>
      <h1>My App</h1>
      <Modal>
        <p>This is inside a portal!</p>
      </Modal>
    </div>
  );
}

✅주요 특징

  • DOM 구조를 벗어남: 컴포넌트는 React 계층 구조에 머물지만 실제 DOM 위치는 다른 곳에 렌더링된다.
  • 레이어 관리: 모달, 팝업, 툴팁 같은 UI 요소를 구현할 때 유용.
  • 이벤트 버블링 유지: Portals를 통해 렌더링된 요소에서도 이벤트는 React 트리 상에서 버블링됩니다.

✅결론

createPortal은 모달, 알림창, 오버레이, 툴팁 등 특정한 UI 패턴을 구현할 때 많이 사용된다.

profile
공유할 때 행복을 느끼는 프론트엔드 개발자

2개의 댓글

comment-user-thumbnail
2024년 11월 20일

건휘님 안녕하세요! 사실 개인적으로 패턴을 이론적으로 공부할 때는 크게 와닿지는 못했던 것 같습니다. 그런데 실제로 간단한 실습 예제 코드를 통해서 Context API를 활용해서 어떻게 합성 컴포넌트를 활용했고 그 결과가 어떻게 되는지를 눈으로 직접 확인할 수 있어서 쉽게 이해할 수 있어서 좋았습니다.ㅎ

답글 달기
comment-user-thumbnail
2024년 11월 20일

안녕하세요 건휘님!
4주차 아티클 작성, 발표 준비, 그리고 실습까지 준비하시느라 정말 고생 많으셨습니다.

이번 주차 주제인 디자인 패턴은 평소 코드 작성 시 깊게 고민해본 적이 없는 부분이라 혼자 공부할 때 생소한 내용이 많았는데요. 건휘님의 아티클과 오늘 발표, 실습 덕분에 그동안 어렵게 느껴졌던 부분들을 좀 더 명확히 이해할 수 있었습니다.

다양한 코드 예시들을 통해 추상적이라고 생각했던 개념들을 직접 눈으로 확인하며 실습해보니 이해가 훨씬 수월했습니다. 각 패턴의 장점, 단점, 그리고 어떤 상황에서 사용해야 하는지를 명확히 정리해 주신 덕분에, 여러 디자인 패턴을 비교하고 적절한 상황에 선택하는 것이 중요하다는 것을 배웠습니다. 앞으로 합동 세미나나 앱잼 같은 협업 프로젝트에서 이 내용을 적용할 때 큰 도움이 될 것 같아요!

특히 마지막에 로그인 인증이 되지 않으면 아무것도 렌더링 되지 않음을 적용해야 하는 부분은 실제 사례도 보여주시면서 설명해주셔서 더 이해가 잘됐고 왜 이와 같이 코드를 작성해야 하는지 바로 이해가 되는 부분이었습니다.

좋은 아티클, 실습 준비해주셔서 감사드리고 수고 많으셨습니다!!

답글 달기