여기까지, 검색 페이지의 필터링 항목에 따라 쿼리스트링을 관리하는 코드를 작성해보았다.
하지만 아직 풀리지 않은 의문이 남았다.
왜 이전에는 이미 선택한 체크박스가 드롭다운 창을 닫았다가 다시 열면 체크 상태가 유지되지 않았을까?
뭔가 상위의 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>
아예 버튼과 드롭다운 창을 하나의 컴포넌트로 합쳤다.
<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>
하나하나 뜯어보자.
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 };
};
드롭다운 창 표시여부를 관리할 state가 필요했다.
드롭다운 창 밖의 영역을 클릭할 때도 창이 사라져야 하지만, 내부 영역의 닫기 버튼을 눌렀을 때도 사라져야 하기 때문에 창 표시 여부를 관리하는 state를 업데이트 할 setState도 필요했다.
주의할 점은, 대부분의 useClickAway는 사이드바나 모달창이 표시되었을 때 뒤에 배경이 깔리고, 그 배경을 클릭했을 때 창이 사라지도록 구현하는 경우가 많은 것 같다. 하지만 이 드롭다운 창은 드롭다운창 내부를 제외한 모든 다른 영역을 클릭했을 때 사라져야 했다. 따라서 그 창의 영역을 지정할 ref가 필요했다.
그래서 커스텀훅에서 이 3가지를 리턴했다.
필요한 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문자열이 포함되는지 검사하는 것이었다.
이 로직은 정확하지 않을 뿐더러 코드만 보고 그 코드의 의도를 명확히 설명할 수 없어 좋은 코드라고 생각하지 않았는데, 다시 리팩토링을 진행하면서 이러한 단점을 보완할 수 있었다.