데이터 검증을 자동화하는 React 컴포넌트 구현하기

우디·2024년 6월 15일
11

Picktoss

목록 보기
3/4
post-thumbnail

데이터 검증을 자동화하는 CategoryProtector 컴포넌트를 소개합니다. 이 컴포넌트는 다양한 페이지 및 컴포넌트에서 필요한 데이터의 존재 여부를 확인하고, 필요시 자동으로 Dialog를 띄워 데이터 생성을 유도합니다.

개요

여기 카테고리가 중요한 컴포넌트 및 페이지들이 존재한다.

퀴즈 생성문서 작성

따라서 해당하는 Dialog를 열거나 문서 작성 페이지로 이동할 때, 카테고리가 존재하지 않는다면 아래와 같이 카테고리를 생성하는 모달을 사용자에게 띄워 카테고리를 생성시켜야 한다.

문제

이 때, 두가지 문제가 있다.

  1. 문서 작성 페이지로 이동하는 링크, 퀴즈 생성 Dialog 트리거 등은 다양한 형태로 서비스 곳곳에 다수 퍼져 있다.
퀴즈 만들기 Dialog Triggers문서 생성 Redirect Links
  1. 클릭 이벤트가 발생했을 때 카테고리 생성 Dialog를 표시해야 하기 때문에 Fake Trigger가 필요하다.

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()}
      />
    </>
  )
}

주요 코드

1. clonedChildren

const clonedChildren = (child: ReactNode): ReactNode => {
  if (categories && categories.length > 0) return child;

  if (React.isValidElement(child)) {
    return React.cloneElement(child, { onClick: handleClick });
  }
  return child;
};

자식 요소를 클론하여 카테고리가 없을 경우 클릭 이벤트 핸들러를 추가한다.

2. handleClick

const handleClick: MouseEventHandler = (e) => {
  if (!categories || categories.length === 0) {
    e.preventDefault();
    setIsDialogOpen(true);
    targetRef.current = e.currentTarget;
  }
};

클릭 이벤트가 발생했을 때, 카테고리가 없으면 기본 동작을 막고 카테고리 생성 Dialog를 오픈한다.

3. handleDialogSuccess

const handleDialogSuccess = () => {
  setIsDialogOpen(false);
};

카테고리 생성 다이얼로그가 성공적으로 완료되면 Dialog를 close한다.

4. useEffect

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의 구현으로 카테고리가 필요한 모든 액션을 안전하게 보호할 수 있어 코드의 반복을 줄이고 유지보수성을 향상시킬 수 있었다.

profile
시행착오를 즐기는 프론트엔드 개발자입니다!

2개의 댓글

comment-user-thumbnail
2024년 6월 22일

재밌게 잘봤습니다 :) 읽다보니 코드에서 조금 우려되는 부분이 있어서 의견 남겨드립니다.

categories를 useEffect의 deps로 넣어 카테고리 등록 여부를 판단하고 계시는걸로 보입니다. 이렇게 useEffect로 성공 여부를 판단한다면 예상치못한 사이드이펙트가 발생할 수 있지 않을까하는 생각이 들었습니다.
가령 n개의 protector가 등록된 상황에서 각 버튼을 눌러 카테고리 등록 모달을 띄웠지만 카테고리 등록을 하지 않고 모달을 꺼버린다면 targetRef는 살아있는 상태가 됩니다. 이때 모종의 이유로 쿼리가 다시 호출되어 categories가 바뀐다면 n개의 useEffect가 동시에 실행이 될 수 있지 않을까요?

<CategoryProtector>
      <A/>
</CategoryProtector>
<CategoryProtector>
      <B/>
</CategoryProtector>

CreateCategoryDialog에서 카테고리 등록 없이 모달을 껐을 때 tragetRef를 클린업해줄 수도 있지만 그보다는 useEffect의 로직을 onSuccess로 직접 넘기는 방향도 고민해볼 수 있을 것 같습니다.

++
의도적으로 이렇게 쓰신걸 수도 있지만 그럼에도 작은 팁을 공유드려보자면, 이렇게 array의 아이템이 있는지 확인하는 조건은

if(categories && categories.length > 0)

이렇게 축약할 수 있습니다.

if(categories?.length)

재밌는 컨셉인 것 같아서 자세히 읽어보다가 코드리뷰 아닌 코드리뷰를 한것 같아서 죄송합니다..ㅎㅎ 앞으로도 좋은 포스팅 기대하겠습니다:)

1개의 답글

관련 채용 정보