내가 만들어본 뒤로가기 관련 커스텀 훅
회사에서 열심히 개발하다 보면 공통으로 요구하는 사항이 있다. 이러한 공통 요구 사항을 커스텀 훅으로 개발한 것 중 뒤로가기와 관련된 커스텀 훅 개발 경험을 공유해본다~!
커스텀 훅마다 JSdoc으로 파일 상단에 간단한 설명을 적었다.
useAppointedGoBack
import { useCallback, useEffect } from 'react';
import { useRouter } from 'next/router';
/**
* 현재 페이지에서 뒤로가기 시 지정된 url로 뒤로가기합니다.
*
* 앱 네이티브 Back은 useEffect로 처리합니다.
* 웹 헤더의 Back 경우, 해당 버튼 onClick에 goBack를 할당하면 됩니다.
*
* @param {string} url
* @returns {function: void}
*/
const useAppointedGoBack = (url: string) => {
const router = useRouter();
const goBack = useCallback(() => {
router.back();
}, [router]);
useEffect(() => {
router.beforePopState(() => {
router.replace(url);
return false;
});
return () => {
router.beforePopState(() => true);
};
}, [router, url]);
return goBack;
};
export default useAppointedGoBack;
useAppointedGoBack 은 뒤로가기 시 지정한 곳으로 뒤로 갈 수 있는 커스텀 훅이다. Page [A] → Page [B] → Page [C] 처럼 이어지는 플로우에서 예를 들면 Page [C]에서 뒤로가기 시 Page [B]가 아니라 Page [A] 혹은 플로우에 없는 아예 다른 페이지로 이동하고 싶은 요구사항이 종종 있다. 이러한 요구사항을 해결할 수 있는 커스텀 훅이다.
코드를 이해하기 위해선 goBack 함수와 useEffect 안에 있는 로직을 이해하면 된다.
goBack
const goBack = useCallback(() => {
router.back();
}, [router]);
간단한 함수다. goBack 함수를 실행하면 기본적인 뒤로가기를 수행한다. 기본 동작이라 useAppointedGoBack 를 사용하는 페이지에서 goBack 를 따로 사용하지 않아도 무방하지만, 응집성을 이유로 넣어놨다. (뒤로가기 수행은 페이지 내에서 처리하고 지정된 페이지로 이동은 이 훅에서 처리하면 헷갈릴 수도..)
useEffect 내부
useEffect(() => {
router.beforePopState(() => {
router.replace(url);
return false;
});
return () => {
router.beforePopState(() => true);
};
}, [router, url]);
먼저 beforePopState 를 알아야 하는데 뒤로가기를 클릭하면 아래와 같은 기능이 수행된다.
뒤로가기 버튼 클릭 → 브라우저 주소 변경 (히스토리는 하나 줄었음) → router.beforePopState 실행 → router.beforePopState 함수가 false 를 return 하면 주소만 변경되고 실제로 화면을 이동하지 않음
위 특성을 활용하여 Page [A] → Page [B] → Page [C] 인 케이스에서 Page [C]에서 뒤로가기 시 Page [A]로 이동하고 싶다면 url 에 Page [A]를 할당하고 뒤로가기 버튼 클릭 시 goBack 함수를 실행하도록 해주면 된다.
그럼 Page [A] → Page [B] → Page [C] 인 케이스에서 Page [C]에서 뒤로가기 시 Page [B]로 주소가 이동되고 router.beforePopState 의 callback이 실행되면서 Page [B]가 Page [A]로 replace된다.
replace를 사용한 이유
결과적으로 Page [A] → Page [B] → Page [C] 인 케이스에서 Page [C]에서 뒤로가기 시 Page [A]로 이동하면 히스토리는 Page [A] → Page [A]가 된다. 지정된 페이지로 이동하긴 했지만, 히스토리가 약간 이상해 보일 수도 있다.
구현은 여러 가지 방법이 있겠지만 일단 이렇게 로직을 짠 이유는 요구사항이 그렇기 때문이다.
기획자가 사용자에게 기대하는 플로우는 Page [A] → Page [B] → Page [C] → Page [A] 인데 Page [B] 또는 Page [C] 에서 뒤로가기를 할 수 있어서 Page [B] 또는 Page [C] 에서 뒤로가기 할 때 원래 이동해야 할 페이지로 이동해주세요 라는 요구사항이 발생한 것이다.
replace를 사용하면 현재 위치한 곳을 말 그대로 대체해버리기 때문에 replace를 활용해서 다시 돌아가면 안 되는 페이지를 다른 페이지로 대체할 수 있었다.
추가로 히스토리가 Page [A] → Page [A]가 되었으니 문제가 되지 않나요? 라고 생각할 수 있다. 이는 기능을 개발할 때 인지하고 있는 부분이고 위치 시킬 Page [A]가 되는 페이지는 해당 페이지에서 웹뷰를 종료한다거나 다른 방식의 뒤로가기를 수행하는 페이지로만 이동할 수 있게끔 기획자랑 협의가 되어있는 상태라 문제가 되지 않았다.
사용 예시
const Component = () => {
const goBack = useAppointedGoBack('이동을 원하는 페이지 URL');
...
return (
<Header
goBack={goBack}
needButton={false}
isShare={isShare}
XButton
/>
)
}
useScrollRestoration
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import Loading from '@/components/Loading';
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
interface Props {
key: string;
isLoading?: boolean;
}
/**
* 현재 페이지에 대한 스크롤 기록이 있을 경우, 해당 위치로 이동합니다.
*
* route 변경이 시작될 때, 세션 스토리지에 스크롤 값을 저장합니다.
*
* * Loading 상태를 표기하고 싶을 경우
* 1. param { isLoading }에 Loading State를 넣어줍니다.
* 2. 해당 훅의 return 값인 ScrollRestorationLoading, isScrollLoading로 컴포넌트에서 Loading 상태를 표기합니다.
* ex) src/features/credit/main/Normal/index.tsx
*
* * Loading 상태가 필요 없을 경우
*
* 1. param { isLoading }에 아무 값도 넣지 않으며,
* 해당 훅의 return 값인 ScrollRestorationLoading, isScrollLoading를 컴포넌트에서 사용하지 않습니다.
*
* @param {key} string - 고유한 string
* @param {boolean} isLoading - fetching 등에 대한 loading 값
*
* @returns {Component, boolean}
*/
const useScrollRestoration = ({ key = '', isLoading = false }: Props) => {
const router = useRouter();
const [scrollY, setScrollY] = useState<number>(0);
const [isScrollLoading, setIsScrollLoading] = useState<boolean>(true);
useEffect(() => {
if (!key) return undefined;
/**
* route 변경이 시작될 때, session 스토리지에 현재 위치를 저장합니다.
*/
const routeChangeStartHandler = () => {
sessionStorage.setItem(
`__next_scroll_${key}`,
JSON.stringify({
x: window.pageXOffset,
y: window.pageYOffset,
}),
);
};
router.events.on('routeChangeStart', routeChangeStartHandler);
return () => {
router.events.off('routeChangeStart', routeChangeStartHandler);
};
}, [key, router.events]);
useIsomorphicLayoutEffect(() => {
if (!key || isLoading || !router.isReady) return;
window.history.scrollRestoration = 'manual';
const scroll = sessionStorage.getItem(`__next_scroll_${key}`);
if (scroll) {
const { y } = JSON.parse(scroll);
setScrollY(y || 0);
sessionStorage.removeItem(`__next_scroll_${key}`);
}
setIsScrollLoading(false);
}, [isLoading, key, router.isReady]);
useIsomorphicLayoutEffect(() => {
if (isScrollLoading) return;
window.scrollTo(0, scrollY);
}, [isScrollLoading, scrollY]);
const ScrollRestorationLoading = () => {
if (isLoading || isScrollLoading) {
return <Loading />;
}
return (
<section
className="w-full"
style={{ height: scrollY }}
/>
);
};
return { ScrollRestorationLoading, isScrollLoading };
};
export default useScrollRestoration;
useScrollRestoration 은 뒤로가기 시 스크롤을 복원해주는 커스텀 훅이다.
// next.config.js
module.exports = {
experimental: {
scrollRestoration: true
}
}
위와 같이 NextJS에서 experimental 로 스크롤 복원을 제공해주긴 하지만, 이 훅을 개발할 때는 회사 프로젝트가 13 미만 버전이기도 했고 13.x.x 버전에서도 잘 작동하지 않는다는 Issue를 본 거 같아서 (13.x.x대에서 잘 안되다 해결되었다는 트윗) 직접 구현했고 현재도 잘 작동한다.
그리고 직접 만든 useScrollRestoration 은 Loading 컴포넌트가 노출되는 화면에서도 Loading이 끝나고 스크롤을 복원해주는 기능도 있다.
우선 route 변경이 시작될 때, 세션 스토리지에 스크롤 값을 저장하고 돌아올 때 해당 값으로 이동해서 스크롤 복원을 수행한다. 이는 NextJS의 scrollRestoration 기능 구현과 비슷할 것이다.
useScrollRestoration 은 Loading 상태를 표기하고 싶을 경우, Loading 상태를 표기하고 싶지 않은 경우로 나누어서 설명하겠다.
Loading 상태를 표기하고 싶지 않은 경우
이 경우는 일반적인 스크롤 복원과 같다.
useEffect(() => {
if (!key) return undefined;
/**
* route 변경이 시작될 때, session 스토리지에 현재 위치를 저장합니다.
*/
const routeChangeStartHandler = () => {
sessionStorage.setItem(
`__next_scroll_${key}`,
JSON.stringify({
x: window.pageXOffset,
y: window.pageYOffset,
}),
);
};
router.events.on('routeChangeStart', routeChangeStartHandler);
return () => {
router.events.off('routeChangeStart', routeChangeStartHandler);
};
}, [key, router.events]);
Page [A]에서 route가 변경될 때, 세션 스토리지에 스크롤 위치인 window.pageXOffset, window.pageYOffset 를 저장한다. key는 고유한 string 이고 key가 없다면 훅은 작동하지 않는다.
const router = useRouter();
const [scrollY, setScrollY] = useState<number>(0);
const [isScrollLoading, setIsScrollLoading] = useState<boolean>(true);
...
useIsomorphicLayoutEffect(() => {
if (!key || isLoading || !router.isReady) return;
window.history.scrollRestoration = 'manual';
const scroll = sessionStorage.getItem(`__next_scroll_${key}`);
if (scroll) {
const { y } = JSON.parse(scroll);
setScrollY(y || 0);
sessionStorage.removeItem(`__next_scroll_${key}`);
}
setIsScrollLoading(false);
}, [isLoading, key, router.isReady]);
useIsomorphicLayoutEffect(() => {
if (isScrollLoading) return;
window.scrollTo(0, scrollY);
}, [isScrollLoading, scrollY]);
스크롤 복원은 useLayoutEffect 로 수행한다. useEffect 는 paint 작업 이후 비동기로 작동하기 때문에 사용자가 보기에 페이지가 뜬 후, 스크롤이 이동되는 것처럼 보일 수 있다. 이는 유저 경험을 떨어뜨리기에 paint 이전에 동기적으로 수행하는 useLayoutEffect 를 사용했다.
추가로 NextJS warning을 제거하기 위해 useIsomorphicLayoutEffect 를 사용하였다.
코드 상단에 있는 useIsomorphicLayoutEffect 부터 보면 조건문이 있는데, key가 없거나 router가 준비가 안 되었을 때 return 한다.
isLoading 은 Loading 상태를 표기하고 싶을 때 필요하고 기본값은 false 기 때문에 여기서는 무시해도 된다.
스크롤을 수동으로 복원하기 위해 scrollRestoration 에 manual 를 할당하고 세션 스토리지에서 값을 가져와 state 에 저장하고 세션 스토리지에 있는 값을 삭제한다. (x값은 아직 쓸 일이 없어 사용은 안 한다.)
다음으로 isScrollLoading 값을 false 로 변경한다. 바로 window.scrollTo(0, scrollY); 를 사용해 이동해도 되지만, Loading 상태를 표기하고 싶을 때 필요한 로직이라 따로 상태를 나누어 처리했다.
코드의 두 번째 useIsomorphicLayoutEffect 에서는 스크롤 값을 가져왔고 state 에 저장된 해당 위치로 스크롤 이동한다.
사용 예시
const NormalCredit = () => {
...
useScrollRestoration({
key: '고유한 key string',
});
...
};
Loading 상태를 표기하고 싶을 경우
예를 들어, Page [A]는 페이지가 마운트 될 때 API 요청을 하고 그동안 Loading 컴포넌트를 보여준다. 다른 페이지로 이동했다가 Page [A]로 이동할 때도 다시 API 요청을 하고 Loading 컴포넌트를 보여준다.
이 페이지에서 로딩이 완료된 후 스크롤 복원을 하려면 위에 설명한 로직이나 NextJS에서 제공하는 기능으로는 스크롤 복원을 할 수 없다.
위 요구사항을 만족하기 위해 몇 가지 로직을 추가했다.
const useScrollRestoration = ({ key = '', isLoading = false }: Props)
useScrollRestoration 의 Props에 isLoading 를 할당할 수 있는데 이는 API 요청 중을 의미하는 값이다.
useIsomorphicLayoutEffect(() => {
if (!key || isLoading || !router.isReady) return;
...
}, [isLoading, key, router.isReady]);
아까 본 첫 번째 useIsomorphicLayoutEffect 에서 isLoading 이 true 일 때 (API 요청 중)는 스크롤 복원을 수행하지 않고 API 요청이 끝난 후 스크롤을 복원한다.
const ScrollRestorationLoading = () => {
if (isLoading || isScrollLoading) {
return <Loading />;
}
return (
<div
className="w-full"
style={{ height: scrollY }}
/>
);
};
API가 요청 중이거나, 스크롤 복원 로직이 아직 끝나지 않았을 경우는 Loading 컴포넌트를 보여준다. API 요청이 끝났고, 스크롤 복원 로직(기존 스크롤 위치를 state 에 저장)도 끝났다면 기존 스크롤 위치만큼 해당하는 빈 div 를 render 한다. 이는 viewport 100%에 해당하는 Loading 컴포넌트가 렌더된 상태에서 스크롤을 복원할 경우, 최대 높이가 100vh가 되어 스크롤 값이 100vh보다 클 때 원하는 높이만큼 스크롤 이동할 수 없기 때문이다.
위 ScrollRestorationLoading 컴포넌트를 Page [A]에서 Loading 상태를 표기할 때 컴포넌트로 사용하면 요구 사항을 만족할 수 있다.
사용 예시
const NormalCredit = () => {
...
const { isLoading } = useQuery(...); // 이 Query는 마운트 될 때마다 API를 요청해요~
const { ScrollRestorationLoading, isScrollLoading } = useScrollRestoration({
key: '고유한 key string',
isLoading
});
...
if(isScrollLoading) return <ScrollRestorationLoading />
return (
...
)
};
마무리
useAppointedGoBack , useScrollRestoration 이외에도 다른 훅도 많은데 생각보다 내용이 길어서 2개만 소개해봤다. 코드를 왜 이렇게 짰는지, 나조차 기억이 안 났는데 정리하며 예전에 했던 생각들과 왜 이렇게 해야 했는지 리마인드할 수 있어서 좋았다. 또 소개해볼 만한 커스텀 훅이 있으면 나중에 작성해보겠다~!
Reference
Functions: useRouter | Next.js
Learn more about the API of the Next.js Router, and access the router instance in your page with the useRouter hook.