프론트엔드 첫걸음

[React Router] useBlocker 이해하기 본문

개발 공부/React

[React Router] useBlocker 이해하기

차정 2025. 3. 3. 16:00

개요

사용자의 입력을 요구하는 서비스에서는 화면을 떠날 때 '떠나시겠습니까?' 를 묻는 경우가 있다.
이 때 useBlocker를 잘 사용하면 더 좋은 코드를 작성할 수 있을것이다.

useBlocker란?

useBlocker는 React Router에서 네비게이션 차단 기능을 제공하는 훅이다.
사용자가 페이지를 이동하려 할 때 특정 조건이 충족되면 네비게이션을 차단할 수 있다.
즉, 페이지 이탈을 방지하는 역할을 한다.

 

useBlocker 코드 

React-Router 에서 useBlocker 코드를 찾아보자.

//1. 매개변수
export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker {

  let { router, basename } = useDataRouterContext(DataRouterHook.UseBlocker);
  let state = useDataRouterState(DataRouterStateHook.UseBlocker);

  let [blockerKey, setBlockerKey] = React.useState("");
  let blockerFunction = React.useCallback<BlockerFunction>(
    (arg) => {
      //함수아니면(boolean)이면 그대로 반환
      if (typeof shouldBlock !== "function") {
        return !!shouldBlock;
      }
      if (basename === "/") {
        return shouldBlock(arg);
      }

      // If they provided us a function and we've got an active basename, strip
      // it from the locations we expose to the user to match the behavior of
      // useLocation
        // 만약 사용자가 함수를 제공했고 활성화된 basename이 있다면,    
        // useLocation의 동작과 일치하도록 사용자에게 노출하는 위치 정보에서 basename을 제거한다.
      let { currentLocation, nextLocation, historyAction } = arg;
      return shouldBlock({
        currentLocation: {
          ...currentLocation,
          pathname:
            stripBasename(currentLocation.pathname, basename) ||
            currentLocation.pathname,
        },
        nextLocation: {
          ...nextLocation,
          pathname:
            stripBasename(nextLocation.pathname, basename) ||
            nextLocation.pathname,
        },
        historyAction,
      });
    },
    [basename, shouldBlock]
  );

  // This effect is in charge of blocker key assignment and deletion (which is
  // tightly coupled to the key)
  // 이 effect는 blocker 키를 할당하고 삭제하는 역할을 한다(키와 밀접하게 연결되어 있음).
  React.useEffect(() => {
    let key = String(++blockerId);
    setBlockerKey(key);
    return () => router.deleteBlocker(key);
  }, [router]);

  // This effect handles assigning the blockerFunction.  This is to handle
  // unstable blocker function identities, and happens only after the prior
  // effect so we don't get an orphaned blockerFunction in the router with a
  // key of "".  Until then we just have the IDLE_BLOCKER.
  // 이 effect는 blockerFunction을 할당하는 역할을 한다. 
  // 이는 blockerFunction의 불안정한 함수 식별자를 처리하기 위한 것으로, 
  // 이전 effect 이후에만 실행되어야 한다. 
  // 그렇지 않으면 키가 ""인 orphaned(고아 상태의) blockerFunction이 
  // 라우터에 남게 된다. 
  // 그 전까지는 IDLE_BLOCKER를 사용한다.
  React.useEffect(() => {
    if (blockerKey !== "") {
      router.getBlocker(blockerKey, blockerFunction);
    }
  }, [router, blockerKey, blockerFunction]);

// DataRouterContext는 메모이제이션되므로, 
// router.state가 아닌 state에서 blocker를 가져오는 것이 더 좋다. 
// 이렇게 하면 blocker 상태가 업데이트될 때 올바르게 반영될 수 있다.
  // Prefer the blocker from state not router.state since DataRouterContext
  // is memoized so this ensures we update on blocker state updates
  return blockerKey && state.blockers.has(blockerKey)
    ? state.blockers.get(blockerKey)!
    : IDLE_BLOCKER;
}

 

매개변수

export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker
  • shouldBlock:
    • boolean 값일 경우: true이면 네비게이션 차단, false이면 차단 안 함.
    • BlockerFunction일 경우: (arg) => boolean 형태의 함수로, 현재 위치(currentLocation), 이동할 위치(nextLocation), 히스토리 액션(historyAction)을 받아 네비게이션 차단 여부를 결정.
    • Blocker는 Block 상태에 대한 타입

 

타입

declare function useBlocker(
  shouldBlock: boolean | BlockerFunction
): Blocker;

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

type Blocker =
  | {
      state: "unblocked";
      reset: undefined;
      proceed: undefined;
      location: undefined;
    }
  | {
      state: "blocked";
      reset(): void;
      proceed(): void;
      location: Location;
    }
  | {
      state: "proceeding";
      reset: undefined;
      proceed: undefined;
      location: Location;
    };

interface Location<State = any> extends Path {
  state: State;
  key: string;
}

interface Path {
  pathname: string;
  search: string;
  hash: string;
}

enum HistoryAction {
  Pop = "POP",
  Push = "PUSH",
  Replace = "REPLACE",
}
  •  속성으로 state, location 갖고, 메서드로 reset과 proceed 가짐
  • 속성 (Properties)
    • state (blocker의 현재 상태)
      • unblocked - blocker가 대기 상태이며, 아직 네비게이션을 차단하지 않음.
      • blocked - blocker가 네비게이션을 차단한 상태.
      • proceeding - blocker가 proceed() 호출을 통해 차단된 네비게이션을 진행하는 중.
    • location
      • 차단된 네비게이션의 대상 위치(가려고 했던곳)
      • blocked 일 때의 location은 가려고 했던곳.
        (proceeding 일때의 location은  blocker.proceed() 호출 후 이동 중인 위치.)
* blocked와 proceeding을 분리한 이유
- "이동이 차단된 상태"와  "이동이 진행 중인 상태"를 명확히 구분해야 함.
- UI에서 다르게 처리할 수 있도록 설계 (예: blocked 상태에서는 confirm 창, proceeding 상태에서는 로딩 UI)
- reset()을 호출할 수 있는 상태(blocked)와 필요 없는 상태(proceeding)를 구분
  • 메서드 (Methods)
    • proceed()
      • blocked 상태일 때, blocker.proceed()를 호출하면 차단된 위치로 네비게이션을 진행할 수 있음.
    • reset()
      • blocked 상태일 때, blocker.reset()을 호출하면 blocker를 다시 unblocked 상태로 되돌려 현재 위치에 머물도록 함.

내부 동작

1) router, basename, state 가져오기

let { router, basename } = useDataRouterContext(DataRouterHook.UseBlocker);
let state = useDataRouterState(DataRouterStateHook.UseBlocker);
  • router: React Router의 내부 DataRouter 인스턴스를 가져와서 차단 기능을 수행할 수 있도록 함.
  • basename: 현재 라우트의 기본경로 (ex. /app)
  • state : 현재 useBlocker의 상태

2) blockerFunction 정의

export type BlockerFunction = (args: {
  currentLocation: Location;
  nextLocation: Location;
  historyAction: NavigationType;
}) => boolean;
let blockerFunction = React.useCallback<BlockerFunction>(
  (arg) => {
    if (typeof shouldBlock !== "function") {
      return !!shouldBlock;
    }
    if (basename === "/") {
      return shouldBlock(arg);
    }

    // basename이 존재하면, nextLocation과 currentLocation의 pathname에서 basename을 제거
    let { currentLocation, nextLocation, historyAction } = arg;
    return shouldBlock({
      currentLocation: {
        ...currentLocation,
        pathname:
          stripBasename(currentLocation.pathname, basename) ||
          currentLocation.pathname,
      },
      nextLocation: {
        ...nextLocation,
        pathname:
          stripBasename(nextLocation.pathname, basename) ||
          nextLocation.pathname,
      },
      historyAction,
    });
  },
  [basename, shouldBlock]
);
  • shouldBlock이 boolean이면 그대로 반환.
  • shouldBlock이 함수이면 BlockerFunction으로 실행.
  • { currentLocation, nextLocation, historyAction } 는 useBlock에 넘겨주는 shouldBlock함수의 매개변수(현재위치, 다음위치, 이동방식)
  • basename을 스트립해서(stripBasename) shouldBlock을 호출
    • basename이 /app이고 nextLocation.pathname이 /app/settings라면 settings로 변환.

3) blockerKey 할당 후 blockerFunction 등록

React.useEffect(() => {
  let key = String(++blockerId);
  setBlockerKey(key);
  return () => router.deleteBlocker(key);
}, [router]);

React.useEffect(() => {
  if (blockerKey !== "") {
    router.getBlocker(blockerKey, blockerFunction);
  }
}, [router, blockerKey, blockerFunction]);
  • 첫 번째 useEffect:
    blockerKey를 먼저 생성 (setBlockerKey(key))
    cleanup 함수에서 router.deleteBlocker(key)를 호출하여 언마운트 시 제거
  • 두 번째 useEffect:
    blockerKey가 ""이 아닐 때 router.getBlocker(blockerKey, blockerFunction)을 실행
    즉, blockerKey가 설정된 후에만 blockerFunction을 등록하도록 순서를 조정

4) blocker 상태 반환

return blockerKey && state.blockers.has(blockerKey)
  ? state.blockers.get(blockerKey)!
  : IDLE_BLOCKER;
  • state.blockers에서 blockerKey에 해당하는 blocker상태를 가져와 반환.
  • 존재하지 않으면 기본값 IDLE_BLOCKER 반환.

*만약 blockerKey 없이 router.getBlocker("", blockerFunction)이 실행되면, 빈 키를 가진 blocker가 등록될 수 있음.
이런 상태에서는 useBlocker가 정상적으로 동작하지 않음.
그래서 blockerKey가 설정될 때까지 IDLE_BLOCKER(차단이 없는 기본 상태)를 유지하는 방식으로 처리.

  function getBlocker(key: string, fn: BlockerFunction) {
    let blocker: Blocker = state.blockers.get(key) || IDLE_BLOCKER;

    if (blockerFunctions.get(key) !== fn) {
      blockerFunctions.set(key, fn);
    }

    return blocker;
  }

*RouterState는 React Router가 내부적으로 관리하는 상태정보에 대한 인터페이스로 ,
이 RouterState가 가진 속성중의 하나가 blockers(활성화 된 blocker들의 맵).

*getBlocker로 Blocker상태(BlockerUnblocked | BlockerBlocked | BlockerProceeding)를 반환함.

요약 

  1. shouldBlock이 true이거나 특정 조건을 만족하면 네비게이션을 차단한다.
  2. 차단된 상태에서 blocker.proceed()를 호출하면 이동을 허용하고, blocker.reset()을 호출하면 이동을 취소한다.
  3. blocker.state를 통해 현재 상태(blocked, proceeding, unblocked)를 확인하고 적절한 UI 처리를 할 수 있다.

 

 

사용 예시

import { useBlocker } from "./useBlocker";
import useEffect From 'react'
function FormPage() {
  const [isDirty, setIsDirty] = React.useState(false);

  const blocker = useBlocker(() => isDirty);

  useEffect(() => {
    if (blocker.state === "blocked") {
      if (window.confirm("정말로 나가시겠습니까? 변경 사항이 저장되지 않습니다.")) {
        blocker.proceed(); //이동하려던 페이지로 이동
      } else {
        blocker.reset(); //unblocked 상태로 되돌려 현재 위치에 머물도록 함.
        //새로고침이 아니라 네비게이션 차단 상태(blocked)를 해제하고 현재 페이지에 그대로 머무르게 됨.
      }
    }
  }, [blocker]);

  return (
    <form>
      <input onChange={() => setIsDirty(true)} />
      <button type="submit">저장</button>
    </form>
  );
}

[[https://reactrouter.com/6.29.0/hooks/use-blocker#useblocker]]

 

useBlocker v6.29.0 | React Router

 

reactrouter.com

 

결론

 사용자의 입력이 필요한 페이지에서는, 입력 도중 페이지를 벗어나려 할 때 이를 막고 사용자에게 경고하는 기능이 필요하다. useBlocker를 활용하면 이러한 동작을 효과적으로 구현할 수 있다.
 useBlocker를 활용하면 React Router 기반으로 깔끔하고 확장 가능한 네비게이션 차단 로직을 구현할 수 있으며, 불필요한 window.onbeforeunload 남용을 줄일 수 있다.