[2차 프로젝트 리팩토링] (3) useClickAway 커스텀훅으로 드롭다운창 외부 영역 클릭 시 창 닫기 (드롭다운 창 체크박스가 선택해제되는 문제 해결하기)

GY·2022년 3월 4일
0

리액트

목록 보기
48/54

여기까지, 검색 페이지의 필터링 항목에 따라 쿼리스트링을 관리하는 코드를 작성해보았다.

하지만 아직 풀리지 않은 의문이 남았다.

왜 이전에는 이미 선택한 체크박스가 드롭다운 창을 닫았다가 다시 열면 체크 상태가 유지되지 않았을까?

뭔가 상위의 state가 업데이트되어 리렌더링이 발생하면서 하위 컴포넌트의 state가 초기화되는 이유일 것이라고 생각했다.

하지만 정확한 원인을 모른 채 우선 전역상탯값으로 관리해 사용해보았고, 내 가설이 맞다는 것을 확인했다.
(물론 지금 와서 생각해보면 굳이 이렇게 하지않고, 코드를 조금만 더 들여다 보면 더 쉽게 문제를 해결할 수 있었을 거라는 생각이 든다..ㅠ)

전역 상태관리를 했던 이유는 체크 후 모달창을 닫았다가 다시 열면 선택했던 체크박스가 체크되어있지 않았기 때문이다.
아무래도 선택한 값을 관리하는 state가 다른 state가 업데이트되어 리렌더링 되면서 초기화되는 것 같았다.

찾아보니, 가장 상위 컴포넌트에 아래와 같은 코드가 있었다.

const [currentID, setCurrentID] = useState();

  const clickHandler = id => {
    setCurrentID(id);
  };

  const closeHandler = () => {
    setCurrentID(false);
  };
  
 return (
	<ModalBtn onClick={() => clickHandler(3)}>
   //버튼을 클릭하면 currentID가 업데이트 된다.
	테마
		<MdOutlineKeyboardArrowDown />
   </ModalBtn>
	{currentID === 3 && (
     //업데이트된 id에 따라 드롭다운 창이 조건부 렌더링 된다.
  		<SelectTheme
  			closeHandler={closeHandler} 
			//드롭다운 창 컴포넌트로 전달된 closeHandler함수는 
			//부모 컴포넌트의 currentId상태를 업데이트하고, 
			//하위 컴포넌트를 리렌더링하면서 하위 state를 초기화 시킨다.
/>
  )}

즉, 각 버튼을 누르면 currentID라는 state가 업데이트 되면서 각 id에 맞는 모달창이 조건부 렌더링된다.
그리고 이 id를 업데이트하는 함수를 handleCloser라는 함수로 전달해준다. 모달창 내부에서는 이 함수를 사용해 모달창을 닫는다.

따라서 모달창을 닫을 때마다 부모컴포넌트의 state가 업데이트되면서 자식 컴포넌트까지 리렌더링되고, 체크박스의 체크여부가 유지되지 않았던 것이다.


전역상태관리, 이대로 괜찮은가!

쿼리스트링과 전역 상태관리 두 개 다 사용할 필요는 없을 듯..!

사실 이대로 전역상탯값을 사용해도 되기는 하지만, 계속 의문이 들었다.
쿼리스트링을 사용하는 이유가 크게 의미가 없어지는 느낌이었기 때문이다.

데이터를 호출할 때 여러 독립된 컴포넌트에서 선택한 상탯값을 모두 적용해 쿼리스트링을 완성해야 하는데, 이것을 url창에 쿼리스트링을 적용함으로써 기능을 이미 구현하고 있었다. 따라서 전역상태관리가 필요하지는 않았다.

그럼에도 전역 상태관리를 했던 이유는... 이상하게도 각 컴포넌트별로 선택한 상탯값이 초기화되어 드롭다운 창을 닫았다가 열면 선택한 체크박스가 선택해제되어있었기 때문이다.

다시 클릭하면 선택 해제도 해야 하고, 해제되면 쿼리스트링에서도 빼야 하는데...

결국 이렇게 의미 없이 두 군데에서 전체 선택한 상탯값을 관리하는 이상한 형태가 되어 버렸고, 전체 상태관리도 겨우 체크박스 선택여부를 확인하기 위한 용도로 밖에 사용되지 않았다.


커스텀 훅 사용

그리고 하나 더, 열고 닫는 로직은 공통적으로 많이 쓰이기 때문에 별도의 커스텀훅으로 만들고 싶었다.

그래서! 커스텀훅을 사용하고 전역 상태관리 없이 각 컴포넌트의 상태관리만 사용해 다시 리팩토링을 했다.


드롭다운창을 열고 닫는 로직부터 리팩토링하자

기존 코드는 버튼을 클릭했을 때 currentId라는 state를 업데이트 한다.
그리고 이 currentId state에 따라 드롭다운 창을 조건부 렌더링 하고 있다.

<div>
  <ModalBtn onClick={() => clickHandler(1)}>
    가격 범위
    <MdOutlineKeyboardArrowDown />
  </ModalBtn>
  {currentID === 1 && (
    <SelectPrice
      closeHandler={closeHandler}
      handleFilter={handleFilter}
      />
  )}
</div>

after

아예 버튼과 드롭다운 창을 하나의 컴포넌트로 합쳤다.

<div>
	<SelectType
		closeHandler={closeHandler}
	/>
</div>

버튼과 드롭다운 창은 하나의 하위 컴포넌트로 옮겼다.
이제 부모 컴포넌트에서 드롭다운 창을 보여주고 사라지게 하는 state와 함수 props를 전달해주지 않고, 이 컴포넌트 안에서 관리할 것이다.

<Wrapper ref={clickRef}>
      <ModalBtn onClick={() => setIsOpened(!isOpened)}>
        스테이 유형
        <MdOutlineKeyboardArrowDown />
      </ModalBtn>
      {isOpened && (
        <ModalBack>
          <PeopleTitle>
            스테이유형
            <AiOutlineClose onClick={() => setIsOpened(!isOpened)} />
          </PeopleTitle>
          <ModalPeopleBtnWrapper>
            <ModalPeopleBtn
              onClick={() => handleArrayToSearchParams('category', category)}
            >
              적용하기
            </ModalPeopleBtn>
          </ModalPeopleBtnWrapper>
          <CheckList>

useClickAway 커스텀 훅 만들기

하나하나 뜯어보자.

export const useClickAway = () => {
  const [isOpened, setIsOpened] = useState(false);
  const clickRef = useRef();

  useEffect(() => {
    const handleDocumentClick = event => {
      const node = clickRef.current;
      const nodeHTML = node.innerHTML;
      const targetHTML = event.target.innerHTML;

      if (!nodeHTML.includes(targetHTML)) {
        setIsOpened(false);
      }
    };

    document.addEventListener('click', handleDocumentClick);

    return () => {
      document.removeEventListener('click', handleDocumentClick);
    };
  }, [clickRef.current]);
  return { isOpened, setIsOpened, clickRef };
};

필요했던 로직

  1. 드롭다운 창 표시여부를 관리할 state가 필요했다.

  2. 드롭다운 창 밖의 영역을 클릭할 때도 창이 사라져야 하지만, 내부 영역의 닫기 버튼을 눌렀을 때도 사라져야 하기 때문에 창 표시 여부를 관리하는 state를 업데이트 할 setState도 필요했다.

  3. 주의할 점은, 대부분의 useClickAway는 사이드바나 모달창이 표시되었을 때 뒤에 배경이 깔리고, 그 배경을 클릭했을 때 창이 사라지도록 구현하는 경우가 많은 것 같다. 하지만 이 드롭다운 창은 드롭다운창 내부를 제외한 모든 다른 영역을 클릭했을 때 사라져야 했다. 따라서 그 창의 영역을 지정할 ref가 필요했다.

그래서 커스텀훅에서 이 3가지를 리턴했다.


useClickAway 사용하기

필요한 3가지를 받아왔다. 사용해보자!

 const { isOpened, setIsOpened, clickRef } = useClickAway();
  return (
    <Wrapper ref={clickRef}>
      //버튼과 드롭다운 창을 모두 제외한 영역을 클릭했을 때
      //창을 사라지게 만들어야 하므로 이 두가지를 감싼 요소에 ref를 지정
      <ModalBtn onClick={() => setIsOpened(!isOpened)}>
        // 버튼 클릭 시 창 켜고 끄기
        스테이 유형
        <MdOutlineKeyboardArrowDown />
      </ModalBtn>
      {isOpened && (
        //상태에 따라 드롭다운 창 켜고 끄기
        <ModalBack>
          <PeopleTitle>
            스테이유형
            <AiOutlineClose onClick={() => setIsOpened(!isOpened)} />
            //드롭다운 창 내부 영역이지만 닫기버튼을 누를 때도 창 닫기
          </PeopleTitle>
          <ModalPeopleBtnWrapper>
            <ModalPeopleBtn

잘 작동한다!


아쉬운 점... 여전히 고민되는 점

바로 innerHTML을 사용했다는 점이다.

아래의 부분인데,

const node = clickRef.current;
const nodeHTML = node.innerHTML;
const targetHTML = event.target.innerHTML;

if (!nodeHTML.includes(targetHTML)) {
        setIsOpened(false);
}

이렇게 코드를 작성했던 이유는...

클릭한 곳의 이벤트 타겟이 드롭다운 창 내부 영역의 요소가 아닐 경우에만 창이 사라지게 만들어야 하기 때문이다.

더 좋은 방법이 있는지는 더 찾아보고 고민해봐야겠다.


추가 리팩토링

2022.03.11 업데이트

innerHTML을 사용하지 않고 다른 방법으로 구현해보았다.
프리온보딩 코스에서 사이드 바를 구현했던 바 있는데, 이 것을 다시 리팩토링 해보면서 더 좋은 방법을 찾아 다시 적용해 보았다.

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

export default function useClickAway() {
  const [isOpened, setIsOpened] = useState(false);
  const clickRef = useRef(null);

  function handleClickAway(e) {
    const target = e.target;
    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 };
}

그리고 다음과 같이 사용했다.
clickRef를 지정한 요소는 해당 요소를 포함한 모든 요소를 클릭했을 때 clickaway 기능에서 제외된다. clickRef 밖의 영역을 클릭했을 때 창이 닫긴다.
별도로 누를 때마다 창이 표시되거나 사라져야 할 경우 onToggle함수를 onClick으로 지정해준다.

onToggle은 setIsOpened(!isOpened) 함수일 뿐이지만 굳이 만들어서 사용하는 이유는, setIsOpened와 isOpened 두 개의 변수를 전달받지 않고 간결하게 코드를 작성하기 위함이고, 어떤 의도로 전달받아 왔는지 그 역할을 보다 명확하게 하기 위함이었다.

그리고 isOpened상탯값에 따라 표시 여부를 결정할 영역, 드롭다운 창을 조건부 렌더링한다.

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

//생략
  return (
    <Wrapper ref={clickRef}>
      <ModalBtn onClick={onToggle}>
        스테이 유형
        <MdOutlineKeyboardArrowDown />
      </ModalBtn>
      {isOpened && (
        <ModalBack>
          <PeopleTitle>
            스테이유형
          //생략

이전에 사용했던 방법은 innerHTML로 문자열 전체에서 해당하는 요소의 html문자열이 포함되는지 검사하는 것이었다.
이 로직은 정확하지 않을 뿐더러 코드만 보고 그 코드의 의도를 명확히 설명할 수 없어 좋은 코드라고 생각하지 않았는데, 다시 리팩토링을 진행하면서 이러한 단점을 보완할 수 있었다.

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

0개의 댓글