React Router v6을 쓰면서 중첩 라우팅을 구현하고, 거기에 페이지 전환 애니메이션도 넣으려고 하는 상황. 이때, 같은 경로를 쓰면서 다른 컴포넌트를 보여주는 페이지도 있다.
같은 경로를 쓰는 이유는 따로 경로로 쓰는 것이 아니라 화면 뷰를 바꾸는 것이 더 낫겠다는 생각이었다. 따라서 다음과 같이 구성하였다.
또한, detail 페이지를 제외한 나머지 화면엔 공통으로 들어가는 Header가 존재한다. 이 Header의 검색박스에 접근하면 화면 뷰가 검색 모드로 변환된다. 이 모드는 경로가 /일 때든, /search 일 때는 둘 다 작동하게끔 한다.
경로 /에선 Outlet에 LandingView가 보여야 하고, isSearchMode가 되면 SearchModeView가 보여야 한다.
경로 /search에선 Outlet에 SearchResultView가 보여야 하고, 마찬가지로 검색모드로 진입이 가능해야 한다.
헤더를 공유하는 페이지끼리는 아래 View만 바뀌어야 한다. 예를 들어, 경로 /와 경로 /search 사이의 이동은 LandingView와 SearchResultView만 바뀌어야 한다.
헤더를 공유하지 않는 페이지로 간다면 전체 View가 바뀐다. 예를 들어, 경로 /와 경로 /search에서 /detail 페이지로 간다면 헤더를 포함한 페이지가 바뀌어야 한다.
일단 좀 위의 페이지는 구성이 복잡해서, 다른 코드로 단순화 하였다.
import { useState } from "react";
import {
NavLink,
Outlet,
Route,
RouterProvider,
createBrowserRouter,
createRoutesFromElements,
useNavigate,
} from "react-router-dom";
import { motion, Variants } from "framer-motion";
const fadeAnimation: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
const AnimatedLayout = () => {
return (
<div>
<Outlet />
</div>
);
};
const RootLayout = () => {
const navigate = useNavigate();
const [isSearchMode, setIsSearchMode] = useState(false);
const handleClick = () => {
setIsSearchMode(false);
navigate("/search");
};
return (
<div>
<header>
<button onClick={() => setIsSearchMode(!isSearchMode)}>
Let's Search
</button>
</header>
{isSearchMode ? <InputComponent onClick={handleClick} /> : <Outlet />}
</div>
);
};
const InputComponent = ({ onClick }: { onClick: () => void }) => {
return (
<div>
<input />
<button onClick={onClick}>찾기</button>
</div>
);
};
const LandingPage: React.FC = () => {
return <div>This is LandingPage</div>;
};
const SearchPage: React.FC = () => {
return (
<div>
<p>This is SearchPage</p>
<p>
<NavLink to="/">Go LandingPage</NavLink>
</p>
<p>
<NavLink to="/detail">This is Content</NavLink>
</p>
</div>
);
};
const DetailPage: React.FC = () => {
const navigate = useNavigate();
return (
<div>
<p>This is DetailPage</p>
<button onClick={() => navigate(-1)}>Back</button>
</div>
);
};
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<AnimatedLayout />}>
<Route path="/" element={<RootLayout />}>
<Route index element={<LandingPage />} />
<Route path="search" element={<SearchPage />} />
</Route>
<Route path="detail" element={<DetailPage />} />
</Route>
)
);
function App() {
return <RouterProvider router={router} />;
}
export default App;
Router 설정 부분만 따로 봐보자.
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<AnimatedLayout />}>
<Route path="/" element={<RootLayout />}>
<Route index element={<LandingPage />} />
<Route path="search" element={<SearchPage />} />
</Route>
<Route path="detail" element={<DetailPage />} />
</Route>
)
);
function App() {
return <RouterProvider router={router} />;
}
v6로 오면서 BrowserRouter를 직접 선언하는 게 아니고 createBrowserRouter라는 걸로 인스턴스를 생성할 수 있다. 그리고 그 안에 Routes들을 또 createRoutesFromElements라는 걸로 구성을 할 수 있다.
Routes 안에 서브를 넣는 게 아니라, Route안에 자식으로 넣으면 중첩 라우팅을 할 수 있다.
나는 전체적인 AnimatedLayout으로 한 번 Outlet을 지정해주고, 그 다음 /안에서 index과 /search에 대해 RootLayout을 통해 Outlet을 지정해주었다.
이제 Framer Motion을 통해서 각 뷰의 컨테이너를 motion.div로 바꾸고 효과를 주면 되는데, 이때부터 나는 엄청난 삽질을 시작했다 ..... 너무 많은 문제를 겪어서 대충 상황만 보면 이렇다.
{isSearchMode ? <InputComponent onClick={handleClick} /> : <Outlet />}
나랑 문제 상황이 비슷해서, 어떻게 해결했나 봤는데 주요 포인트는 '새로운 Routes Key'를 지정해 주는 것이었다. 또, Nest되는 곳에서 다시 Routes를 써서 분기를 나눠주었더라.
The reason why AnimatePresence is re-animating the parent route upon child navigation is because of the key prop. When the key changes, React treats it as an entirely new component. Since the location.key will always change when a route changes, passing location.key to both Routes components will trigger the route animations every time.
그래서 걍 나도 v5으로 일단 해결 봤다(...) v6으로 해보려고 했는데 잘 안 되더라고? 😇
1. index에서 BrowserRouter 뿌려주기
<BrowserRouter>
<Routes>
<Route path="/*" element={<App />} />
</Routes>
</BrowserRouter>
2. App에서는 크게크게만 나눠주기
이때~ /와 /search는 같은 헤더를 가지기 때문에 키가 변하면 안 된다. 따로 처리해준다.
function App() {
const location = useLocation();
const getRouteKey = (pathname: string) => {
if (pathname === "/") return "root";
if (pathname.startsWith("/search")) return "root";
if (pathname.startsWith("/detail")) return "detail";
};
return (
<AnimatePresence initial={false} mode="wait">
<Routes location={location} key={getRouteKey(location.pathname)}>
<Route path="/*" element={<RootLayout />} />
<Route path="/detail" element={<DetailPage key="detail" />} />
</Routes>
</AnimatePresence>
);
3. 중첩 라우팅에서 다시 뿌려주기
LandingPage와 SearchResult를 구분해줘야 하므로 해당하는 키를 뿌려준다.
const getRouteKey = (pathname: string) => {
if (pathname === "/") return "landing";
if (pathname.startWidth("/search")) return "search";
};
<AnimatePresence initial={false} mode="wait">
{isSearchMode ? (
<InputComponent onClick={handleClick} />
) : (
<Routes location={location} key={getRouteKey(location.pathname)}>
<Route index element={<LandingPage />} />
<Route path="search" element={<SearchPage />} />
</Routes>
)}
</AnimatePresence>
굿
import { useState } from "react";
import {
NavLink,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router-dom";
import { AnimatePresence, motion, Variants } from "framer-motion";
const fadeAnimation: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
const RootLayout = () => {
const getRouteKey = (pathname: string) => {
console.log("RootLayout: ", pathname);
if (pathname === "/") return "landing";
if (pathname === "/search") return "search";
};
const location = useLocation();
const navigate = useNavigate();
const [isSearchMode, setIsSearchMode] = useState(false);
const handleClick = () => {
setIsSearchMode(false);
navigate("/search");
};
return (
<motion.div
key="root"
variants={fadeAnimation}
initial="initial"
animate="animate"
exit="exit"
>
<header>
<button onClick={() => setIsSearchMode(!isSearchMode)}>
Let's Search
</button>
</header>
<AnimatePresence initial={false} mode="wait">
{isSearchMode ? (
<InputComponent onClick={handleClick} />
) : (
// <AnimatePresence initial={false} mode="wait">
<Routes location={location} key={getRouteKey(location.pathname)}>
<Route index element={<LandingPage />} />
<Route path="search" element={<SearchPage />} />
</Routes>
// </AnimatePresence>
)}
</AnimatePresence>
</motion.div>
);
};
const InputComponent = ({ onClick }: { onClick: () => void }) => {
return (
<motion.div
variants={fadeAnimation}
initial="initial"
animate="animate"
exit="exit"
key="searchmode"
>
<input />
<button onClick={onClick}>찾기</button>
</motion.div>
);
};
const LandingPage: React.FC = () => {
return (
<motion.div
key="landing"
variants={fadeAnimation}
initial="initial"
animate="animate"
exit="exit"
>
This is LandingPage
</motion.div>
);
};
const SearchPage: React.FC = () => {
return (
<motion.div
variants={fadeAnimation}
initial="initial"
animate="animate"
exit="exit"
key="search"
>
<p>This is SearchPage</p>
<p>
<NavLink to="/">Go LandingPage</NavLink>
</p>
<p>
<NavLink to="/detail">This is Content</NavLink>
</p>
</motion.div>
);
};
const DetailPage: React.FC = () => {
const navigate = useNavigate();
return (
<motion.div
key="detail"
variants={fadeAnimation}
initial="initial"
animate="animate"
exit="exit"
>
<p>This is DetailPage</p>
<button onClick={() => navigate(-1)}>Back</button>
</motion.div>
);
};
function App() {
const location = useLocation();
const getRouteKeyB = (pathname: string) => {
if (pathname === "/") return "root";
if (pathname.startsWith("/search")) return "root";
if (pathname.startsWith("/detail")) return "detail";
};
return (
<AnimatePresence initial={false} mode="wait">
<Routes location={location} key={getRouteKeyB(location.pathname)}>
<Route path="/*" element={<RootLayout />} />
<Route path="/detail" element={<DetailPage key="detail" />} />
</Routes>
</AnimatePresence>
);
// const router = createBrowserRouter(
// createRoutesFromElements(
// <Routes location={location} key={location.pathname}>
// <Route element={<AnimatedLayout />}>
// <Route path="/" element={<RootLayout />}>
// <Route index element={<LandingPage />} />
// <Route path="search" element={<SearchPage />} />
// </Route>
// <Route path="detail" element={<DetailPage key="detail" />} />
// </Route>
// </Routes>
// )
// );
// return <RouterProvider router={router} />;
}
export default App;
후기 너무 웃기네요 ㅋㅋㅋㅋ