[TIL] 241126_React: Context 적용하기 <Layout편>

지코·2024년 11월 26일
1

Today I Learned

목록 보기
57/66
post-thumbnail

⚡️ Context 개념을 Layout에 적용하기

지난 포스팅에서 다룬 Router Context는 주소와 관련된 작업을 수행하기 위해 구현되었다. 이번 포스팅에서 다룰 Layout Context는 각 화면에서 사용할 Dialog를 위해 구현하였다.
전반적인 아키텍처는 Router Context와 비슷하다!

📁 Dialog.jsx

import React from "react";

class Dialog extends React.Component {
  constructor(props) {
    super(props);
    this.footerRef = React.createRef();
  }

  componentDidMount() {
    if (!this.footerRef.current) return;
    // footer에서 button을 모두 찾는데, 유사 배열이므로 Array로 만듬.
    const buttons = Array.from(
      this.footerRef.current.querySelectorAll("button")
    );
    
    if (!buttons.length === 0) return;
    const activeButton = buttons[buttons.length-1]; // 가장 오른쪽 버튼
    activeButton.focus();
  }

  render() {
    const { header, children, footer } = this.props;

    return (
      <div className="Dialog">
        {header && <header>{header}</header>}
        <main>{children}</main>
        {footer && <footer ref={this.footerRef}>{footer}</footer>}
      </div>
    );
  }
}

export default Dialog;

먼저 기본적인 Dialog를 구현한다. 본래는 함수 컴포넌트로 구현했으나, 후에 createRef()를 통한 ref 객체를 사용하기 위해 클래스 컴포넌트로 변경하였다.

기본적인 기능은 header, footer 그리고 children을 props로 받아 렌더링하는데, 이 때 header와 footer는 조건부로 렌더링한다.

footerRef 는 footer에 버튼이 들어올 경우 기본 값에 대한 포커싱을 적용하기 위해 사용하였는데, 컴포넌트가 마운트되었을 때 모든 button 태그들을 찾아 가장 오른쪽 버튼에 포커싱되도록 한다.
이를 적용하면 Dialog가 렌더링되었을 때 기본값 버튼을 엔터키로 선택하거나, Shift+Tap을 통해 다른 버튼을 선택할 수 있다.

먼저 footer에 버튼이 두 개 존재할 때이다. 오른쪽 버튼인 예, 주문 상태를 확인합니다. 버튼에 포커싱이 된 것을 확인할 수 있다. 따라서 ✨고객이 해당 버튼을 누를 수 있도록 유도하는 효과✨를 준다.

footer에 버튼이 한 개 존재할 때이다. 이 때는 유일하게 존재하는 버튼에 포커싱이 된 것을 확인할 수 있다.


📁 MyLayout.jsx

import React from "react";
import ReactDOM from "react-dom";
import Dialog from "../components/Dialog";
import Backdrop from "../components/BackDrop";
import { getComponentName } from "./utils";

export const layoutContext = React.createContext({});
layoutContext.displayName = "LayoutContext";

export class Layout extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      dialog: <Dialog />,
    };
    // 비동기적으로 동작하기 때문에 this 바인딩
    this.setDialog = this.setDialog.bind(this);
  }

  setDialog(dialog) {
    this.setState({ dialog });
  }

  render() {
    const value = {
      dialog: this.state.dialog,
      setDialog: this.setDialog,
    };

    return (
      <layoutContext.Provider value={value}>
        {this.props.children}
      </layoutContext.Provider>
    );
  }
}

export const withLayout = (WrappedComponent) => {
  const WithLayout = (props) => (
    <layoutContext.Consumer>
      {({ dialog, setDialog }) => {
        const openDialog = setDialog;
        const closeDialog = () => setDialog(null);
        // 좀 더 개선된 버전
        const startLoading = (message) =>
          openDialog(<Dialog>{message}</Dialog>);
        const finishLoading = closeDialog;
        
        const enhancedProps = {
          dialog,
          openDialog,
          closeDialog,
          startLoading,
          finishLoading,
        };

        return <WrappedComponent {...props} {...enhancedProps} />;
      }}
    </layoutContext.Consumer>
  );
  WithLayout.displayName = `WithLayout(${getComponentName(WrappedComponent)})`;
  return WithLayout;
};
// dialog 상태에 따라 노출시키기
export const DialogContainer = withLayout(
  ({ dialog }) =>
    dialog &&
    ReactDOM.createPortal(
      <Backdrop>{dialog}</Backdrop>,
      document.querySelector("#dialog")
    )
);

위에서 구현한 Dialog를 사용하는 Layout Context 파일이다. createContext() 함수를 이용해 layoutContext 객체를 생성해준다. 크게 보면 Provider를 사용하는 Layout 컴포넌트Consumer를 사용하는 withLayout 컴포넌트로 나눌 수 있다.

🅿 먼저 Layout 컴포넌트는 하위 트리에 전달할 값을 가지고 있어야 하고, 그 값은 렌더링할 dialog와 그 dialog 값을 변경시킬 setDialog 함수여야 한다. 따라서 두 값을 가진 객체를 Provider의 value 속성을 통해 전달한다.

🅲 withLayout 컴포넌트는 지난 시간에 다룬 withRouter 컴포넌트와 마찬가지로, WrappedComponent가 사용하는 props를 받아 재구성한 뒤 enhancedProps 형태로 WrappedComponent에 전달한다.

  • openDialog / closeDialog: 각각 Dialog를 열고 닫는 함수.
  • startLoading: Dialog에 들어갈 메시지를 children으로 받아 Dialog를 렌더링.
  • finishLoading: startLoading 함수와 구색을 맞추기 위해 구현. 실질적으로는 Dialog를 닫는 함수.

⚡️ 실제로 적용해보기

withLayout을 통해 실제로 Layout Context를 사용해보자.

장바구니 화면을 렌더링하는 CartPage 컴포넌트를 예시로 들겠다.

export default MyLayout.withLayout(MyRouter.withRouter(CartPage));

지난 시간에 사용한 MyRouter.withRouter에 이어, MyLayout.withLayout으로 컴포넌트를 감싸주었다.

이제 CartPage 컴포넌트는 withLayout이 가지고 있는 함수들을 사용할 수 있음!

// 콜백 함수: OrderForm을 통해 전달 받은 주문 정보를 가져옴.
  async handleSubmit(values) {
    const { startLoading, finishLoading, openDialog } = this.props;
    startLoading('결제 중 ...');
    // api 호출
    try {
      await OrderApi.createOrder(values);
    } catch (e) {
      openDialog(<ErrorDialog />);
      return;
    }
    finishLoading();
    openDialog(<PaymentSuccessDialog />);
  }

매개변수 values를 통해 전달 받은 주문 정보를 사용하는 handleSubmit 함수를 예로 들겠다. handleSubmit 함수는 '결제하기' 버튼을 클릭하면 실행되며, withLayout 컴포넌트가 가지고 있는 함수들은 props를 통해 가져올 수 있다.

  1. 사용자가 <결제하기> 버튼을 누르면 startLoading 함수를 통해 '결제 중 ...' 이라는 메시지가 적힌 Dialog가 뜨는 것을 확인할 수 있다.
  2. Dialog 렌더링과 함께 API 호출을 시도하며, 성공 시 finishLoading 함수를 통해 기존 Dialog를 종료한다.
  3. 기존 Dialog 종료 직후 PaymentSuccessDialog 컴포넌트가 openDialog 함수를 통해 렌더링된다. 이 때 사용자는 결제 창으로 넘어갈지 선택할 수 있다.
  4. 만약 API 호출에 실패했을 경우, ErrorDialog 컴포넌트를 openDialog 함수를 통해 렌더링한다.

Layout Context는 이와 같은 방식으로 사용할 수 있다.
그리고 파일 최하단에 구현되어 있는 DialogContainer 컴포넌트는 다음 시간에 React의 createPortal 함수에 대해 다루면서 분석하려고 한다‼️

Reference

👩🏻‍🏫 [리액트 2부] 고급 주제와 훅
https://www.inflearn.com/course/리액트-고급주제와-훅-2부

📄 React 공식 문서: Context-React
https://ko.legacy.reactjs.org/docs/context.html#before-you-use-context

profile
꾸준하고 성실하게, FE 개발자 역량을 키워보자 !

0개의 댓글