과제 구현사항 중 사이드 바와 드롭다운 창이 있었다.
사이드바 | 드롭다운 창 |
---|---|
사이드 바는 내가 구현한 사항이었으나, 드롭다운 창은 다른 팀원이 구현했다.
이 두 가지 모두 밖의 영역을 클릭 했을 때 창이 사라지게 만드는 click away 기능이 필요했는데, 과제 당시에는 공통로직을 사용하기에 어려움이 있었다.
그리고 다시 리팩토링하면서 이 부분을 해결했는데, 오늘은 이 부분을 정리해보려고 한다.
사이드 바는 버튼 클릭 시 사이드바가 나오면서 뒤에 배경이 깔리고, 사이드 바 밖의 영역을 클릭하면 사라져야했다.
기존에는 헤더에서 사이드바를 열고닫는 상태값을 선언하고,
헤더에 위치한 햄버거버튼에 전달해주어 클릭할 때마다 상탯값을 변경할 수 있도록 했다.
그리고 사이드 바는 이 상탯값에 따라 표시되도록 했다.
function HeaderNav() {
const [isSideBarOpened, setIsSideBarOpened] = useState(false);
return (
<HeaderContainer>
<MenuToggle
isSideBarOpened={isSideBarOpened}
setIsSideBarOpened={setIsSideBarOpened}
/>
<Sidebar
isSideBarOpened={isSideBarOpened}
setIsSideBarOpened={setIsSideBarOpened}
/>
사이드 바 컴포넌트 내부 로직을 살펴보자.
사이드 바가 열렸을 때 밖에는 배경색이 깔리므로, 여기에 부여한 클래스 이름이 선택되면 사이드 바를 다시 닫도록 상탯값을 업데이트 해주었다.
그리고 상탯값이 바뀔 때마다 사이드 바는 좌우로 이동하여 표시된다.
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]);
여기까지는 구현에 큰 문제는 없었다.
그럼, 다음 드롭다운 창을 보자.
드롭다운 창은 해당하는 버튼을 클릭하면 나타나고, 해당하는 다른 영역을 눌렀을 때 사이드 바와 마찬가지로 사라진다.
그러나 사이드 바와 다른 점은, 사이드 바는 뒤에 깔린 배경만 클릭하면 됐지만 드롭다운의 경우 드롭다운 창을 제외한 모든 무작위의 요소를 클릭했을 때 사라져야 한다는 것이다.
당시에는 클릭한 요소의 클래스 이름과 태그이름으로 구분하여 로직을 작성했다.
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 기능을 사용한다면 일일히 필요한 태그 이름이나 클래스 이름을 지정해주어야 한다.
그래서 다시 공통로직으로 분리해 리팩토링해보았다.
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 커스텀훅은 완성했으니, 차근차근 적용해보자!
메뉴 버튼과 사이드 바를 포함한 상위 컴포넌트인 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();
};
그럼 사이드 바에서는 어떻게 동작할까?
사이드 바를 감싼 가장 상위 요소에 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 값에 따라 위치를 이동시키는 로직을 보다 간결하게 구현할 수 있다.
정상적으로 잘 작동하는 것을 볼 수 있다
내가 직접 구현했던 영역은 아니었지만, 다른 팀원의 코드를 다시 살펴보면서 리팩토링 해보려고 한다.
이렇게 되어있었다. 내가 생각했던 기존 코드의 단점은 다음과 같다.
<FilterButton name="가공방식" options={['밀링', '선반']} />
<FilterButton
name="재료"
options={['알루미늄', '탄소강', '구리', '합금강', '강철']}
/>
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>;
}
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>
);
}
사이드 바보다 간단하게 사용할 수 있다.
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가 잘 작동하지 않지만,
리팩토링 후에는 잘 작동하는 것을 볼 수 있다.
이전 | 이후 |
---|---|