데이터 검증을 자동화하는 CategoryProtector 컴포넌트를 소개합니다. 이 컴포넌트는 다양한 페이지 및 컴포넌트에서 필요한 데이터의 존재 여부를 확인하고, 필요시 자동으로 Dialog를 띄워 데이터 생성을 유도합니다.
여기 카테고리가 중요한 컴포넌트 및 페이지들이 존재한다.
퀴즈 생성 | 문서 작성 |
---|---|
![]() | ![]() |
따라서 해당하는 Dialog를 열거나 문서 작성 페이지로 이동할 때, 카테고리가 존재하지 않는다면 아래와 같이 카테고리를 생성하는 모달을 사용자에게 띄워 카테고리를 생성시켜야 한다.
이 때, 두가지 문제가 있다.
퀴즈 만들기 Dialog Triggers | 문서 생성 Redirect Links |
---|---|
![]() | ![]() |
2번 문제에 대한 간단한 예시를 보자
위의 '노트 추가하러 가기' 링크는 카테고리가 존재하지 않는다면 카테고리 생성 Dialog의 트리거로써 동작해야 하고, 카테고리가 존재한다면 /create-document
로 리다이렉트 시키는 링크로 동작해야 한다.
이 둘은 다른 기능을 수행하지만 같은 스타일을 가져야 하기 때문에 코드는 다음과 같다.
function App({ category }: { category: Category[] | null }) {
if (category == null) {
return <CreateCategoryDialog trigger={<FakeLink same-styles-with-real-link>노트 추가하러 가기</FakeLink>} />
}
return <Link href="/create-document" className='some classnames'>노트 추가하기 가기</Link>
}
이러한 과정을 카테고리가 필요한 컴포넌트를 트리거하거는 모든 곳에서 적용할 생각을하니 머리가 지끈거린다. 또, 추후 유지보수 할 생각에 마음이 너무나 아프다.
또한 사용자가 카테고리를 생성한 후, 다시 한 번 실제 링크를 클릭해야만 문서 생성 페이지로 리다이렉트가 가능하다는 불편함이 존재한다.
어떤 인터페이스가 있으면 마음 편하게 작업을 이어갈 수 있을지 고민해보았다.
function App() {
return (
<CategoryProtector>
<Link href="/create-document">노트 추가하기 가기</Link>
</CategoryProtector>
)
}
위처럼 CategoryProtector가 카테고리가 존재하는지 확인하고, 존재하지 않는다면 Category 생성 Dialog를, 존재한다면 기존의 동작을 수행한다면 어떨까?
이 때, Category 생성 Dialog를 통해 카테고리를 생성한 후 자동으로 기존의 동작을 수행하게 한다면 금상첨화일 것이다.
interface Props extends PropsWithChildren {}
export function CategoryProtector({ children }: Props) {
const { data: session } = useSession()
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: () =>
getCategories({
accessToken: session?.user.accessToken || '',
}).then((res) => res.categories),
})
const [isDialogOpen, setIsDialogOpen] = useState(false)
const targetRef = useRef<(EventTarget & Element) | null>(null)
const handleClick: MouseEventHandler = (e) => {
if (!categories || categories.length === 0) {
e.preventDefault()
setIsDialogOpen(true)
targetRef.current = e.currentTarget
}
}
const handleDialogSuccess = () => {
setIsDialogOpen(false)
}
const clonedChildren = (child: ReactNode): ReactNode => {
if (categories && categories.length > 0) return child
if (React.isValidElement(child)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
return React.cloneElement(child, { onClick: handleClick } as any)
}
return child
}
useEffect(() => {
if (!targetRef.current) return
const target = targetRef.current
targetRef.current = null
const event = new MouseEvent('click', { bubbles: true, cancelable: true, view: window })
target.dispatchEvent(event)
}, [categories])
return (
<>
{React.Children.map(children, clonedChildren)}
<CreateCategoryDialog
trigger={<></>}
externalOpen={isDialogOpen}
onOpenChange={setIsDialogOpen}
onSuccess={() => handleDialogSuccess()}
/>
</>
)
}
const clonedChildren = (child: ReactNode): ReactNode => {
if (categories && categories.length > 0) return child;
if (React.isValidElement(child)) {
return React.cloneElement(child, { onClick: handleClick });
}
return child;
};
자식 요소를 클론하여 카테고리가 없을 경우 클릭 이벤트 핸들러를 추가한다.
const handleClick: MouseEventHandler = (e) => {
if (!categories || categories.length === 0) {
e.preventDefault();
setIsDialogOpen(true);
targetRef.current = e.currentTarget;
}
};
클릭 이벤트가 발생했을 때, 카테고리가 없으면 기본 동작을 막고 카테고리 생성 Dialog를 오픈한다.
const handleDialogSuccess = () => {
setIsDialogOpen(false);
};
카테고리 생성 다이얼로그가 성공적으로 완료되면 Dialog를 close한다.
useEffect(() => {
if (!targetRef.current) return;
const target = targetRef.current;
targetRef.current = null;
const event = new MouseEvent('click', { bubbles: true, cancelable: true, view: window });
target.dispatchEvent(event);
}, [categories]);
카테고리가 로드된 후, 저장된 클릭 이벤트를 다시 실행하여 기존의 기대했던 액션을 수행한다.
퀴즈 만들기 Dialog trigger에 적용한 모습은 다음과 같다.
CategoryProtector의 구현으로 카테고리가 필요한 모든 액션을 안전하게 보호할 수 있어 코드의 반복을 줄이고 유지보수성을 향상시킬 수 있었다.
재밌게 잘봤습니다 :) 읽다보니 코드에서 조금 우려되는 부분이 있어서 의견 남겨드립니다.
categories를 useEffect의 deps로 넣어 카테고리 등록 여부를 판단하고 계시는걸로 보입니다. 이렇게 useEffect로 성공 여부를 판단한다면 예상치못한 사이드이펙트가 발생할 수 있지 않을까하는 생각이 들었습니다.
가령 n개의 protector가 등록된 상황에서 각 버튼을 눌러 카테고리 등록 모달을 띄웠지만 카테고리 등록을 하지 않고 모달을 꺼버린다면 targetRef는 살아있는 상태가 됩니다. 이때 모종의 이유로 쿼리가 다시 호출되어 categories가 바뀐다면 n개의 useEffect가 동시에 실행이 될 수 있지 않을까요?
CreateCategoryDialog에서 카테고리 등록 없이 모달을 껐을 때 tragetRef를 클린업해줄 수도 있지만 그보다는 useEffect의 로직을 onSuccess로 직접 넘기는 방향도 고민해볼 수 있을 것 같습니다.
++
의도적으로 이렇게 쓰신걸 수도 있지만 그럼에도 작은 팁을 공유드려보자면, 이렇게 array의 아이템이 있는지 확인하는 조건은
이렇게 축약할 수 있습니다.
재밌는 컨셉인 것 같아서 자세히 읽어보다가 코드리뷰 아닌 코드리뷰를 한것 같아서 죄송합니다..ㅎㅎ 앞으로도 좋은 포스팅 기대하겠습니다:)