🐹

なぜMPAからSPAに移行したのか:App Routerリファクタリング実践

5 months ago
14

この記事は、ひとりでつくるSaaS - 設計・実装・運用の記録 Advent Calendar 2025 の11日目の記事です。

昨日の記事では「App Routerのディレクトリ設計」について書きました。この記事では、MPAからSPAへの移行を実践した理由と具体的な実装について解説します。

📝 この記事で使う用語

  • MPA(Multi Page Application): ページ遷移のたびにサーバーからHTMLを取得し、画面全体を再読み込みする方式
  • SPA(Single Page Application): 初回読み込み後はJavaScriptでページを切り替え、画面全体を再読み込みしない方式
  • クライアントサイドナビゲーション: ブラウザ側でURLを変更し、必要なデータだけを取得してページを更新する方式

🎯 なぜSPAに移行したのか

App Routerを採用した当初は、Server Componentsの恩恵を最大化するため、MPA的な構成にしていました。ページ遷移のたびにサーバーでHTMLを生成し、新しいページを表示する方式です。

しかし、開発を進めるうちに以下の課題が見えてきました。

MPA的な構成の課題

1. ナビゲーションメニューの再読み込み

サイドバーにナビゲーションメニューを配置していますが、ページ遷移のたびに再読み込みされていました。展開状態がリセットされたり、一瞬ちらついたりして、体験が損なわれていました。

2. スクロール位置のリセット

一覧画面でスクロールして詳細画面に遷移し、戻ると最初の位置に戻ってしまう。フィルタ条件もリセットされ、再度設定し直す必要がありました。

3. 遷移時のちらつき

ページ遷移のたびに画面全体が再描画されるため、レイアウトが一瞬崩れたり、ローディング状態が目立ったりしていました。

🔧 SPA移行で実現したこと

1. スクロール位置の復元

詳細画面から一覧に戻ったとき、元のスクロール位置を復元します。

// useScrollRestoration.ts const SCROLL_CACHE_KEY = 'app_scroll_cache'; const CACHE_EXPIRY = 5 * 60 * 1000; // 5分 export function useScrollRestoration() { // スクロール位置を保存 const saveScroll = useCallback(() => { const cache = { scrollY: window.scrollY, pathname: window.location.pathname, timestamp: Date.now(), }; sessionStorage.setItem(SCROLL_CACHE_KEY, JSON.stringify(cache)); }, []); // スクロール位置を復元 const restoreScroll = useCallback(() => { const stored = sessionStorage.getItem(SCROLL_CACHE_KEY); if (!stored) return; const cache = JSON.parse(stored); // 有効期限チェック if (Date.now() - cache.timestamp > CACHE_EXPIRY) { sessionStorage.removeItem(SCROLL_CACHE_KEY); return; } // 同じパスなら復元 if (cache.pathname === window.location.pathname) { window.scrollTo(0, cache.scrollY); } }, []); return { saveScroll, restoreScroll }; }

2. フィルタ状態のURL同期

フィルタやソート条件をURLパラメータに保存し、ブラウザの履歴と連携させています。ここではnuqsというライブラリを使っています。nuqsは、URLパラメータをReactの状態として扱えるライブラリです。

https://nuqs.dev/

// useListFilters.ts import { parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; export const listFilterParsers = { category: parseAsString, tag: parseAsString, sort: parseAsStringEnum(['newest', 'oldest', 'popular'] as const) .withDefault('newest'), search: parseAsString, }; export function useListFilters() { return useQueryStates(listFilterParsers, { history: 'push', // ブラウザ履歴に追加 shallow: true, // サーバー再取得なし }); }

これにより、以下のようなURLが生成されます。

/articles?category=tech&sort=popular&search=Next.js

URLをコピーしてシェアすれば、同じフィルタ状態で一覧を表示できます。

📦 状態管理の設計

SPA化にあたり、状態管理を整理しました。

Zustandでグローバル状態を管理

Zustandは、シンプルで軽量な状態管理ライブラリです。Reduxより設定が少なく、Providerでラップする必要もないため、手軽に導入できます。

https://zustand.docs.pmnd.rs/

一覧データやローディング状態をZustandで一元管理しています。

// articleStore.ts import { create } from 'zustand'; interface Article { id: string; title: string; category: string; createdAt: string; } interface ArticleStore { // 記事一覧 articles: Article[]; setArticles: (articles: Article[]) => void; // ローディング状態 isLoading: boolean; setIsLoading: (loading: boolean) => void; } export const useArticleStore = create<ArticleStore>(set => ({ articles: [], setArticles: (articles) => set({ articles }), isLoading: false, setIsLoading: (isLoading) => set({ isLoading }), }));

URLを単一情報源として活用

フィルタ条件はURLパラメータを単一情報源(Single Source of Truth)としています。

// FilterContext.tsx export function FilterProvider({ children }: { children: ReactNode }) { // URLからフィルタ状態を取得(nuqs) const [filters, setFilters] = useListFilters(); // 派生状態はURLから計算 const hasActiveFilters = useMemo(() => { return !!(filters.category || filters.tag || filters.search); }, [filters]); return ( <FilterContext.Provider value={{ filters, setFilters, hasActiveFilters }}> {children} </FilterContext.Provider> ); }

🖥️ レイアウト層の工夫

SPA化で重要なのは、レイアウト層でのデータ取得を避けることです。

Before: レイアウトでデータ取得

// ❌ ページ遷移のたびにデータを再取得してしまう function MainLayout({ children }: { children: ReactNode }) { const { data, isLoading } = useArticles(); // ここでデータ取得 return ( <div className="flex"> <Sidebar articles={data} isLoading={isLoading} /> <main>{children}</main> </div> ); }

After: レイアウトはストアを参照するだけ

// ✅ レイアウト層ではZustandの状態を参照するだけ function MainLayout({ children }: { children: ReactNode }) { // Zustandから状態を取得(データ取得はしない) const articles = useArticleStore(state => state.articles); const isLoading = useArticleStore(state => state.isLoading); return ( <div className="flex"> <Sidebar articles={articles} isLoading={isLoading} /> <main>{children}</main> </div> ); }

データ取得は各ページコンポーネントで行い、結果をZustandに保存します。レイアウト層はその状態を参照するだけなので、ページ遷移時に再取得が発生しません。

🔀 クライアントサイドナビゲーション

Next.jsのuseRouterを使ってクライアントサイドナビゲーションを実装しています。

// useSPANavigation.ts import { useRouter } from 'next/navigation'; import { useCallback } from 'react'; export const useSPANavigation = () => { const router = useRouter(); const navigateTo = useCallback((path: string) => { router.push(path); }, [router]); const navigateBack = useCallback(() => { router.back(); }, [router]); return { navigateTo, navigateBack }; };

router.push()を使うことで、ページ全体を再読み込みせずにURLを変更し、必要なコンポーネントだけを更新できます。

🎯 移行のポイント

1. 段階的に移行する

最初から完全なSPAを目指すのではなく、課題が顕著な画面から段階的に移行しました。

  • 第1段階: ナビゲーションメニューの状態保持
  • 第2段階: 一覧↔詳細のスクロール復元
  • 第3段階: フィルタ条件のURL同期

2. SSRの恩恵は維持する

SPA化しても、初回表示はSSRで行っています。App RouterのServer Componentsを活かし、初回表示は高速に、遷移後はクライアントサイドで処理しています。

3. 永続化を意識する

スクロール位置、フィルタ条件、メニュー展開状態など、復元したい状態は適切に永続化します。

状態保存先理由
フィルタ条件URLシェア可能、履歴連携
スクロール位置sessionStorageタブ内で復元
メニュー展開状態localStorageユーザー設定として永続化
表示形式localStorageユーザー設定として永続化

✅ まとめ

MPAからSPAへの移行で実現したことをまとめます。

解決した課題:

  • ナビゲーションメニューの再読み込み → Zustandで状態を保持
  • スクロール位置のリセット → sessionStorageで復元
  • フィルタ条件のリセット → URLパラメータで永続化
  • 画面遷移時のちらつき → クライアントサイドナビゲーション

設計のポイント:

  • Zustandでグローバル状態を管理
  • URLを単一情報源として活用
  • レイアウト層でのデータ取得を避ける
  • SSRの恩恵は維持する

App Routerを使いながらSPA的な体験を実現することで、Server Componentsの恩恵とクライアントサイドの快適さを両立できました。

明日は「Next.js Route HandlerからHonoへ」について解説します。


シリーズの他の記事

  • 12/10: App Routerのディレクトリ設計:Next.jsプロジェクトの構成術
  • 12/12: Next.js Route HandlerからHonoへ:API設計が楽になった理由
0
0
0
なぜMPAからSPAに移行したのか:App Routerリファクタリング実践
0
Posts
0
Followers
0
Likes

Properties

Page
テクノロジー
TECH