CSR 페이지 전환 모션 (Recat Router v6.4)

Hong Kyu Chu (Mr. Chu)·2023년 11월 25일

React

목록 보기
1/1
post-thumbnail

구현 링크 (https://4mryxj.csb.app/)

안녕하세요

이 글에서는 리액트 라우터 돔v6.4 createBrowserRouter에서 페이지 전환시 어떻게 framer-motion의 exit를 활용하는지 이야기 해보려 합니다.

Exit animations
AnimatePresence works by detecting when direct children are removed from the React tree.
Any motion components contained by the removed child that have an exit prop will fire that animation before the entire tree is finally removed from the DOM.
Note: Direct children must each have a unique key prop so AnimatePresence can track their presence in the tree.

AnimatePresence는 React 트리에서 직접적인 자식이 제거될 때 작동합니다. 그렇기 때문에 트리에서 존재 여부를 추적할 수 있도록 고유한 key 속성을 가져야 합니다.

그래서 페이지 전환시 AnimatePresence의 자식 요소가 변했다는것을 알려주기 위해 자식요소로 Layout을 만들어 pathname으로 key값을 줍니다.

NEXT.js 예시

//Root
<RecoilRoot>
  <HeadComponent/>
	<AnimatePresence initial={false} exitBeforeEnter>
		 <Component {...pageProps} key={router.pathname} />
	</AnimatePresence>
</RecoilRoot>

//Layout
export default function Layout({children}:LayoutComponent) {
	return (
		<motion.div 
			initial={{opacity:0}} 
			animate={{opacity:[0,1]}} 
			exit={{opacity: [1,0]}}>
			{children}
		</motion.div>
	);
}

BrowserRouter

그렇다면 리액트 라우터 돔에서는 어떻게 해왔을까요?
중첩 라우팅 예시를 확인해 보겠습니다.

//Root
<BrowserRouter>
   <Routes>
      <Route index path="/" element={<Index />} />
      <Route path="/*" element={<NotFound />} />
      <Route path="/home/*" element={<Home />} />
  </Routes>
</BrowserRouter>

//Home
export function Home() {
	const location = useLocation()
	return(
		<>
		<Nav/>
		<Content/>
		<AnimatePresence initial={false}>
		   <Routes location={location} key={location.pathname}>
		     <Route path="resume" element={<Resume />} />
		     <Route path="about" element={<About />} />
		     <Route path="github" element={<GitHub />} />
		     <Route path="game_app" element={<Others />} />
		     <Route path="project" element={<Project />} />
		     <Route path="detail/:id" element={<Detail />} />
		   </Routes>
		</AnimatePresence>
		</>
	)
}

Data Api가 존재하지 않는 BrowserRouter 컴포넌트로 작업할 때
Routes에 key로 location.pathname를 할당하는 형식으로 작업 했습니다.

그러나 (createBrowserRouter)

v6.4부터 data Api를 지원하는 createBrowserRouter로 작업해야 하는 경우가 생겼습니다.

An <Outlet> should be used in parent route elements to render their child route elements. This allows nested UI to show up when child routes are rendered. If the parent route matched exactly, it will render a child index route or nothing if there is no index route.

하위 경로 요소를 렌더링하려면 상위 경로 요소에서 <Outlet> 를 사용해야 합니다.
그래서 아래와 같이 구성 합니다.


//Root
const router = createBrowserRouter(routerInfo)

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
     <RouterProvider router={router} />
  </React.StrictMode>
);

//router.js 
export const routerInfo = [
    {
      path: "/",
      element: <App/>,
      errorElement: <NotFound/>,
      children: [
        {
            path: "home",
            element: <Home/>,
            errorElement: <div>error</div>,
            children: [
              {
                  path: "resume",
                  element: <Resume />,
              },
              {
                 path: "detail/:id",
                 element: <Detail />,
								 loader: getDetailPage
              },
...

//Home
export function Home() {
	const location = useLocation()
	return(
		<>
		<Nav/>
		<Content/>
		<AnimatePresence initial={false}>
		   <Outlet/>
		</AnimatePresence>
		</>
	)
}

그렇다면 이제 어디에 키를 주어 AnimatePresence의 트리에 변화를 알릴까요?

Outlet에 key 값을 줘봅시다.

export function Home() {
	const location = useLocation()
	return(
		<>
		<Nav/>
		<Content/>
		<AnimatePresence initial={false}>
		   <Outlet key={loaction.pathname}/>
		</AnimatePresence>
		</>
	)
}

React Developer Tools를 보면 Outlet에 key 값이 들어갔지만 framer-motion에 exit가 일어나지 않습니다.

Outlet은 대체… 무엇일까?

interface OutletProps {
  context?: unknown;
}
declare function Outlet(
  props: OutletProps
): React.ReactElement | null;

[v6] First-class animation primitives · remix-run/react-router · Discussion #8008

링크에 글을 보고 알게 된 것은

Outletlocation이 변경될 때마다 다음에 렌더링할 컴포넌트를 찾아 렌더링하는 역할을 합니다. Outlet 자체는 내부적으로 상태를 유지하지 않고, location이 변경되면 해당 위치에 따라 매번 렌더링을 수행합니다.

Outlet은 단순히 중첩된 라우트 트리에서 현재 위치에 해당하는 하위 라우트를 찾아 렌더링하는 것이라는 겁니다.

따라서 Outlet은 상태를 가지지 않고 location에 의존하여 동적으로 컴포넌트를 렌더링하는 역할을 합니다. 상위 컴포넌트에서 location이 변경될 때마다 Outlet은 해당 위치에 따라 다음에 렌더링할 컴포넌트를 찾아 렌더링합니다.

그렇다면 우리는 어떻게 해야하는가?

function AnimatedOutlet() {
  let [context] = useState(useOutlet());
  return context.outlet;
}

글에서 처럼 useOutlet()을 사용하여 AnimatedOutlet 컴포넌트를 활용합시다.

const AnimatedOutlet = () => {
  const o = useOutlet();
  const [outlet] = useState(o);

  return <>{outlet}</>;
};

AnimatedOutlet의 역할은

  • 현재 라우트의 Outlet을 가져오기: useOutlet을 호출하여 현재 라우트에 해당하는 Outlet 컴포넌트를 얻습니다.
  • 상태로 Outlet 관리: useState를 사용하여 outlet을 상태로 관리합니다. 이는 리액트 컴포넌트의 상태로써 outlet이 변경될 때마다 컴포넌트가 리렌더링되도록 합니다.
  • UI에 Outlet 렌더링: JSX에서 {outlet}을 통해 현재 라우트에 해당하는 Outlet 컴포넌트를 렌더링합니다. 이로써 현재 라우트의 컴포넌트가 동적으로 표시됩니다.
<AnimatePresence mode="wait">
	<AnimatedOutlet key={location.pathname} />
</AnimatePresence>

요약

React Router v6.4 최신버젼에서 Data Api 지원하는 createBrowserRouter를 사용할 때 Outlet 컴포넌트를 사용하게 되는데 Outlet은 라우트가 변경될 때마다 단순히 새로운 컴포넌트를 렌더링합니다. 그래서 Outlet을 상태관리하는 AnimatedOutlet을 만들고 key 값을 사용하여 AnimatePresence가 라우트 변경을 감지하도록 합니다. 이로써 라우트 변경 시에 리마운트를 통한 애니메이션(exit) 효과를 얻을 수 있습니다.

코드 소스

아래는 아래와 같은 뎁스가 있는 화면전환을 코드샌드박스에 올려 놨습니다.

  • index
    • home
      • login
      • more
    • mypage

https://codesandbox.io/p/sandbox/routing-motion-4mryxj?layout=%257B%2522sidebarPanel%2522%253A%2522EXPLORER%2522%252C%2522rootPanelGroup%2522%253A%257B%2522direction%2522%253A%2522horizontal%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522id%2522%253A%2522ROOT_LAYOUT%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522clpdlsidb01t73b6j5pnade1m%2522%252C%2522sizes%2522%253A%255B70%252C30%255D%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522EDITOR%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522id%2522%253A%2522clpdlsida01t43b6jqi1hcok0%2522%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522SHELLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522id%2522%253A%2522clpdlsida01t63b6joa66483h%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522DEVTOOLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522id%2522%253A%2522clpdlsida01t53b6j2u0nknsb%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%252C%2522sizes%2522%253A%255B59.12336992480133%252C40.87663007519867%255D%257D%252C%2522tabbedPanels%2522%253A%257B%2522clpdlsida01t43b6jqi1hcok0%2522%253A%257B%2522id%2522%253A%2522clpdlsida01t43b6jqi1hcok0%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clpdlqcq401so3b6jljei20jf%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522FILE%2522%252C%2522filepath%2522%253A%2522%252Fpublic%252Findex.html%2522%252C%2522state%2522%253A%2522IDLE%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clpdlqcq401so3b6jljei20jf%2522%257D%252C%2522clpdlsida01t53b6j2u0nknsb%2522%253A%257B%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clpdl2bsw00053b6jkfzujanv%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522UNASSIGNED_PORT%2522%252C%2522port%2522%253A0%252C%2522path%2522%253A%2522%2522%257D%252C%257B%2522type%2522%253A%2522SANDBOX_INFO%2522%252C%2522id%2522%253A%2522clpdlphqp01jb3b6jbyq4stx8%2522%252C%2522mode%2522%253A%2522permanent%2522%257D%255D%252C%2522id%2522%253A%2522clpdlsida01t53b6j2u0nknsb%2522%252C%2522activeTabId%2522%253A%2522clpdl2bsw00053b6jkfzujanv%2522%257D%252C%2522clpdlsida01t63b6joa66483h%2522%253A%257B%2522tabs%2522%253A%255B%255D%252C%2522id%2522%253A%2522clpdlsida01t63b6joa66483h%2522%257D%257D%252C%2522showDevtools%2522%253Atrue%252C%2522showShells%2522%253Atrue%252C%2522showSidebar%2522%253Atrue%252C%2522sidebarPanelSize%2522%253A17.059659090909093%257D

0개의 댓글