React와 History API를 사용하여 react-router-dom 구현하기

React와 History API를 사용하여 react-router-dom 구현하기

생성일
2023년 07월 05일
태그
React
History API
SPA

Background

7월 원티드 프리온보딩 챌린지에서 SPA를 직접 구현하는 과제를 내주셨다. 이 과제를 통해 Single Page Application이 어떻게 작동하는지 더 자세하게 공부할 수 있을거라는 기대감과 함께 진행했으나, 생각보다 신경써야 할 부분이 많기도 했고, 아직 기본기가 부족하다는 느낌을 많이 받았다. 아무튼, 구현 및 리팩토링을 진행해보았다.

과제 구현 내용

구현해야하는 사항들은 다음과 같다.
  1. 해당 주소로 진입했을 때 아래 주소에 맞는 페이지가 렌더링될 것.
  1. 버튼을 클릭하면 해당 페이지로, 뒤로 가기 버튼을 누르면 이전 페이지로 이동할 것.
  1. Router , Route 컴포넌트를 구현하며, 다음과 같은 형태를 띌 것.
ReactDOM.createRoot(container).render( <Router> <Route path="/" component={<Root />} /> <Route path="/about" component={<About />} /> </Router> );
  1. 최소 push 기능을 가진 useRouter Hook을 작성할 것.
const { push } = useRouter();
 
해당 구현 사항만 확인해서는 별 거 없다고 느꼈는데, 신경쓸 것들이 많다. 처음 내가 단순하게 사고했던 것은 다음과 같다.
  1. Route 컴포넌트는 props로 path, component로 제공받고, path와 window.location.pathname 이 같으면 제공받은 컴포넌트를 return, 그렇지 않다는 null을 return 하면 되겠다.
  1. Router 컴포넌트는 단순히 껍데기 역할을 하지 않을까?

구현해보기

첫 번째로 진행했던 것은 useRouter Hook을 작성하였다.
// src/utils/use-router.tsx export default function useRouter() { // 뒤로가기 메서드 const back = () => { history.back(); }; // 특정 페이지 이동 메서드 const push = (url: string | URL | null | undefined) => { // url을 변경해준다. window.history.pushState({}, "", url); // url이 변경되었지만, 화면이 변하지 않아서 넣어주었는데 // TODO: location.href는 새로고침을 발생시키기 때문에 SPA 취지에 맞지 않다... window.location.href = window.location.pathname; }; return { back, push }; }
 
다음은, 각각의 페이지 컴포넌트를 작성하였다.
// src/pages/Root.tsx import React from "react"; import useRouter from "../utils/use-router"; export default function Root() { const { push } = useRouter(); const handleClickGoToAboutButton = () => { push("/about"); }; return ( <> <h1>This is Root Page.</h1> <button onClick={handleClickGoToAboutButton}>About</button> </> ); }
// src/pages/About.tsx import React from "react"; import useRouter from "../utils/use-router"; export default function About() { const { push } = useRouter(); const handleClickGoToMainButton = () => { push("/"); }; return ( <> <h1>This is About Page.</h1> <button onClick={handleClickGoToMainButton}>Go Main</button> </> ); }
 
Route 컴포넌트를 작성하였다. useState 로 현재 url을 로컬에서 상태관리하였고, props로 제공받은 path가 현재 url과 일치하면 제공받은 컴포넌트를, 그렇지 않으면 nulld을 return 하도록 하였다.
popstate 이벤트는 사용자가 브라우저의 ‘뒤로’, ‘앞으로’ 버튼을 클릭하거나, JavaScript의 history api 메서드들을 호출하여 이동할 때 발생한다.
// src/components/Route.tsx import { ReactNode, JSX, useEffect, useState } from "react"; interface RouteProps { path: string; component: JSX.Element; } export default function Route({ path, component }: RouteProps) { const [pathName, setPathName] = useState(window.location.pathname); useEffect(() => { const handlePopState = () => setPathName(window.location.pathname); window.addEventListener("popstate", handlePopState); return () => { window.removeEventListener("popstate", handlePopState); }; }, []); return pathName === path ? component : null; }
 
마지막으로, Router 컴포넌트이다. 처음 생각한 것처럼 단순하게 children 컴포넌트들을 return 시켜주었다.
// src/components/Router.tsx import React, { ReactNode, Children, ReactElement } from "react"; interface RouterProps { children: ReactNode; } export default function Router({ children }: RouterProps) { return children; }

문제점

눈으로 보기에는 작동하는 것처럼 보였으나, 완전 엉망인 구현이었다…
  1. useRouter 훅의 push 메서드에서 해당 함수가 호출될 때마다 url은 변경되지만, 화면이 변하지 않아서 명시적으로 다음과 같은 코드를 작성하였다.
window.location.href = window.location.pathname;
해당 코드는 페이지 새로고침을 발생시켰고, 이는 react-router-dom의 의도와 완전히 다르다. SPA가 아닌 것이다.
url이 변경되지만 화면이 변하지 않았던 이유는 setState 함수가 작동하면서 상태가 변화하고, 이에 따라 리렌더링이 발생하는데, 상태가 변하지 않으니 url이 변경되어도 화면이 바뀌지 않았던 것이다.
 
  1. 렌더링에 대한 로직이 Route 컴포넌트에서 이루어지는 것이 맞는 것인가? Route 컴포넌트는 말그대로 현재 url에 따라서 컴포넌트 혹은 null을 return하는 역할을 하고, 렌더링 로직은 부모 컴포넌트는 Router 에서 작성되는 것이 맞는 것 같다.
  1. Route 컴포넌트가 url에 따라서 component 혹은 null을 return하여 눈으로 보기에 잘 렌더링되는 것 같지만, 컴포넌트를 return하던, null을 return 하던간에 아무튼 Route 컴포넌트는 렌더링이 되었다. 눈으로 보기에만 다르지 페이지에 대한 모든 Route 컴포넌트가 렌더링되고 있는 것이다.

리팩토링 방안

위의 문제점들을 종합하여, 다음과 같이 리팩토링 방안을 설계하였다.
  1. url 상태는 Router 컴포넌트에서 context api를 활용하여 전역으로 관리하자. 페이지에 컴포넌트가 많고, 깊숙한 컴포넌트에서 상태를 변화시키게 된다면 props drilling이 발생할 수 있고, 이를 context api로 보완할 수 있다.
  1. Route 컴포넌트는 말그대로 페이지 컴포넌트를 보여주는 역할이다. 렌더링 로직은 부모 컴포넌트인 Router 에 작성하자.
  1. Route 컴포넌트가 url에 따라서 props로 제공받은 컴포넌트 혹은 null을 return 한다고 해도, 결국은 렌더링 된다. 서비스의 규모가 커짐에 따라서 Route 컴포넌트가 엄청 많아진다면…? 전달받은 props가 리액트 컴포넌트인지를 판단하여 선택적으로 렌더링시키자.
  1. Routes 컴포넌트를 만들어서 관심사를 분리하자.
      • Router - 이벤트 감지, path 상태관리
      • Routes - 하위 Route 컴포넌트 중 렌더링 해야하는 컴포넌트만 렌더링
      • Route - 말그대로 렌더링되는 컴포넌트, 아무 역할이 없는 껍데기

리팩토링!!

차근차근 진행해보자.
  1. Route 컴포넌트에는 아무런 로직이 없다. props로 전달받은 path 조차 사용되지 않음.
// src/components/Route.tsx import { ReactNode } from "react"; interface RouteProps { path: string; component: ReactNode; } export default function Route({ path, component }: RouteProps) { return <>{component}</>; }
 
  1. Router 컴포넌트는 path를 관리하는 context가 있고, provider의 역할을 한다.
import React, { ReactNode, useEffect, useState, createContext } from "react"; interface RouterProps { children: ReactNode; } interface RouterContextValue { currentPath: string; changePath: (to: string) => void; } // path 관리 context export const RouterContext = createContext<RouterContextValue | null>(null); export default function Router({ children }: RouterProps) { // path 상태 const [currentPath, setCurrentPath] = useState(window.location.pathname); useEffect(() => { const handleChangeCurrentPath = (event: PopStateEvent) => { setCurrentPath(event.state?.path ?? "/"); }; window.addEventListener("popstate", handleChangeCurrentPath); return () => { window.removeEventListener("popstate", handleChangeCurrentPath); }; }, []); // path 변경 const changePath = (to: string) => { window.history.pushState({ path: to }, "", to); setCurrentPath(to); }; const routerContextValue: RouterContextValue = { currentPath, changePath, }; return ( <RouterContext.Provider value={routerContextValue}> {children} </RouterContext.Provider> ); }
 
  1. useRouter Hook은 context의 changePath 를 활용한 push 메서드가 존재한다.
import { useContext } from "react"; import { RouterContext } from "../components/Router"; interface UseRouterType { push: (path: string) => void; pathname: string; } export default function useRouter(): UseRouterType { // context const RouterContextValue = useContext(RouterContext); if (RouterContextValue === null) { throw new Error("context 값이 null입니다."); } const { currentPath, changePath } = RouterContextValue; const pathname = currentPath; // 특정 페이지 이동 메서드 const push = (path: string) => { changePath(path); }; return { push, pathname }; }
 
  1. Routes 컴포넌트는 path에 따라서 알맞는 Route 컴포넌트를 렌더링시킨다.
import React, { ReactNode, isValidElement } from "react"; import useRouter from "../utils/use-router"; interface RoutesProps { children: ReactNode[]; } export default function Routes({ children }: RoutesProps) { const { pathname } = useRouter(); const isRenderComponent = (child: ReactNode): boolean => { // React 컴포넌트가 아니라면 false return if (!isValidElement(child)) { return false; } return child.props.path === pathname; }; return <>{children.find(isRenderComponent)}</>; }
 
다음과 같이 필요한 컴포넌트만 알맞게 렌더링한다.
notion image
 
전체 코드는 아래에 있다. References에 달아둔 분의 코드를 많이 참고했다. 내 힘으로 할 수 있도록 열심히 노력해야겠다.

References

댓글

guest