본문 바로가기
웹/프론트엔드

React 좀 더 현명하게 사용하기

by 이민훈 2023. 5. 8.

이제 프론트엔드 엔지니어로 일을 하게 된 지 경력 1년이 넘었는데, 그동안 알게 된 React에 관련된 편리한 꿀팁들을 작성해 보려 합니다. 지극히 주관적인 의견들이 들어가 있음을 미리 알려드립니다!

 

1. 재사용성, 확장성이 좋은 atomic component 만들기

어떤 프로젝트를 시작하던 atomic component를 잘 만드는 것은 중요합니다. material UI나 ant design처럼 잘 만들어진 컴포넌트 라이브러리를 사용하기도 하는데, 시간이 좀 들더라도 컴포넌트를 직접 만들어야 할 때가 있습니다.

 

interface CustomButtonProps {
  text: string;
  onClick?: () => void;
}

const CustomButton = ({ text, onClick = () => {} }: CustomButtonProps) => {
  return <button onClick={() => onClick()}>{text}</button>;
};

export default CustomButton;

 

리액트를 처음 접하고 자주 만들던 컴포넌트의 형태입니다. text와 onClick핸들러를 받아 html button tag에 적절히 전달하고, 추가적인 스타일링을 해주었습니다. 사용하는데 지장은 전혀 없는 컴포넌트이지만, 앱이 점점 확장되고 해당 컴포넌트를 유지보수해야 할 때 조금 번거로운 일들이 생겼습니다. 추가적인 props가 필요할 때마다 버튼 컴포넌트를 지속적으로 수정해 줬고 버튼 컴포넌트의 기존 props의 형태가 바뀔 때 일찍이 버튼 컴포넌트를 쓰고 있던 코드를 찾아 같이 코드를 수정해 준다거나 하는 일들이 지속해 발생했습니다.

 

위 컴포넌트를 예로 들어보면, 프로퍼티중 text는 string type입니다. 그리고 일반적인 props이기 때문에 <Button text={"Hello, world"} /> 와 같은 형태로 프로퍼티를 넘기죠. 나중에 버튼 태그에 string이 아닌 어떤 태그를 children의 형태로 넘기고 싶다고 상상해 봅시다. text 프로퍼티를 제거하고 children props를 만들어야 하는 것은 물론 타입도 string이 아닌 React.ReactNode와 같은 타입으로 바뀌어야 하므로 CustomButton 컴포넌트도 수정을 해야하고, 기존에 CustomButton 컴포넌트를 쓰고 있던 코드를 모두 수정해야 합니다.

 

많은 React 프로젝트를 진행하며 어떻게 하면 atomic 컴포넌트를 잘 만들 수 있을까 고민을 거듭하였습니다. 그러다 아래와 같은 형태로 바뀌게 되었는데요.

 

import { ForwardedRef, forwardRef } from "react";
import type { StandardProperties } from "csstype";

export type ButtonDefaultProps = React.ButtonHTMLAttributes<HTMLButtonElement>;

interface CustomButtonProps extends ButtonDefaultProps {
  bgColor?: StandardProperties["backgroundColor"];
}

const CustomButton = forwardRef(
  (props: CustomButtonProps, forwardedRef: ForwardedRef<HTMLButtonElement>) => {
    const { bgColor, ...rest } = props;

    return (
      <button
        ref={forwardedRef}
        {...rest}
        style={{ background: props.bgColor }}
      />
    );
  }
);

export default CustomButton;

 

어떤 점들이 바뀌었는지 살펴보도록 하겠습니다.

 

1. html 태그의 props를 확장하기

Props의 타입이 html button tag의 props들을 확장하고 있습니다. 이렇게 되면 children, onClick, disabled 등등 기존 props의 타입을 다시 정의할 필요가 없습니다. 또한 사용법이 기존의 button을 확장하고 있기 때문에 혼란스럽지 않습니다. custom props를 어떻게 쓰는지만 숙지하면 됩니다.

 

2. forwardRef로 컴포넌트를 감싸주기

forwardRef로 컴포넌트를 감싸주게 되면, ref 객체를 커스텀 컴포넌트에 주입이 가능하게 됩니다. ref 객체를 주입할 수 있게 되면, 편리하게 해당 컴포넌트 안의 document를 조작할 수 있게 됩니다.

 

3. CSS type으로 CSS props를 정의하기

자주 쓰는 스타일 관련 프로퍼티가 있다면, props로 넘길 수 있도록 따로 정의하기도 합니다. 그때 CSS type을 이용한다면 해당 프로퍼티에 넘길 수 있는 값들을 따로 정의하지 않아도 됩니다. 물론 전체적인 컬러를 통일하기 위해 primary color / non primary color 처럼 가짓수를 제한하려면 boolean 혹은 union type처럼 따로 타입을 정의하고 커스텀 스타일 로직을 짜는 것이 맞습니다.

 

저는 atomic 컴포넌트를 디자인할 때 위 3가지를 베이스로 확장해 나갑니다. 기본 tag와 사용법이 크게 다르지 않기 때문에, 협업할 때 팀원과의 혼란이 줄고 확장성이 매우 뛰어납니다.

 

2. Modal, 로딩바, 툴팁과 같은 Interactive component는 전역적으로

보통 매우 큰 대규모의 프로젝트가 아닌 경우 Modal과 툴팁은 공통화할 수 있는 부분이 매우 많습니다. 보통은 한 프로젝트에 필요한 모달이나 툴팁 컴포넌트들의 UI가 비슷하고 필요한 프로퍼티들이 비슷하기 때문입니다.

 

interface ModalProps {
  title: string;
  content: string;
}

const Modal = (props: ModalProps) => {
  const { title, content } = props;

  return (
    <div>
      <h1>{title}</h1>
      <span>{content}</span>
    </div>
  );
};

export default Modal;

 

간단한 모달을 만들어 보았습니다. 물론 제대로 된 형태의 모달은 아니고 예시로 사용하기 위해 만들어진 컴포넌트입니다.

 

import { useState } from "react";
import CustomButton from "./components/Button";
import Modal from "./components/Modal";

function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <CustomButton onClick={() => setIsOpen((prev) => !prev)}>
        Modal Open
      </CustomButton>
      {isOpen && <Modal title={"Hello, World!"} content={"React"} />}
    </>
  );
}

export default App;

 

그리고 위와 같은 형태로 많이 사용하였습니다. 아주 간단하고도 흔히 볼 수 있는 패턴의 코드입니다. 하지만 모달이 사용되는 곳이 많아질수록 중복된 코드가 생겨나고, Modal을 컴포넌트 안에서 렌더링하므로 예기치 못한 형태로 렌더링 되는 등 애로 사항이 많이 생겼습니다. Modal의 position이 absolute라고 쳐봅시다. 보통은 absolute혹은 fixed로 사용을 많이 하겠죠? absoulte의 경우 relative인 상위 element를 찾을 때까지 탐색하기 때문에 의도치 않은 위치에 모달이 그려질 수 있습니다. 또한 CSS는 상속이 되기 때문에 opacity처럼 상속이 되는 CSS로 인해 Modal의 알파값이 바뀌는 등의 일이 발생할 수 있습니다.

 

import { create } from "zustand";

type Nullable<T> = null | T;

interface AlertModal {
  title: string;
  content: string;
}

interface ModalStoreStates {
  alertModal: Nullable<AlertModal>;

  toggleAlertModal: (alertModal: AlertModal) => void;
}

const modalStore = create<ModalStoreStates>((set, get) => ({
  alertModal: null,

  toggleAlertModal: (alertModal) => {
    if (get().alertModal === null) {
      set({ alertModal });
    } else {
      set({ alertModal: null });
    }
  },
}));

export default modalStore;

 

어떻게 하면 해당 문제들을 해결할 수 있는지, 간단한 예제 코드들을 만들어 보겠습니다. 저는 제가 많이 쓰는 zustand로 전역 상태를 관리하겠습니다. zustand가 싫다면 Context API, redux, recoil등 얼마든지 다른 방법으로 대체가 가능한 부분입니다. alertModal이라는 상태를 가지고 있고, alertModal을 여닫을 수 있는 핸들러 하나가 정의되어 있습니다.

 

import modalStore from "../../store/modal";
import Modal from "../Modal";

const ModalIndicator = () => {
  const { alertModal } = modalStore();

  return (
    <>
      {alertModal !== null && (
        <Modal title={alertModal.title} content={alertModal.content} />
      )}
    </>
  );
};

export default ModalIndicator;

 

다음은 실제로 모달을 렌더링하는 컴포넌트입니다. 해당 컴포넌트는 앞서 말한 CSS 상속 등의 문제를 해결하기 위해 Root 컴포넌트에 배치될 예정입니다.

 

import CustomButton from "./components/Button";
import ModalIndicator from "./components/ModalIndicator";
import modalStore from "./store/modal";

function App() {
  const { toggleAlertModal } = modalStore();

  return (
    <>
      <ModalIndicator />
      <CustomButton
        onClick={() =>
          toggleAlertModal({ title: "Hello, World!", content: "React" })
        }
      >
        Modal Open
      </CustomButton>
    </>
  );
}

export default App;

 

루트 컴포넌트인 App.tsx입니다. CustomButton을 누르면 전역스토어의 alertModal에 값이 세팅되고, ModalIndicator는 App.tsx컴포넌트 아래에 모달을 렌더링하게 될 겁니다. toggleAlertModal은 어떤 컴포넌트에서든 불러 쓸 수 있습니다. 진행하는 프로젝트의 필요에 따라 얼마든지 해당 패턴을 응용하여 편리하게 사용자와 상호작용이 필요한 컴포넌트들을 손쉽게 렌더링할 수 있습니다. 이 외에도 CSS 상속을 피하고자 React Portal을 사용할 수도 있습니다.

 

3. 공통된 로직 hook으로 작성하기

프로젝트를 진행하며 많은 컴포넌트가 생기고, 복잡한 비즈니스 로직이 생겨나기 시작하면 점점 더 코드를 관리하기 어려워집니다. 처음부터 완벽한 설계를 할 수 있다면 좋겠지만, 쉽지 않은 일입니다. 저는 그래서 일정 주기마다 리팩토링을 꼭 진행하곤 합니다. UI component는 직관적이라 반복적인 부분을 쪼개기가 그렇게 힘들지 않았지만, 늘어나는 state와 함수들, useEffect 구문들은 component와 결합하어 유지보수를 어렵게 만듭니다. 직관적이지 않아 component에 비해 generalize 할 수 있는 부분을 찾기가 쉽지 않지만, 비즈니스 로직이나 각종 핸들러 함수 등의 코드들도 모듈화하는 것이 중요하다고 생각합니다. React에서는 custom hook을 통해 이러한 중복을 많이 줄일 수 있습니다. 가끔 중복되는 로직이 아닌 코드들도 오직 가독성이나 유지보수를 위해 hook으로 빼기도 합니다. 간단한 예제 몇 가지를 보여드리면서 글을 마치도록 하겠습니다.

 

1. 특정 프로젝트에 의존하지 않는 형태의 hook

import { useState } from "react";

const useToggle = (initialState = false) => {
  const [toggle, setToggle] = useState(initialState);

  const handleToggle = () => {
    setToggle((prev) => !prev);
  };

  return { toggle, handleToggle };
};

export default useToggle;

 

import { useEffect, useState } from "react";

const useHasScroll = (
  ref: React.RefObject<HTMLElement>,
  dependencies: unknown[]
) => {
  const [hasScroll, setHasScroll] = useState(false);

  useEffect(() => {
    if (!ref.current) return;
    setHasScroll(
      (ref.current.scrollHeight ?? 0) > (ref.current.clientHeight ?? 0)
    );
  }, [ref, dependencies]);

  return hasScroll;
};

export default useHasScroll;

 

2. 특정 프로젝트에 의존하는 형태의 hook

import modalStore, { AlertModal } from "../store/modal";

const useModalHandlers = () => {
  const { setAlertModal } = modalStore();

  const handleOpen = (alertModal: AlertModal) => [setAlertModal(alertModal)];

  const handleClose = () => {
    setAlertModal(null);
  };

  return { handleOpen, handleClose };
};

export default useModalHandlers;

 

3. 복합적인 형태의 hook

const usePolling = () => {
  const [observer] = useState(() => interval(1000));
  const [isPolling, setIsPolling] = useState(false);

  const call = useCallback(async () => {
    fetch(URL).then((res) => {
      console.log(res);
    });
  }, []);

  const togglePolling = () => {
    setIsPolling((prev) => !prev);
  };

  useEffect(() => {
    let subscription: Subscription;

    if (isPolling) {
      subscription = observer.subscribe(call);
    }

    return () => subscription?.unsubscribe();
  }, [call, observer, isPolling]);

  return { togglePolling };
};

 

4. 뷰 로직과 비즈니스 로직을 분리하기 위한 형태의 hook

const useSetup = () => {
  const { setPosts, setComments } = appStore();

  useEffect(() => {
    fetch(getPostsURL)
      .then((res) => res.json())
      .then((data) => {
        setPosts(data);
      });

    fetch(getCommnetsURL)
      .then((res) => res.json())
      .then((data) => {
        setComments(data);
      });
  }, [setPosts, setComments]);

  useEffect(() => {
    webSocket(webSocketURL).subscribe((data) => {
      console.log(data);
    });
  }, []);
};

 

이 외에도 많은 형태의 hook들이 있습니다. 뷰 로직과 비즈니스 로직을 분리하고 공통된 로직을 hook으로 처리함으로써 좋은 품질의 코드를 작성할 수 있습니다.

 

마지막으로 react-query라는 라이브러리를 안 써보신 분들이라면 꼭 한번 써보시기를 바랍니다.

import { useIsFetching, useIsMutating } from "@tanstack/react-query";

const LoadingIndicator = () => {
  const isFetching = useIsFetching();
  const isMutating = useIsMutating();

  return <>{isFetching + isMutating !== 0 && <LoadingBar />}</>;
};

export default LoadingIndicator;

 

캐싱, 호출한 API의 다양한 상태를 훅으로 제공, retry, enabled 등 다양한 옵션 제공 외에도 서버 상태를 관리하기에 아주 좋은 라이브러리입니다. 상태를 훅으로 제공해 주는 덕에 위의 컴포넌트처럼 Loading과 같은 귀찮지만, 디테일에 있어 필수적인 작업을 간단히 처리할 수 있도록 해줍니다.

댓글