리액트 뒤로가기 제어

Goyoung2·2023년 1월 9일
6
post-thumbnail

인사

안녕하세요~ 👋 예전에 작성해두었던 리액트 뒤로가기 제어 문서인데 이제야 올리네요. 참고하셔서 좋은 리액트 앱을 만드시길 바래요~

뒤로가기를 제어해야하는 경우

  • 이전 페이지로 접근을 막아야할 때 (트랜잭션 실행 페이지, 한번만 노출되어야하는 페이지 등)
  • 뒤로가기(백버튼)로 UI를 제어할 때 (모달, 드로워, 액션시트 등)

뒤로가기의 특징

  • 뒤로가기는 popstate 이벤트를 발생시키고, cancel 불가능해요.
  • 뒤로가기 행위 자체를 막을 수 없기 때문에, 뒤로가기를 감지하여 적절한 조치를 해야해요.

뒤로가기 제어 방법(리액트)

1. 뒤로가기 막기: react-router의 listen(), push()

  • 뒤로가기를 막고자 하는 페이지에 마운트시 listen()을 등록해요.
  • listen((location, action) => { … })의 action 을 이용해서 뒤로가기를 할 경우 action = POP을 감지할 수 있어요.
  • POP 액션 감지시 push()하여 뒤로가기를 막을 수 있어요.
  • action 실행시 unlisten()을 실행해서 리스너를 해제해요.
  • 사용 예시:
// Test2.tsx
const Test2 = () => {
  const history = useHistory()
  useBlock(history.location) // 뒤로가기 막기
  
  return (
    <>
      <div>Test2</div>
      <div>
        <button onClick={history.goBack}>Back</button>
      </div>
    </>
  )
}
export default Test2
  • 아래 useBlock을 보면 action === 'POP'일때 원하는 동작을 실행시켜요.
// useBlock.ts
// location: 뒤로가기 막을 페이지 location
const useBlock = (location: Location | Pathname) => {
const history = useHistory()
  
  useEffect(() => {
    const unlisten = history.listen((_, action) => {
      // 리스너 해제
      unlisten() 
      
      // 뒤로가기 시 동작
      if (action === 'POP') {
        history.push(location) 
      }
    })
  }, [history, location])
}
export default useBlock

2. UX 제어: push({ hash }) 또는 push({ state }), goBack()

  • UX가 열릴때 push({hash}) 또는 state 추가
  • UX가 닫힐때 goBack()
  • 사용예시:
// Test3.tsx
const Test3 = () => {
  const { setHash, removeHash, HashElement } = useHash()

  return (
    <Test3Wrapper>
      <Button onClick={() => setHash('modal')}>모달 열기</Button>
      
      <HashElement>
        <Modal>
          <div>모달입니다.</div>
          <Button onClick={() => setHash('other')}>해쉬추가</Button>
          <Button onClick={removeHash}>닫기</Button>
        </Modal>
      </HashElement>
    </Test3Wrapper>
  )
}
  • useHash
/**
 * hash를 추가하여 뒤로가기 UX 제어
 */
const useHash = () => {
  const history = useHistory()
  const hashString = history.location.hash.replace('#', '')
  const hashArray = hashString.split('#')

  // 해쉬추가
  const setHash = (hash: string) => {
    history.push({ hash: hashString ? `${hashString}#${hash}` : hash })
  }
  // 해쉬제거
  const removeHash = () => history.goBack()
  
  // 해쉬엘리먼트
  const HashElement = ({ children }: { children: ReactNode }) => {
    return <>{hashString && hashArray.map(() => children)} </>
  }

  return { setHash, removeHash, HashElement, hash: hashString }
}
export default useHash

2-1 searchParams 활용(추가)

  • useBlockToBack 훅 만들기
"use client";

import { Route } from "next";
import {
  ReadonlyURLSearchParams,
  usePathname,
  useRouter,
  useSearchParams,
} from "next/navigation";
import { useCallback, useLayoutEffect, useState } from "react";

/**
 * 브라우저 뒤로가기를 막아주는 훅.
 * removeBlocking: 블로킹 해제.
 * resumeBlocking: 블로킹 재개.
 */
export function useBlockToBack() {
  // blocking 상태
  const [activate, setActivate] = useState(true);

  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  // search에 block-to-back 추가하는 함수
  const addBlocking = useCallback(() => {
    if (!activate) {
      return;
    }
    const queryString = createQueryString(
      searchParams,
      "block-to-back",
      "true"
    );
    const pathnameWithSearch = (pathname + "?" + queryString) as Route;

    if (searchParams.get("block-to-back") == null) {
      router.push(pathnameWithSearch);
    }
  }, [activate, pathname, router, searchParams]);

  // block-to-back 제거
  function removeBlocking() {
    if (!activate) {
      return;
    }
    setActivate(false);
    router.back();
  }

  // block-to-back 다시 추가
  const resumeBlocking = () => {
    if (activate) {
      return;
    }
    setActivate(true);
    const queryString = createQueryString(
      searchParams,
      "block-to-back",
      "true"
    );
    const pathnameWithSearch = (pathname + "?" + queryString) as Route;

    if (searchParams.get("block-to-back") == null) {
      router.push(pathnameWithSearch);
    }
  };

  useLayoutEffect(() => {
    addBlocking();
  }, [addBlocking]);

  return { removeBlocking, resumeBlocking };
}

// 쿼리스트링의 search 변경
export function createQueryString(
  searchParams: ReadonlyURLSearchParams,
  name: string,
  value: string | null
) {
  const params = new URLSearchParams(searchParams);
  if (value === null) {
    params.delete(name);
  } else {
    params.set(name, value);
  }
  return params.toString();
}
  • BlockingPage
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "~/components/Button";
import { useBlockToBack } from "../useBlockToBack";

export default function BlockingPage() {
  const router = useRouter();

  const { removeBlocking, resumeBlocking } = useBlockToBack();

  return (
    <div>
      <h1 className="headline1">Blocking page</h1>
      <br />
      <Button color="primary" onClick={() => router.back()}>
        뒤로
      </Button>
      <br />
      <br />
      <Button color="primary" onClick={() => removeBlocking()}>
        블로킹제거
      </Button>
      <br />
      <br />
      <Button color="primary" onClick={() => resumeBlocking()}>
        블로킹재개
      </Button>
      <br />
      <br />
      <Button asChild color="primary">
        <Link href={"/other"}>/other</Link>
      </Button>
    </div>
  );
}

3. 글로벌 스토어 활용

  • 글로벌 스토어push()를 사용하여 뒤로가기를 막을 수 있어요.
  • 예를들어, 글로벌 스토어를 구독중인 A → B → C 페이지
  • A페이지에서 storeData = true로 초기화해요.
  • B페이지에서
    • 다음 버튼 클릭 시 트랜잭션을 처리하고 C페이지로 push()해요.
    • 마운트 시 storeData = false일 경우, C페이지로 push()해요.
  • C페이지에서 storeData = false로 변경해요. 뒤로가기 실행시 B페이지로 이동하지만 트랜잭션 처리 없이 곧바로 C페이지로 이동돼요.

(기타) 새로고침, 종료 막기

  • 창이 닫히거나, 새로고침 시에 beforeunload 이벤트가 발생해요. beforeunload는 cancel 가능해요.
  • e.preventDefault()로 이벤트를 취소시킬 수 있어요.
  • 브라우저에서 확인창을 띄워 페이지를 이탈할건지 물어보고, 취소 버튼을 누르면 페이지 이탈이 취소돼요.
  • 예시 코드:
// SomePage.tsx
const preventClose = (e: BeforeUnloadEvent) => {
  e.preventDefault()
  e.returnValue = '' // for chrome. deprectaed.
}

useEffect(() => {
  window.addEventListener('beforeunload', preventClose)
  return () => {
    window.removeEventListener('beforeunload', preventClose)
  }
}, [])

읽어주셔서 감사해요~ 뒤로가기의 원리를 이해하는게 중요한 것 같아요. 다들 좋은 하루 되세요~ 👋😄

profile
프론트엔드 엔지니어로 일하고 있어요. 제품, 동료, 성장을 중요시해요. 겸손, 존중, 신뢰를 갖춘 동료가 되기 위해 노력해요. 😄

0개의 댓글