atomWithHash로 URL 기반 페이지네이션 구현하기

Jotai의 atomWithHash를 이용하여 페이지네이션을 구현해봅시다.

Frontend
React, Jotai, State Management, URL State

블로그 포스트가 늘어나면서 페이지네이션이 필요해졌습니다.
단순히 작동하는 것을 넘어, URL 상태와 동기화되고 재사용 가능하며 구조적으로 깔끔한 페이지네이션을 만들고 싶어졌어요.

왜 atomWithHash인가?

페이지네이션 상태를 관리하는 방법은 여러 가지가 있습니다.

고려한 방법들

1. useState 사용

const [currentPage, setCurrentPage] = useState(1);
  • 가장 간단하지만 새로고침 시 상태가 사라집니다.
  • URL에 페이지 정보가 없어 공유가 불가능합니다.

2. URL Query Parameter

/blog?page=2
  • Next.js의 useSearchParams로 관리
  • 이미 카테고리/태그 필터링에 query parameter를 사용 중이었습니다.
  • /blog?category=Frontend&tag=React&page=2 처럼 URL이 길어지게 됩니다.

3. URL Hash

/blog#page=2
  • Query parameter와 분리되어 깔끔합니다.
  • 필터링은 query, 페이지는 hash로 역할이 명확해집니다.
  • atomWithHash가 이 방식을 자동으로 처리해줍니다.

4. LocalStorage

  • 브라우저에 저장되지만 공유가 불가능합니다.
  • 서버 사이드 렌더링과 맞지 않습니다.

atomWithHash를 이용하다.

jotai-locationatomWithHash는 URL 해시와 Jotai atom을 자동으로 양방향 동기화됩니다.

export const currentPageAtom = atomWithHash("page", 1);

이 한 줄을 통해, 다음과 같은 동작을 기대할 수 있습니다.

  • atom 값 변경 → URL 해시 자동 업데이트
  • URL 해시 변경 → atom 값 자동 업데이트
  • 브라우저 뒤로/앞으로가기 자동 지원

청크 기반 네비게이션을 선택한 이유

페이지가 많을 때 모든 페이지 번호를 보여주기에는 UX가 떨어져 보입니다.
저는 여러 방식을 생각을 해보았는데요,

고려한 방식들

1. 무한 스크롤

  • 페이지 개념이 없어 URL로 특정 위치 공유 불가
  • SEO에 불리
  • 사용자가 “어디까지 봤는지” 파악 어려움

2. 생략 표시 (1 2 3 … 10)

  • 중간 페이지로 바로 가기 어려움
  • ”…”을 클릭할 수 없어 답답함
  • 많이 쓰이지만 UX가 좋지 않다

3. 슬라이딩 윈도우 (3 4 [5] 6 7)

  • 현재 페이지 중심으로 표시
  • 페이지 이동 시 번호가 계속 바뀌어 혼란스러움
  • 전체 중 어디쯤인지 파악 어려움

4. 청크 단위 (1 2 3 4 5 >)

  • 5개씩 묶어서 표시
  • > 버튼으로 다음 5개로 이동
  • 명확한 구간 구분

청크 단위 기반으로 진행

총 3페이지:    1 2 3
총 6페이지:    1 2 3 4 5 >
              < 6
총 13페이지:   1 2 3 4 5 >
              < 6 7 8 9 10 >
              < 11 12 13

청크 이동 로직:

  • 페이지 5에서 > 클릭 → 페이지 6으로 (다음 청크 첫 페이지)
  • 페이지 11에서 < 클릭 → 페이지 10으로 (이전 청크 마지막 페이지가 아니라 첫 페이지)

이렇게 하면 “이전/다음 5개”라는 명확한 단위로 탐색할 수 있습니다.

Atom 설계 철학

Derived Atom 활용

export const totalPagesAtom = atom((get) => {
  const posts = get(filteredPostsAtom);
  return Math.ceil(posts.length / POSTS_PER_PAGE);
});
  • 필터가 변경되면 포스트 수가 변경됩니다.
  • 전체 페이지 수도 자동으로 재계산 되어야 합니다.
  • Derived atom은 의존하는 atom이 변경될 때 자동으로 재계산된다는 점을 고려하였습니다.
export const paginatedPostsAtom = atom((get) => {
  const posts = get(filteredPostsAtom);
  const currentPage = get(currentPageAtom);
  const startIndex = (currentPage - 1) * POSTS_PER_PAGE;
  return posts.slice(startIndex, startIndex + POSTS_PER_PAGE);
});
  • 컴포넌트가 “현재 페이지의 포스트”만 구독하면 됩니다.
  • 다른 페이지로 이동해도 이 atom만 업데이트 됩니다.
  • 슬라이싱 로직이 atom에 캡슐화되어 컴포넌트는 단순해집니다!

상태는 최소한으로, 계산은 자동으로

직접 관리하는 상태:

  • currentPageAtom - 사용자가 직접 변경

자동 계산되는 상태:

  • totalPagesAtom - filteredPosts 기반으로 계산
  • paginatedPostsAtom - filteredPosts + currentPage 기반으로 계산

이렇게 하면 상태 불일치가 발생할 수 없습니다!
currentPage를 바꾸면 paginatedPostsAtom이 자동으로 업데이트 되거든요.

필터 변경 시 페이지 리셋

useEffect(() => {
  setCurrentPage(1);
}, [searchParams]);
  • 사용자가 페이지 10에서 다른 카테고리로 필터링
  • 새 필터 결과는 2페이지만 있습니다.
  • 페이지 10 그대로면 빈 화면

왜 1페이지로?

  • 새로운 필터 = 새로운 검색
  • 처음부터 보는 게 자연스럽습니다.

정리

atomWithHash:

  • URL 상태 동기화로 공유 가능성 확보
  • 브라우저 네이티브 히스토리 활용

청크 기반 네비게이션:

  • 명확한 구간으로 사용자 혼란 최소화
  • 조건부 렌더링으로 깔끔한 UI

Derived Atom:

  • 상태는 최소화, 계산은 자동화
  • 상태 불일치 원천 차단

어떻게 해야 UX적으로 조금 더 좋을지 고민을 좀 해보았어요.
여러 시행착오도 있었지만, 조금 더 깔끔하게 구현해보고자 노력하였습니다.
이 페이지네이션이 10… 이상 넘게 찍히는 날 까지 꾸준히 작성 해보겠습니다.


Jotai Location 문서

관련 글