React

[React] React Prompt 만들어서 사용해보자

눙엉 2024. 3. 6. 23:52

폼을 작성하는 페이지에서 다른 페이지로 이동하려는 시도 시, 이동 전에 현재 작성된 내용이 손실될 수 있다는 경고 메시지가 나타나는 UI는 많은 사용자들이 익히 본 경험이 있습니다. 이러한 상황에서 사용자에게 명확한 선택을 요청하는 UI는 중요한 사용자 경험의 일부입니다.

 

아래의 사진과 같이 브라우저에서 지원하는 UI를 사용할 수도 있으며 커스텀하여 원하는 UI를 사용할 수도 있습니다.

티스토리 글 작성 후 페이지 이동할 때 Prompt가 활성화 되는 경우

 

react-router-dom6.21.0 버전을 사용하여 커스텀 컴포넌트를 사용할 수 있도록 usePrompt 예시 코드입니다.

현재 프로젝트에서 react-router-dom의 버전을 꼭 확인하시기 바랍니다.

 

아래의 usePrompt 코드를 확인한 후 설명을 이어서 하겠습니다.

import { useEffect } from "react";
import { useBlocker } from "react-router-dom";

const usePrompt = (when: boolean) => {
  const blocker = useBlocker(
    ({ currentLocation, nextLocation }) => {
     return when && currentLocation.pathname !== nextLocation.pathname
    }
  );

  useEffect(() => {
    if (blocker.state !== "blocked") return;

    if (window.confirm("정말로 이동하시겠습니까?")) {
      blocker.proceed();
    } else {
      blocker.reset();
    }
  }, [blocker.state]);
};

export default usePrompt;

usePrompt

1개의 인수를 필요로 합니다. when의 용도는 usePrompt를 사용하는 해당 페이지에서 boolean값을 기준과 함께 Prompt가 사용될지 판단하고 있습니다.

 

useBlocker

1개의 인수를 필요로 합니다. 인수의 타입이 2가지로 이루어져 있으며 boolean | BlockerFunction 타입입니다.

boolean은 boolean 값으로 true, false를 사용하고, BlockerFunction은 이름에서 알 수 있듯이 함수타입입니다. 1개의 인수를 사용하며 객체타입으로 이루어져 있습니다.

 

아래의 타입은 BlockerFunction 타입입니다.

type BlockerFunction = (args: {
    currentLocation: Location;
    nextLocation: Location;
    historyAction: HistoryAction;
}) => boolean;

Location 타입은 객체 타입으로, hash, key, pathname, search, state 속성을 가지고 있습니다. 또한, historyActionPOP, PUSH, REPLACE 세 가지로 이루어져 있습니다.

 

useBlocker의 동작 조건

currentLocation nextLocation pathname 비교하여 현재 페이지와 이동할 페이지의 경로를 확인하고, 페이지 이동 여부 판별할 수 있습니다. 그러나 페이지가 이동할 때 현재 페이지와 이동할 페이지의 경로 항상 다르기 때문에 페이지 경로만 비교를 하게 되면 항상 Prompt 동작합니다. 이를 해결하기 위해 usePrompt의 인수로 사용한 when을 함께 활용하여 Prompt가 동작하길 원하는 조건을 추가해 주면, 원하는 조건이 충족되었을 때 페이지 이동을 하게 되면 Prompt가 동작하도록 설정할 수 있습니다.

 

useBlocker 반환타입

이번에는 useBlocker가 반환하는 blocker의 타입을 확인해 보겠습니다.

interface BlockerBlocked {
    state: "blocked";
    reset(): void;
    proceed(): void;
    location: Location;
}

interface BlockerUnblocked {
    state: "unblocked";
    reset: undefined;
    proceed: undefined;
    location: undefined;
}

interface BlockerProceeding {
    state: "proceeding";
    reset: undefined;
    proceed: undefined;
    location: Location;
}

모두 같은 속성을 가지고 있지만 타입에 따라 속성들의 타입이 달라지는 것을 확인할 수 있습니다.

타입에 작성되어 있는 각 각의 타입에 작성되어 있는 state와 reset(), proceed() 메서드에 대해 알아보겠습니다.

 

stateunblocked, blocked, proceeding 세 가지 타입으로 구성되어 있습니다.

 

unblocked: 페이지 이동 시 차단되지 않으며, useBlocker를 호출한 경우의 기본 상태입니다.

blocked: 페이지 이동 시 차단되어 페이지 이동이 허용되지 않습니다. 이 상태에서는 사용자에게 메시지를 전달하거나 추가 작업을 수행할 수 있습니다.
proceeding: 페이지 이동을 수락한 후의 상태로, 이 상태에서는 이동이 허용되어야 합니다.

 

proceed() 메서드state가 blocked일 때 사용자가 원하는 페이지로 이동을 허용하는 메서드입니다.

reset() 메서드는 사용자가 페이지 이동을 원하지 않을 때 state를 다시 unblocked로 변경하는 메서드입니다.

 

이러한 타입을 기반으로 stateblocked가 되었을 때만 reset(), proceed() 메서드를 사용할 수 있는 것을 유추할 수 있습니다.

 

아래는 예시코드에 주석을 이용해 추가 설명을 작성해 보았습니다.

import { useEffect } from "react";
import { useBlocker } from "react-router-dom";

const usePrompt = (when: boolean) => {
  const blocker = useBlocker(
    // currentLocation.pathname -> 현재 페이지 경로
    // nextLocation.pathname -> 이동할 페이지 경로
    ({ currentLocation, nextLocation }) => {
      // 현재 페이지와 이동할 페이지는 항상 다르기 때문에
      // when을 사용해서 Prompt를 동작할 조건을 추가해주어야함
     return when && currentLocation.pathname !== nextLocation.pathname
    }
  );

  useEffect(() => {
    // blocker의 state가 blocked일 때만 메서드를 사용할 수 있어서 조건문 추가
    if (blocker.state !== "blocked") return;

    if (window.confirm("정말로 이동하시겠습니까?")) {
      // 사용자에게 페이지 이동 확인을 요청하고, 확인을 눌렀을 때 (이동을 허용)
      blocker.proceed();
      
      // 여기서 커스텀 컴포넌트를 사용 할 수 있다.
      <Modal />
    } else {
      // 사용자에게 페이지 이동 확인을 요청하고, 취소를 눌렀을 때
      // blocker.state를 unblocked로 초기화하기 위함
      blocker.reset();
    }
  }, [blocker.state]);
};

export default usePrompt;

 

여기까지 react-router-dom을 사용해 웹 사이트의 탐색을 차단하는 경우였습니다. 해당 경우는 클라이언트 사이드에서의 내비게이션 역할을 차단하는 것으로 페이지 새로고침, URL 변경, 탭 닫기 등의 경우는 차단하지 못하는 것을 확인할 수 있습니다. 이러한 경우에는 beforeunload event handler를 사용하라고 react-router-dom 공식문서에 작성되어 있습니다.

 

beforeunload

예시코드에 beforeunload event handler를 추가하고 이어서 설명드리겠습니다.

import { useEffect } from "react";
import { useBlocker } from "react-router-dom";

const usePrompt = (when: boolean) => {
  const blocker = useBlocker(
    ({ currentLocation, nextLocation }) => {
     return when && currentLocation.pathname !== nextLocation.pathname
    }
  );
  
  const beforeUnloadHandler = (event: Event) => {
    event.preventDefault();
    event.returnValue = true;
  };

  useEffect(() => {
    if (blocker.state !== "blocked") return;

    if (window.confirm("정말로 이동하시겠습니까?")) {
      blocker.proceed();
    } else {
      blocker.reset();
    }
  }, [blocker.state]);
  
  useEffect(() => {
    when
      ? window.addEventListener("beforeunload", beforeUnloadHandler)
      : window.removeEventListener("beforeunload", beforeUnloadHandler);
  }, [when])
};

export default usePrompt;

beforeunload 이벤트의 경우 현재 창, 포함된 문서, 리소스가 언로드 되려고 할 때 발생합니다. 해당 이벤트는 사용자가 페이지를 닫거나 다시 로드하거나 다른 곳으로 이동하려고 할 때 브라우저에서 생성된 확인 상자를 트리거하여 정말 페이지를 떠날 것인지 확인하도록 하고 있습니다.

 

대화 상자를 트리거하는 방법으로는 이벤트 객체의 preventDefault() 메서드 호출, 이벤트 객체의 returnValue를 truthy 한 값으로 변경하여야 합니다.

 

beforeunload 이벤트는 몇 가지 문제가 있습니다.

특히 모바일 플랫폼에서는 안정적으로 실행되지 않습니다.

Firefox에서는 beforeunload 리스너가 있는 경우 페이지를 back/forward cache에 저장하지 않아 성능에 좋지 않습니다.

 

필요한 경우에만 리스너에 등록 후 필요하지 않은 경우 리스너에서 제거해주는 작업을 통해 성능 저하를 최소화할 수 있습니다.

 


React Router의 공식문서에서는 사용자의 탐색을 차단하는 것이 좋지 않은 패턴이라고 언급하고 있으며, 특히 폼 양식이 채워져 있는 상황에서 사용자가 이탈하는 것을 차단하는 대신, 폼 양식을 sessionStorage에 저장하여 사용자가 돌아올 때 자동으로 채우는 것을 고려하라고 제안하고 있습니다. 이러한 접근 방식은 사용자 경험을 향상할 수 있는 방법 중 하나로 소개되어 있습니다.

일반적으로 웹 사이트에서는 탐색을 차단하는 경우가 많지만, 이 외의 다양한 상황에서도 사용자 경험을 고려하는 것이 중요합니다. Prompt를 사용하지 않고도 다양한 방법으로 사용자의 경험을 개선할 수 있으며, 이를 위해 공식문서를 주의 깊게 읽어보는 것이 좋습니다. 코드와 훅이 어렵지 않아 사용에 큰 문제가 없을 것으로 판단되지만, 언제나 에러나 잘못된 정보가 발생할 수 있으니 필요하다면 공식문서를 참고하여 문제를 해결하는 것이 좋습니다. 댓글로 질문이나 의견을 남겨주시면 도움이 될 것입니다. 감사합니다 :)