본문 바로가기
JavaScript/React

React Router DOM

by curious week 2025. 3. 21.

React에서 페이지 이동 방법

페이지 전환이 필요한 경우 react-router-dom을 사용하는 것이 가장 좋습니다.


1. React Router (react-router-dom)

React에서 페이지 이동을 구현하는 표준 라이브러리.

설치

npm install react-router-dom

기본 사용법

import { BrowserRouter, Routes, Route, Link } from "react-router-dom";

const Home = () => <h1>홈 페이지</h1>;
const About = () => <h1>소개 페이지</h1>;

const App = () => {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">홈</Link> | <Link to="/about">소개</Link>
      </nav>

      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </BrowserRouter>
  );
};

export default App;

설명

  • <BrowserRouter> → 라우팅 기능 활성화
  • <Routes> → 여러 개의 <Route>를 감싸는 컨테이너
  • <Route path="/" element={<Home />} /> → URL이 /이면 <Home />을 렌더링
  • <Link to="/about">소개</Link> → <a> 대신 사용 (페이지 리로드 없이 이동 가능)

2. useNavigate()를 이용한 프로그래밍 방식 이동

Link 대신 버튼 클릭 시 페이지 이동을 원한다면 useNavigate() 훅을 사용할 수 있음.

import { useNavigate } from "react-router-dom";

const Home = () => {
  const navigate = useNavigate();

  return (
    <div>
      <h1>홈 페이지</h1>
      <button onClick={() => navigate("/about")}>소개 페이지로 이동</button>
    </div>
  );
};

export default Home;

설명

  • useNavigate()를 사용하면 페이지 리로드 없이 동적으로 이동 가능
  • navigate(-1) → 뒤로 가기 (브라우저 history.back()과 동일)
  • navigate("/about", { replace: true }) → 현재 URL을 대체하여 이동 (뒤로 가기 X)

3. URL 파라미터와 쿼리스트링

1) URL 파라미터 (:id)

동적인 페이지 이동을 구현할 때 사용 (/profile/:username)

import { useParams } from "react-router-dom";

const Profile = () => {
  const { username } = useParams();
  return <h1>{username}님의 프로필</h1>;
};

// 라우팅 설정
<Routes>
  <Route path="/profile/:username" element={<Profile />} />
</Routes>;

/profile/mars → "mars님의 프로필"
/profile/jimin → "jimin님의 프로필"


2) 쿼리스트링 (?key=value)

useLocation()을 사용하여 URL 쿼리스트링 값을 가져올 수 있음.

import { useLocation } from "react-router-dom";

const SearchPage = () => {
  const location = useLocation();
  const queryParams = new URLSearchParams(location.search);
  const keyword = queryParams.get("q");

  return <h1>검색 결과: {keyword}</h1>;
};

// 예: /search?q=React

쿼리스트링 값 읽기

  • /search?q=React → "검색 결과: React"
  • /search?q=JavaScript → "검색 결과: JavaScript"

4. useEffect()와 함께 useNavigate() 활용

페이지 로드 후 자동으로 이동하고 싶다면 useEffect()와 useNavigate()를 함께 사용.

import { useEffect } from "react";
import { useNavigate } from "react-router-dom";

const RedirectComponent = () => {
  const navigate = useNavigate();

  useEffect(() => {
    setTimeout(() => {
      navigate("/home");
    }, 3000); // 3초 후 자동 이동
  }, [navigate]);

  return <h1>3초 후 홈으로 이동합니다...</h1>;
};

3초 후 /home 페이지로 자동 이동됨.


React Router vs Window.location

React Router (react-router-dom) 페이지 리로드 없이 부드러운 전환, SPA 최적화 라이브러리 설치 필요
window.location.href 간단한 방식, 모든 환경에서 작동 페이지가 새로고침됨 (SPA 아님)

window.location.href (비추천)

window.location.href = "/about"; // 새로고침이 발생함!

React에서는 react-router-dom을 사용하는 것이 훨씬 좋음


React Router (react-router-dom) 설정 및 사용법


1. 프로젝트 폴더 구조

React에서 페이지 이동을 관리하려면 폴더 구조를 깔끔하게 정리하는 것이 중요합니다.

📂 src
 ┣ 📂 components  → (공통 UI 컴포넌트)
 ┣ 📂 pages        → (각 페이지 컴포넌트)
 ┃ ┣ 📄 Home.js
 ┃ ┣ 📄 About.js
 ┃ ┣ 📄 Profile.js
 ┃ ┗ 📄 NotFound.js
 ┣ 📂 routes       → (라우터 관련 설정)
 ┃ ┗ 📄 AppRouter.js
 ┣ 📄 App.js       → (최상위 컴포넌트)
 ┗ 📄 index.js     → (ReactDOM 렌더링)

pages/ → 페이지별 컴포넌트 (Home.js, About.js, Profile.js)
routes/ → AppRouter.js에서 라우팅 설정을 관리
App.js → AppRouter.js를 불러와서 사용


2. react-router-dom 설치

npm install react-router-dom

3. 페이지 컴포넌트 만들기 (pages/)

라우팅할 페이지를 만들고, 각각을 export default 해야 합니다.

Home.js (홈 페이지)

const Home = () => {
  return <h1>홈 페이지</h1>;
};

export default Home;

About.js (소개 페이지)

const About = () => {
  return <h1>소개 페이지</h1>;
};

export default About;

Profile.js (동적 라우팅)

URL에 따라 다른 내용을 표시하는 동적 페이지 (예: /profile/사용자이름)

import { useParams } from "react-router-dom";

const Profile = () => {
  const { username } = useParams(); // URL의 :username 값 가져오기

  return <h1>{username}님의 프로필 페이지</h1>;
};

export default Profile;

/profile/mars → "mars님의 프로필 페이지"
/profile/jimin → "jimin님의 프로필 페이지"


NotFound.js (404 페이지)

없는 페이지에 접근했을 때 표시할 Not Found 페이지 (기본적으로 * 경로로 설정)

const NotFound = () => {
  return <h1>404: 페이지를 찾을 수 없습니다.</h1>;
};

export default NotFound;

4. AppRouter.js에서 라우팅 설정

React Router를 설정하는 핵심 부분!

import { BrowserRouter, Routes, Route } from "react-router-dom";
import Home from "../pages/Home";
import About from "../pages/About";
import Profile from "../pages/Profile";
import NotFound from "../pages/NotFound";

const AppRouter = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/profile/:username" element={<Profile />} />
        <Route path="*" element={<NotFound />} /> {/* 404 페이지 */}
      </Routes>
    </BrowserRouter>
  );
};

export default AppRouter;

라우팅 방식

  • / → <Home />
  • /about → <About />
  • /profile/:username → <Profile /> (예: /profile/희성)
  • * → <NotFound /> (없는 페이지)

5. App.js에서 AppRouter 불러오기

이제 AppRouter를 최상위 App.js에서 불러와서 사용합니다.

import AppRouter from "./routes/AppRouter";

const App = () => {
  return <AppRouter />;
};

export default App;

App.js는 AppRouter를 불러오기만 하면 됩니다.


6. Link와 useNavigate로 페이지 이동

1) Link를 사용한 네비게이션

React에서는 <a> 태그 대신 <Link>를 사용하여 페이지 이동을 구현합니다.

import { Link } from "react-router-dom";

const Navbar = () => {
  return (
    <nav>
      <Link to="/">홈</Link> | 
      <Link to="/about">소개</Link> | 
      <Link to="/profile/희성">프로필</Link>
    </nav>
  );
};

export default Navbar;

 to="/" → 홈 이동
 to="/about" → 소개 페이지 이동
 to="/profile/희성" → 동적 프로필 페이지 이동


2) useNavigate()를 사용한 프로그래밍 방식 이동

버튼 클릭 시 동적으로 페이지 이동을 하고 싶다면 useNavigate()를 사용합니다.

import { useNavigate } from "react-router-dom";

const Home = () => {
  const navigate = useNavigate();

  return (
    <div>
      <h1>홈 페이지</h1>
      <button onClick={() => navigate("/about")}>소개 페이지로 이동</button>
    </div>
  );
};

export default Home;

navigate("/about") → 소개 페이지로 이동
navigate(-1) → 이전 페이지로 이동 (history.back()과 동일)


7. URL 쿼리스트링 사용 (?search=React)

React Router에서는 useLocation()을 사용하여 쿼리스트링 값을 읽을 수 있습니다.

import { useLocation } from "react-router-dom";

const SearchPage = () => {
  const location = useLocation();
  const queryParams = new URLSearchParams(location.search);
  const keyword = queryParams.get("q");

  return <h1>검색 결과: {keyword}</h1>;
};

export default SearchPage;

/search?q=React → "검색 결과: React"
/search?q=JavaScript → "검색 결과: JavaScript"


React 프로젝트에서 react-router-dom을 사용할 때, 일반적으로 main.tsx (또는 index.js)에서 BrowserRouter로 전체 앱을 감싸는 것이 베스트 프랙티스입니다.

이는 라우팅 상태를 최상위에서 관리하고, 다른 Provider(예: Redux, Context API 등)와 함께 감싸서 일관된 상태를 유지할 수 있기 때문입니다.


1. main.tsx에서 BrowserRouter로 감싸기

React 프로젝트에서 main.tsx는 애플리케이션의 **진입점(entry point)**입니다.

main.tsx (또는 index.tsx)

import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

최상위에서 BrowserRouter로 감싸면, 모든 하위 컴포넌트에서 react-router-dom을 사용할 수 있음.
라우터 설정을 App.tsx 또는 AppRouter.tsx로 분리하여 관리하는 것이 좋음.


2. App.tsx에서 라우팅 설정

일반적으로 App.tsx에서 Routes와 Route를 설정합니다.

import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import About from "./pages/About";
import Profile from "./pages/Profile";
import NotFound from "./pages/NotFound";

const App = () => {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
      <Route path="/profile/:username" element={<Profile />} />
      <Route path="*" element={<NotFound />} />
    </Routes>
  );
};

export default App;

 main.tsx에서 BrowserRouter로 감싸고, App.tsx에서 라우트 설정을 하면 깔끔한 구조가 됩니다.


3. 다른 Provider들과 함께 감싸기

react-router-dom을 사용할 때, Redux Provider나 Context API Provider를 함께 사용할 수도 있습니다.

Redux와 함께 사용하기 (Provider)

import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { Provider } from "react-redux";
import store from "./store"; // Redux Store
import App from "./App";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <Provider store={store}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </Provider>
  </React.StrictMode>
);

Redux의 Provider를 BrowserRouter보다 바깥에 두어 전역 상태 관리가 가능하게 함.
라우팅과 Redux를 함께 사용할 때 가장 일반적인 구조.


Context API와 함께 사용하기

import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { UserProvider } from "./context/UserContext"; // Context API Provider
import App from "./App";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <UserProvider>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </UserProvider>
  </React.StrictMode>
);

Context API를 사용하면 전역적으로 사용자 데이터를 관리할 수 있음.
Provider를 BrowserRouter보다 바깥에 두면 전역 상태를 유지할 수 있음.


4. HashRouter vs BrowserRouter

React Router에는 BrowserRouter 외에도 HashRouter가 있습니다.

BrowserRouter HTML5 history.pushState API를 사용 /about, /profile/username
HashRouter URL에 #을 포함하여 사용 (서버 설정 불필요) #/about, #/profile/username

일반적인 SPA(싱글 페이지 애플리케이션)에서는 BrowserRouter를 사용하지만,
정적 페이지 또는 서버 설정 없이 배포해야 할 경우(GitHub Pages) HashRouter 사용 가능

import { HashRouter } from "react-router-dom";

<HashRouter>
  <App />
</HashRouter>;

특정 부분만 리렌더링하는 방법

  1. Sidebar는 고정된 컴포넌트로 유지.
  2. Outlet을 활용하여 특정 부분만 동적으로 변경.
  3. BrowserRouter는 전체 앱을 감싸되, Sidebar는 루트 레벨에 배치.
  4. 페이지 이동 시 Sidebar는 그대로 유지되고, Outlet 영역만 리렌더링됨.

1. 폴더 구조

📂 src
 ┣ 📂 components   → (공통 UI 컴포넌트)
 ┃ ┗ 📄 Sidebar.tsx
 ┣ 📂 pages        → (변경될 페이지 컴포넌트)
 ┃ ┣ 📄 Dashboard.tsx
 ┃ ┣ 📄 Settings.tsx
 ┃ ┗ 📄 Profile.tsx
 ┣ 📂 routes       → (라우터 설정)
 ┃ ┗ 📄 Layout.tsx  → (Sidebar를 포함한 레이아웃)
 ┣ 📄 App.tsx       → (라우터 설정)
 ┣ 📄 main.tsx      → (ReactDOM 설정)

2. Sidebar.tsx (고정될 사이드바 컴포넌트)

import { Link } from "react-router-dom";

const Sidebar = () => {
  return (
    <nav className="sidebar">
      <h2>내비게이션</h2>
      <ul>
        <li><Link to="/dashboard">대시보드</Link></li>
        <li><Link to="/profile">프로필</Link></li>
        <li><Link to="/settings">설정</Link></li>
      </ul>
    </nav>
  );
};

export default Sidebar;

<Link>를 사용하여 페이지 이동
Sidebar는 항상 고정된 상태로 유지됨


3. Layout.tsx (Sidebar 포함한 레이아웃)

React Router의 Outlet을 사용하여 Sidebar를 제외한 부분만 변경하도록 설정.

import { Outlet } from "react-router-dom";
import Sidebar from "../components/Sidebar";

const Layout = () => {
  return (
    <div className="layout">
      <Sidebar />
      <main className="content">
        <Outlet /> {/* 이 부분만 변경됨 */}
      </main>
    </div>
  );
};

export default Layout;

<Sidebar />는 고정되고,
<Outlet />은 변경될 페이지가 렌더링되는 부분


4. App.tsx (라우팅 설정)

Layout.tsx를 부모 컴포넌트로 감싸서 Sidebar는 고정하고 특정 부분만 변경하도록 설정.

import { Routes, Route, BrowserRouter } from "react-router-dom";
import Layout from "./routes/Layout";
import Dashboard from "./pages/Dashboard";
import Profile from "./pages/Profile";
import Settings from "./pages/Settings";

const App = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route path="dashboard" element={<Dashboard />} />
          <Route path="profile" element={<Profile />} />
          <Route path="settings" element={<Settings />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
};

export default App;

Layout을 <Route path="/" element={<Layout />}>로 감싸 Sidebar가 유지됨
<Outlet /> 부분만 dashboard, profile, settings 페이지로 변경됨


5. main.tsx에서 BrowserRouter 감싸기

main.tsx에서는 BrowserRouter로 App을 감싸기만 하면 됨.

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { BrowserRouter } from "react-router-dom";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

Protected Route와 중첩 라우트(Nested Route)


 1. Protected Route (인증된 사용자만 접근 가능)

개념

  • 로그인이 된 사용자만 특정 페이지에 접근할 수 있도록 막는 라우팅 방식
  • 일반적으로 isLoggedIn, user 같은 상태를 기준으로 판단

📁 예시 구성

src/
├── pages/
│   ├── Login.tsx
│   ├── Dashboard.tsx  ← 보호된 페이지
├── store/authStore.ts ← 로그인 상태 저장
├── routes/ProtectedRoute.tsx
└── App.tsx

ProtectedRoute.tsx

// routes/ProtectedRoute.tsx
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';

interface Props {
  children: React.ReactNode;
}

const ProtectedRoute = ({ children }: Props) => {
  const { isLoggedIn } = useAuthStore();

  if (!isLoggedIn) {
    return <Navigate to="/login" replace />;
  }

  return <>{children}</>;
};

export default ProtectedRoute;

App.tsx에서 사용

import ProtectedRoute from './routes/ProtectedRoute';
import Dashboard from './pages/Dashboard';

<Route
  path="/dashboard"
  element={
    <ProtectedRoute>
      <Dashboard />
    </ProtectedRoute>
  }
/>

2. Nested Route (중첩 라우트)

개념

  • 한 페이지 안에서 자식 경로에 따라 다른 화면을 보여줄 수 있음
  • 탭 구조, 다단계 페이지 구성 등에 유용

📁 예시 구성

src/
├── pages/
│   ├── Settings/
│   │   ├── SettingsLayout.tsx
│   │   ├── Profile.tsx
│   │   └── Password.tsx

SettingsLayout.tsx

// SettingsLayout.tsx
import { Outlet, Link } from 'react-router-dom';

function SettingsLayout() {
  return (
    <div>
      <h2>설정</h2>
      <nav>
        <Link to="profile">프로필</Link>
        <Link to="password">비밀번호</Link>
      </nav>
      <Outlet /> {/* 자식 라우트 출력 자리 */}
    </div>
  );
}

App.tsx 설정

import SettingsLayout from './pages/Settings/SettingsLayout';
import Profile from './pages/Settings/Profile';
import Password from './pages/Settings/Password';

<Route path="/settings" element={<SettingsLayout />}>
  <Route path="profile" element={<Profile />} />
  <Route path="password" element={<Password />} />
</Route>
  • /settings 접속 시 SettingsLayout이 보이고
  • /settings/profile, /settings/password로 접속하면 각각의 자식 컴포넌트가 <Outlet />에 렌더링됨
Protected Route 로그인 등 인증 상태에 따라 접근을 제한
Nested Route 부모 컴포넌트 내부에 자식 라우트 구조 렌더링 (Outlet)

이걸 응용하면:

  • /admin 전체를 Protected로 만들고
  • 그 안에 /admin/users, /admin/settings 등 중첩 구조로 만들 수도 있어요.

 

1. 관리자 전용 보호 라우트 (Admin Protected Route)

예: /admin 경로는 isAdmin === true인 유저만 접근 가능

// routes/AdminRoute.tsx
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';

interface Props {
  children: React.ReactNode;
}

const AdminRoute = ({ children }: Props) => {
  const { isLoggedIn, user } = useAuthStore();

  if (!isLoggedIn || user?.role !== 'admin') {
    return <Navigate to="/login" replace />;
  }

  return <>{children}</>;
};

export default AdminRoute;
// App.tsx
import AdminRoute from './routes/AdminRoute';
import AdminDashboard from './pages/admin/AdminDashboard';

<Route
  path="/admin"
  element={
    <AdminRoute>
      <AdminDashboard />
    </AdminRoute>
  }
/>

2. useParams()로 URL 동적 파라미터 사용

예: /users/:id 경로에서 유저 ID를 파라미터로 사용

// pages/UserDetail.tsx
import { useParams } from 'react-router-dom';

function UserDetail() {
  const { id } = useParams<{ id: string }>();

  return <h1>유저 상세 페이지: ID = {id}</h1>;
}

export default UserDetail;
// App.tsx
import UserDetail from './pages/UserDetail';

<Route path="/users/:id" element={<UserDetail />} />
  • /users/3 → id = 3
  • /users/banana → id = banana

useParams()는 보통 getUserById(id) 등의 API와 함께 사용됩니다.


3. 라우팅 기반 모달 (모달을 URL로 컨트롤)

개념

  • URL이 /posts/123이면 PostModal이 뜨도록 설정
  • /posts에서는 리스트만 보이고, /posts/:id에서는 모달과 함께 리스트가 보임

예시

// pages/PostsPage.tsx
import { Outlet, useNavigate, useParams } from 'react-router-dom';
import PostModal from './PostModal';

const PostsPage = () => {
  const navigate = useNavigate();
  const { id } = useParams<{ id: string }>();

  const posts = [
    { id: '1', title: '첫 번째 게시글' },
    { id: '2', title: '두 번째 게시글' },
  ];

  return (
    <div>
      <h1>게시글 목록</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <button onClick={() => navigate(`/posts/${post.id}`)}>
              {post.title}
            </button>
          </li>
        ))}
      </ul>

      {/* 라우팅 기반 모달 */}
      {id && <PostModal postId={id} onClose={() => navigate('/posts')} />}
      <Outlet />
    </div>
  );
};

export default PostsPage;
// pages/PostModal.tsx
interface Props {
  postId: string;
  onClose: () => void;
}

const PostModal = ({ postId, onClose }: Props) => {
  return (
    <div style={{
      position: 'fixed', top: '20%', left: '30%',
      width: '40%', padding: '2rem', background: 'white',
      boxShadow: '0 0 10px rgba(0,0,0,0.3)'
    }}>
      <h2>게시글 상세: {postId}</h2>
      <p>게시글 내용...</p>
      <button onClick={onClose}>닫기</button>
    </div>
  );
};

export default PostModal;
// App.tsx
<Route path="/posts" element={<PostsPage />}>
  <Route path=":id" element={<></>} /> {/* 모달 처리를 위한 dummy route */}
</Route>

관리자 전용 라우트 로그인 + 관리자 권한 체크 후 페이지 접근 허용
useParams() 동적 URL 파라미터(:id) 추출
라우팅 기반 모달 특정 URL일 때만 모달을 띄워주고, URL 변경으로 닫기


useSearchParams 중심으로 정리


1) 기본 사용: 읽기/쓰기

import { useSearchParams } from "react-router-dom"

function Page() {
  const [searchParams, setSearchParams] = useSearchParams()

  // 읽기
  const page = Number(searchParams.get("page") ?? 1)
  const q = searchParams.get("q") ?? ""

  // 쓰기(전체 교체)
  const goFirst = () => setSearchParams({ page: "1" })

  // 쓰기(기존 유지 + 일부만 변경)
  const setPage = (n: number) => {
    const next = new URLSearchParams(searchParams) // 기존 쿼리 보존
    next.set("page", String(n))
    setSearchParams(next)
  }

  return (
    <>
      <button onClick={() => setPage(page + 1)}>다음</button>
      <button onClick={goFirst}>처음으로</button>
      <div>page={page}, q={q}</div>
    </>
  )
}
  • setSearchParams({ ... }) 객체를 넣으면 기존 쿼리를 전부 교체합니다.
  • 기존 파라미터 유지하려면 new URLSearchParams(searchParams)로 복사 후 set()/delete() 하세요.

2) 파라미터 추가/수정/삭제 패턴

// 추가 또는 변경
const setKeyword = (keyword: string) => {
  const next = new URLSearchParams(searchParams)
  if (keyword) next.set("q", keyword)
  else next.delete("q") // 빈값이면 삭제
  setSearchParams(next)
}

// 여러 값 관리(?tag=a&tag=b)
const addTag = (tag: string) => {
  const next = new URLSearchParams(searchParams)
  next.append("tag", tag) // 동일 키 다중 값 허용
  setSearchParams(next)
}

// 전체 초기화
const clearAll = () => setSearchParams({})

3) replace 네비게이션(뒤로가기 스택 덜어내기)

// 기본은 push(히스토리 쌓임), replace로 대체 가능
setSearchParams({ page: "2" }, { replace: true })
  • 검색 폼에서 입력할 때마다 히스토리가 쌓이지 않게 하려면 replace: true가 유용합니다.

4) UI 상태와 URL 동기화(모달, 토글 등)

4-1) URL 기반 모달 열고 닫기

import { useSearchParams } from "react-router-dom"

function Posts() {
  const [searchParams, setSearchParams] = useSearchParams()
  const openedId = searchParams.get("postId") // 있으면 모달 오픈

  const openModal = (id: string) => {
    const next = new URLSearchParams(searchParams)
    next.set("postId", id)
    setSearchParams(next) // push
  }

  const closeModal = () => {
    const next = new URLSearchParams(searchParams)
    next.delete("postId")
    setSearchParams(next, { replace: true }) // 닫기는 replace 권장
  }

  return (
    <>
      <button onClick={() => openModal("1")}>1번 게시글</button>
      {openedId && (
        <PostModal
          postId={openedId}
          onClose={closeModal}
        />
      )}
    </>
  )
}
  • 열기: postId 추가
  • 닫기: postId 제거(+ replace: true로 뒤로가기 이력 최소화)

4-2) 카드 토글 + URL 동기화(토글-안정버전)

function Card({ id }: { id: number }) {
  const [isOpen, setIsOpen] = useState(false)
  const [searchParams, setSearchParams] = useSearchParams()

  const onClick = () => {
    setIsOpen(prev => {
      const nextOpen = !prev
      const next = new URLSearchParams(searchParams)
      if (nextOpen) next.set("id", String(id))
      else next.delete("id")
      setSearchParams(next, { replace: !nextOpen }) // 닫기는 replace
      return nextOpen
    })
  }

  return <button onClick={onClick}>열기/닫기</button>
}
  • setState(prev => next) 콜백으로 토글 후 상태를 기준으로 URL을 업데이트하세요.

5) 필터/정렬/페이지네이션 실전 패턴

type SortKey = "new" | "popular"
type Filters = {
  page?: number
  q?: string
  sort?: SortKey
  tags?: string[] // multi-value
}

function useListQuery(): [Filters, (patch: Partial<Filters>, opt?: { replace?: boolean }) => void] {
  const [sp, setSP] = useSearchParams()

  const filters: Filters = {
    page: sp.get("page") ? Number(sp.get("page")) : 1,
    q: sp.get("q") ?? "",
    sort: (sp.get("sort") as SortKey) ?? "new",
    tags: sp.getAll("tag"), // ?tag=a&tag=b
  }

  const update = (patch: Partial<Filters>, opt?: { replace?: boolean }) => {
    const next = new URLSearchParams(sp)

    if (patch.page !== undefined) next.set("page", String(patch.page))
    if (patch.q !== undefined) {
      patch.q ? next.set("q", patch.q) : next.delete("q")
    }
    if (patch.sort !== undefined) next.set("sort", patch.sort)

    if (patch.tags !== undefined) {
      next.delete("tag")
      for (const t of patch.tags) next.append("tag", t)
    }

    setSP(next, opt)
  }

  return [filters, update]
}
function ListPage() {
  const [{ page, q, sort, tags }, update] = useListQuery()

  // 페이지 이동
  const go = (n: number) => update({ page: n })

  // 검색어 변경(입력 중 replace 권장)
  const onChangeQ = (value: string) => update({ q: value, page: 1 }, { replace: true })

  // 태그 토글
  const toggleTag = (t: string) => {
    const s = new Set(tags)
    s.has(t) ? s.delete(t) : s.add(t)
    update({ tags: [...s], page: 1 })
  }

  return (
    <>
      <input value={q} onChange={(e) => onChangeQ(e.target.value)} />
      <button onClick={() => go(page + 1)}>다음 페이지</button>
      <button onClick={() => toggleTag("bug")}>#bug</button>
      <div>sort={sort}, tags={tags.join(",")}</div>
    </>
  )
}

6) URL ↔ 전역상태(Zustand) 동기화 팁

  • 최초 마운트 시 URL → Zustand로 “한 번” 복사
  • 이후에는 URL만 진실의 원천(SSOT) 으로 두고, UI 상호작용은 항상 URL을 갱신(→ 필요하면 useEffect로 전역 상태 최신화)
function useSyncWithStore() {
  const [sp] = useSearchParams()
  const setData = useRequireStore(s => s.setData)

  useEffect(() => {
    const id = sp.get("id")
    if (!id) return
    // id를 기준으로 API 호출 후 결과를 전역 저장
    fetch(`/api/requires/${id}`).then(r => r.json()).then(setData)
  }, [sp, setData])
}

7) 숫자/불리언 파라미터 안전 파서

export const qp = {
  num: (v: string | null, def = 0) => {
    if (v == null) return def
    const n = Number(v)
    return Number.isFinite(n) ? n : def
  },
  bool: (v: string | null, def = false) => {
    if (v == null) return def
    return v === "1" || v === "true"
  }
}

// 사용
const page = qp.num(searchParams.get("page"), 1)
const show = qp.bool(searchParams.get("show"), true)

8) 성능/UX 사소하지만 중요한 팁

  • replace 사용처: 입력 중인 검색폼, 모달 닫기, 정렬 변경처럼 뒤로가기에 흔적을 남기고 싶지 않을 때.
  • push 사용처: 페이지네이션, 디테일 진입처럼 사용자 히스토리가 필요한 경우.
  • URL에 상태가 많아질수록 초기 진입 북마크/공유가 쉬워지고, QA/디버깅도 편해집니다.
  • ESLint “unused expressions”를 피하려면 조건부 실행에 삼항연산자 대신 if/else를 권장합니다.