[원티드 프리온보딩 2주차 1차과제] - 요청 대시보드 구현 리팩토링 (토글, 사이드바 useClickAway util함수 만들기)

GY·2022년 3월 7일
0

원티드 프리온보딩

목록 보기
12/12

과제 구현사항 중 사이드 바와 드롭다운 창이 있었다.

사이드바드롭다운 창

사이드 바는 내가 구현한 사항이었으나, 드롭다운 창은 다른 팀원이 구현했다.

이 두 가지 모두 밖의 영역을 클릭 했을 때 창이 사라지게 만드는 click away 기능이 필요했는데, 과제 당시에는 공통로직을 사용하기에 어려움이 있었다.

그리고 다시 리팩토링하면서 이 부분을 해결했는데, 오늘은 이 부분을 정리해보려고 한다.


🧐 기존 코드부터 살펴보자.

사이드 바는 버튼 클릭 시 사이드바가 나오면서 뒤에 배경이 깔리고, 사이드 바 밖의 영역을 클릭하면 사라져야했다.

1️⃣ 헤더

기존에는 헤더에서 사이드바를 열고닫는 상태값을 선언하고,
헤더에 위치한 햄버거버튼에 전달해주어 클릭할 때마다 상탯값을 변경할 수 있도록 했다.
그리고 사이드 바는 이 상탯값에 따라 표시되도록 했다.

function HeaderNav() {
  const [isSideBarOpened, setIsSideBarOpened] = useState(false);

  return (
    <HeaderContainer>
      <MenuToggle
        isSideBarOpened={isSideBarOpened}
        setIsSideBarOpened={setIsSideBarOpened}
      />
      <Sidebar
        isSideBarOpened={isSideBarOpened}
        setIsSideBarOpened={setIsSideBarOpened}
      />

2️⃣ 사이드바

사이드 바 컴포넌트 내부 로직을 살펴보자.
사이드 바가 열렸을 때 밖에는 배경색이 깔리므로, 여기에 부여한 클래스 이름이 선택되면 사이드 바를 다시 닫도록 상탯값을 업데이트 해주었다.

그리고 상탯값이 바뀔 때마다 사이드 바는 좌우로 이동하여 표시된다.


  const handleClickOutside = (e: MouseEvent): void => {
    const target = e.target as HTMLElement;
    if (target.classList.contains('background')) setIsSideBarOpened(false);
  };

  useEffect(() => {
    window.addEventListener('click', handleClickOutside);
    return () => {
      window.removeEventListener('click', handleClickOutside);
    };
  }, []);

  useEffect(() => {
    if (sideBarRef.current) {
      if (isSideBarOpened) {
        sideBarRef.current.style.transform = 'translateX(0)';
      } else {
        sideBarRef.current.style.transform = 'translateX(-100%)';
      }
    }
  }, [isSideBarOpened]);

여기까지는 구현에 큰 문제는 없었다.
그럼, 다음 드롭다운 창을 보자.


3️⃣ 드롭다운 창

드롭다운 창은 해당하는 버튼을 클릭하면 나타나고, 해당하는 다른 영역을 눌렀을 때 사이드 바와 마찬가지로 사라진다.

그러나 사이드 바와 다른 점은, 사이드 바는 뒤에 깔린 배경만 클릭하면 됐지만 드롭다운의 경우 드롭다운 창을 제외한 모든 무작위의 요소를 클릭했을 때 사라져야 한다는 것이다.

당시에는 클릭한 요소의 클래스 이름과 태그이름으로 구분하여 로직을 작성했다.


  const handleClickOutside = (e: MouseEvent): void => {
    const target = e.target as HTMLElement;
    if (
      !target.classList.contains('optionList') &&
      target.tagName !== 'BUTTON' &&
      target.tagName !== 'svg'
    )
      setIsClicked(false);
  };

  useEffect(() => {
    window.addEventListener('click', handleClickOutside);
    return () => {
      window.removeEventListener('click', handleClickOutside);
    };
  }, []);

물론 기능은 정상적으로 작동한다.
하지만 공통적인 로직의 재사용이 불가능하고 확장성 또한 없다는 생각이 들었다.
클래스 이름과 태그이름으로 구분을 할 경우 코드 유지보수에서 에러를 핸들링하기도 어려울 것 같을 뿐더러, 프로젝트 규모가 커졌을 경우 각기 다른 곳에서 click away 기능을 사용한다면 일일히 필요한 태그 이름이나 클래스 이름을 지정해주어야 한다.

그래서 다시 공통로직으로 분리해 리팩토링해보았다.


🛠 리팩토링, 시작해보자!

1️⃣ useClickAway 커스텀 훅 만들기

useClickAway로 커스텀 훅을 만들어 진행해보기로 했다.
clickRef에 포함되지 않는 요소가 클릭되었을 경우 isOpened 상탯값은 업데이트 되고, 이 상탯값에 따라 창의 표시여부가 변경된다.

onToggle: 사이드 바 외부의 메뉴버튼 클릭시 사이드바 여닫기 기능이 필요하다.

또한 외부에서 다른 버튼을 눌렀을 때 여닫는 기능이 필요하기 때문에 onToggle함수를 만들어주었다. setState함수를 리턴하게 되면 토글 기능을 위해 state도 함께 받아 setState(!state)형태로 사용해야 하기 때문에, 처음부터 함수로 정의해 간단하게 사용할 수 있도록 했다.

import { useEffect, useRef, useState, RefObject } from 'react';

export interface ClickAway {
  clickRef: RefObject<HTMLElement>;
  isOpened: boolean;
  onToggle: () => void;
}

function useClickAway() {
  const [isOpened, setIsOpened] = useState(false);
  const clickRef = useRef<HTMLElement>(null);

  function handleClickAway(e: MouseEvent): void {
    const target = e.target as HTMLElement;
    if (!clickRef.current?.contains(target)) setIsOpened(false);
  }

  function onToggle() {
    setIsOpened(!isOpened);
  }

  useEffect(() => {
    if (isOpened) {
      document.addEventListener('click', handleClickAway);
    } else {
      document.removeEventListener('click', handleClickAway);
    }
    return () => {
      document.removeEventListener('click', handleClickAway);
    };
  }, [isOpened]);
  return { clickRef, isOpened, onToggle };
}

export default useClickAway;

useClickAway 커스텀훅은 완성했으니, 차근차근 적용해보자!

2️⃣ 사이드바

HeaderNav에 적용하기

메뉴 버튼과 사이드 바를 포함한 상위 컴포넌트인 HeaderNav에서 커스텀 훅을 사용하고, 필요한 부분을 각 컴포넌트에 props로 전달해준다.

function HeaderNav() {
  const { clickRef, isOpened, onToggle } = useClickAway();

  return (
    <HeaderContainer>
      <MenuToggle onToggle={onToggle} />
      <Sidebar isOpened={isOpened} clickRef={clickRef} />
   

메뉴 버튼을 클릭할 때마다 state가 업데이트 되도록 onToggle 함수를 onClickg에 등록해준다.
useClickAway에서 정의한 타입 중 한 가지 onToggle만 사용할 것이므로 Pick을 사용해 타입지정을 해주었다.


type TMenuToggle = Pick<ClickAway, 'onToggle'>;

function MenuToggle({ onToggle }: TMenuToggle) {
  const onClickButton = () => {
    onToggle();
  };

Sidebar에 적용하기

그럼 사이드 바에서는 어떻게 동작할까?
사이드 바를 감싼 가장 상위 요소에 ref를 등록해준다.
그리고 이 ref의 current값이 있을 때, isOpened 상태에 따라 좌우로 이동시켜준다.

useClickAway에서 지정한 ClickAway 항목 중 clickRef와 isOpened만 사용할 것이므로 Pick을 사용해 타입을 지정해주었따.

type Sidebar = Pick<ClickAway, 'clickRef' | 'isOpened'>;

function Sidebar({ clickRef, isOpened }: Sidebar) {
  useEffect(() => {
    if (clickRef.current) {
      clickRef.current.style.transform = isOpened
        ? 'translateX(0)'
        : 'translateX(-100%)';
    }
  }, [isOpened]);

  return (
    <>
      <Wrapper ref={clickRef}>
        <Header>

더 간단하게 고쳐보자.

JS로 직접 style을 조작하는 것은 좋지 않을 뿐더러,
스타일 컴포넌트를 사용하고 있으므로 props를 넘겨주어 더 가독성 좋고 간결한 코드를 만들 수 있다.

사이드 바를 감싸고 있는 요소 Wrapper에 isOpened만 props로 전달해준다.
자바스크립트 로직은 전부 삭제했다.


type Sidebar = Pick<ClickAway<RefObject<HTMLElement>>, 'clickRef' | 'isOpened'>;

function Sidebar({ clickRef, isOpened }: Sidebar) {
  return (
    <>
      <Wrapper ref={clickRef} isOpened={isOpened}>
       //생략
      </Wrapper>
      {isOpened && <Background />}
    </>
  );
}

여기서 주의할 점은, 타입스크립트에서 스타일 컴포넌트를 사용할 때는 넘겨주는 props에 타입을 지정해주어야 한다는 것이다.

const Wrapper = styled.aside`
  transform: ${({ isOpened }: { isOpened: boolean }) =>
    isOpened ? 'translateX(0)' : 'translateX(-100%)'};
  transition: all 0.2s ease-in;
`;

isOpened 값에 따라 위치를 이동시키는 로직을 보다 간결하게 구현할 수 있다.


결과

정상적으로 잘 작동하는 것을 볼 수 있다


3️⃣ 드롭다운 창

내가 직접 구현했던 영역은 아니었지만, 다른 팀원의 코드를 다시 살펴보면서 리팩토링 해보려고 한다.

❗️ 잠깐, 불필요하게 중복된 로직부터 합치자!

❌ 필터링 버튼 컴포넌트

이렇게 되어있었다. 내가 생각했던 기존 코드의 단점은 다음과 같다.

<FilterButton name="가공방식" options={['밀링', '선반']} />
<FilterButton
  name="재료"
  options={['알루미늄', '탄소강', '구리', '합금강', '강철']}
/>
  • 기존 코드는 불필요하게 2번 컴포넌트를 선언하여 효율적이지 않고 확장성이 떨어진다.
    - 이후에 가공방식과 재료 외에 다른 필터링 항목이 여러개로 늘어나게 된다면, 계속해서 해당 컴포넌트를 선언해주어야 한다.
  • 각 항목이 상수화되어 있지 않고 유지보수에 용이하지 않다.
    - 일일히 컴포넌트에 직접 props로 따로따로 명시해주는 것보다는, 한 곳에서 관리해주는 것이 이후 유지보수에 용이 할 거라는 생각이 들었다.

⭕️ 전체 필터링 항목 객체로 관리하기

constants에서 필요한 항목을 선언한 다음,

export const filterList = {
  가공방식: ['밀링', '선반'],
  재료: ['알루미늄', '탄소강', '구리', '합금강', '강철'],
};

mapping 해주었다.

{Object.entries(filterList).map(([key, value]) => (
  <FilterButton key={key} name={key} options={value} />
))}

다른 필터링항목이 추가되거나, 항목을 수정해야 할 경우 constants 폴더에서 반영만 하면된다.


❌ 필터링 항목 체크 박스

기존 코드는 가공방식 혹은 재료, 필터링 버튼을 무엇을 클릭했느냐에 따라 조건문으로 리턴해주고 있었다.

  • 코드의 길이가 매우 길고 가독성이 떨어진다. 필터링 항목이 늘어난다면 코드의 길이는....여기서 더 어마어마하게 길어질 것이다.

  if (name === '가공방식') {
    return (
      <Wrap>
        <Button
          value="method"
          type="button"
          onClick={handleClick}
          isSelected={methods.length > 0}
        >
          {name}
          {methods.length > 0 && <span>({methods.length})</span>}
          <IoMdArrowDropdown className="icon" size="20" />
        </Button>
        {isOpened && (
          <OptionList>
            {options.map((option) => {
              if (methods.includes(option)) {
                return (
                  <OptionItem key={option}>
                    <input
                      type="checkbox"
                      name={name}
                      value={option}
                      onChange={handleCheck}
                      checked
                    />
                    <p>{option}</p>
                  </OptionItem>
                );
              }
              return (
                <OptionItem key={option}>
                  <input
                    type="checkbox"
                    name={name}
                    value={option}
                    onChange={handleCheck}
                  />
                  <p>{option}</p>
                </OptionItem>
              );
            })}
          </OptionList>
        )}
      </Wrap>
    );
  }
  if (name === '재료') {
    return (
      <Wrap>
        <Button
          value="materials"
          type="button"
          onClick={handleClick}
          isSelected={materials.length > 0}
        >
          {name}
          {materials.length > 0 && <span>({materials.length})</span>}
          <IoMdArrowDropdown className="icon" size="20" />
        </Button>
        {isClicked && (
          <OptionList>
            {options.map((option) => {
              if (materials.includes(option)) {
                return (
                  <OptionItem key={option}>
                    <input
                      type="checkbox"
                      name={name}
                      value={option}
                      onChange={handleCheck}
                      checked
                    />
                    <p>{option}</p>
                  </OptionItem>
                );
              }
              return (
                <OptionItem key={option}>
                  <input
                    type="checkbox"
                    name={name}
                    value={option}
                    onChange={handleCheck}
                  />
                  <p>{option}</p>
                </OptionItem>
              );
            })}
          </OptionList>
        )}
      </Wrap>
    );
  }
  return <h1>오류</h1>;
}

⭕️ option mapping

props로 넘겨받은 options props를 mapping하였다.
중복된 로직을 제거하여 코드의 길이가 짧아졌다.

{isOpened && (
        <div ref={clickRef}>
          <OptionList>
            {options.map((option) => {
              if (methods.includes(option)) {
                return (
                  <OptionItem key={option}>
                    <input
                      type="checkbox"
                      name={name}
                      value={option}
                      onChange={handleCheck}
                      checked
                    />
                    <p>{option}</p>
                  </OptionItem>
                );
              }

❗️ 이제 useClickAway를 적용해보자!

사이드 바보다 간단하게 사용할 수 있다.
isOpened일 떄만 드롭다운 창을 조건부 렌더링하고,
해당 창에 ref를 등록해주어 해당 창과 창 내부요소를 제외한 밖의 다른 요소를 클릭했을 때 isOpened 값을 변경해 창이 닫기도록 만들어준다.


function FilterButton({ name, options }: Props) {

  const { isOpened, clickRef, onToggle } = useClickAway();
  const handleClick = () => {
    onToggle();
  };

  return (
    <Wrap>
      <Button
        value="method"
        type="button"
        onClick={handleClick}
        isSelected={methods.length > 0}
      >
        {name}
		//생략
      </Button>
      {isOpened && (
        <div ref={clickRef}>
          <OptionList>
            {options.map((option) => {

결과

의도한 대로 click away 기능이 잘 작동한다!
이전에는 가공방식을 클릭했다가 재료버튼을 클릭했을 때 clickaway가 잘 작동하지 않지만,
리팩토링 후에는 잘 작동하는 것을 볼 수 있다.

이전이후
profile
Why?에서 시작해 How를 찾는 과정을 좋아합니다. 그 고민과 성장의 과정을 꾸준히 기록하고자 합니다.

0개의 댓글