Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Vol. 10 100超のページコンポーネントのレイアウトを再設計し、設定画面のスクロール体験を改善した話

この記事は、Bill One開発Unitブログリレー2025の第10弾、および Sansan Advent Calendar 2025、12日目の記事です。

こんにちは、技術本部Bill One Engineering Unit の今村です。2025年4月に新卒でSansanに入社し、Bill Oneの開発に携わっています。

今回は、「設定画面のサイドバーでページを選択するたびに、サイドバーのスクロールが一番上に戻ってしまう問題」を、React RouterのLayout Routesという仕組みを導入することで解決した話を紹介します。

また、Bill One開発Unitブログリレー2025で3回目の執筆です、過去のブログも是非ご覧ください!

buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

目次

自分がつらい = ユーザーもきっとつらい

Bill Oneの設定画面には、多くの設定項目が並んだサイドバーがあります。ユーザーによっては頻繁に複数のページを行き来する設定画面です。

ところが、サイドバーのメニューアイテムを押してページ遷移するたびに、サイドバーのスクロール位置が一番上に戻ってしまう状態になっていました。

サイドバーのスクロール位置が一番上に戻ってしまう(実際のBill Oneの画面ではありません)

PdMやCSを通じて、顧客からは、

  • 「設定を行き来していると、毎回一番上に戻るのがつらい」
  • 「特に下の方のメニューを触るときのストレスが大きい」

といったフィードバックが、自分が入社する前から何度も上がっていたそうです。実際に開発中の自分も「これ、毎回スクロールし直すのしんどいな…」と感じていました。

とはいえ、設定画面は100を超えるページで構成されています。影響範囲が非常に大きく、簡単に修正できる部分ではなかったため、新機能の開発や他機能の改修に比べて優先度を上げづらい状況が続いていました。

それでも、

「自分がこれだけ不便に感じているなら、日常的に使ってくれているユーザーはもっと不便なはず」

という気持ちが強くなり、今回の対応に着手することにしました。

根本的な解決策と暫定対応の位置づけ

今回の対応は「スクロール位置がリセットされる」という課題に対処したものですが、UXの視点では、より根本的な問題として「そもそもスクロールが必要なほどメニューが多い」という構造的な課題も存在しています。

そのため、そもそもスクロール自体を発生させない、または最小化するアプローチも考えられます。例えば、「権限セットを使用して、表示するメニューを絞り込むこと」や、「セクション単位でアコーディオンメニューにする」などでしょうか。

しかし、これらのアプローチは画面全体に大きく変更が入るため、UI/UXの再設計や実装に大きな工数が発生します。そのため、まずは現在のメニュー構造を維持しながら、スクロール位置がリセットされないようにする対応を優先することにしました。

今回の実装は暫定対応の位置づけではありますが、本来あるべき姿を見据えつつ、段階的にユーザー体験を改善していく一歩として捉えています。

問題の正体:レイアウトコンポーネントの再生成

原因はサイドバー単体ではなく、レイアウトコンポーネントの組み方 にありました。設定画面の各ページでは、ざっくり次のような構造になっていました。

// レイアウトコンポーネント
export const SettingLayout: FC<{ children: ReactNode }> = ({ children }) => {
  return (
    <div>
      {/* この中にサイドバーやヘッダーなど共通部分 */}
      <Sidebar />
      <main>{children}</main>
    </div>
  );
};

// 設定画面のページコンポーネント
export const SomeSettingPage = () => {
  return (
    <SettingLayout>
      <PageContent />
    </SettingLayout>
  );
};

各ページコンポーネントでは、SettingLayoutコンポーネントがコンテンツをラップする構造になっています。この構造の問題は、ページが切り替わるたびにサイドバーコンポーネントが再生成されてしまうことです。具体的には、次のようなことが起きています。

  1. ルーティング(ページ)が変わるたびに、SettingLayoutコンポーネントが別インスタンスとして再生成される
  2. それに伴って、サイドバーも毎回作り直される
  3. サイドバーのDOMが再マウントされるため、スクロール位置が一番上に戻ってしまう

上記の問題に対し、スクロール位置を無理やり保持することで挙動だけを整えることもできますが、この問題の根本的な原因は、「ページを切り替えるたびに、サイドバーを毎回ゼロから作り直している」ことにあります。

そのため、ページ固有のコンテンツだけを切り替え、SettingLayoutコンポーネントは再生成しないように実装することが、今回の解決方針になります。

なぜ最初からLayout Routesのような構成にしなかったのか

初期開発時、設定画面はReact Router v5を使用していました。v5には現在のv6以降にあるOutletコンポーネントやLayout Routesの仕組みが存在せず、共通レイアウトをルーティング設定側で管理する方法が今ほどシンプルに書けませんでした。

また、当初は設定画面の数が少なく、サイドバーにスクロールが発生しない状態でした。レイアウトが毎回再生成される構造は存在していましたが、「スクロール位置がリセットされる」という形では表面化していませんでした。

Bill Oneの成長とともに設定画面が増え、サイドバーの項目も増えたことで、この課題が顕在化してきました。

React RouterのOutlet + Layout Routesでレイアウトの記述を一箇所に集約する

実装内容を一言で言うと、「各ページでSettingLayoutコンポーネントを呼ばず、ルーティング設定側でReact RouterのLayout Routes機能を使ってレイアウトの記述を一箇所にまとめた」という対応になります。

これを実現するために、主に次の2点を行う必要がありました。

  • レイアウトコンポーネント側でReact RouterのOutletコンポーネントを使って子コンポーネントをレンダリングする形に変える
  • ルーティング設定にLayoutコンポーネントを組み込む(React RouterのLayout Routesを導入する)

React RouterのOutletコンポーネントについて

React RouterのOutletコンポーネントは、「親ルートにぶら下がっている子ルートのコンポーネントが差し込まれる場所」を表すプレースホルダーです。具体的には、親ルートが一致したときに、その場所に対応する子ルートがレンダリングされるイメージです。

Outlet | React Router

React RouterのLayout Routesについて

React RouterのLayout Routesは、「特定のURL配下で共通レイアウトをまとめて適用するためのルートの書き方」です。具体的には、親ルートにレイアウト用コンポーネント(今回だとSettingLayout)を割り当てて、その内側に「実際のページコンポーネント」を子ルートとしてネストしていくイメージです。

Routing | React Router

「親ルート」や「子ルート」に関しては、イメージが湧きづらいかと思いますので、次に示す変更後のコードを用いて解説します。

まずは変更前を見てみます。

/* 変更前 */

export const SettingLayout: FC<{ children: ReactNode }> = ({ children }) => {
  return (
    <div>
      {/* この中にサイドバーやヘッダーなど共通部分 */}
      <Sidebar />
      <main>{children}</main>
    </div>
  );
};

// 各ページ側でLayoutをかぶせていた
export const SomeSettingPage = () => {
  return (
    <SettingLayout>
      <PageContent />
    </SettingLayout>
  );
};

export const SettingRoutes = () => {
  return (
    <Routes>
      <Route path="settings" element={<SettingTopPage />} />
      <Route path="settings/tenant" element={<SettingTenantPage />} />
      <Route path="settings/tenant_user" element={<SettingTenantUserPage />} />
      {/* ...その他の設定ページ */}
    </Routes>
  );
};

これに対し、レイアウトコンポーネントの形を変えました。もともとはchildrenをpropsで受け取るコンポーネントでしたが、これをOutletコンポーネントを内部に持つコンポーネントに変更しました。また、ルーティング設定側にSettingLayoutコンポーネントを組み込むようにしました。

/* 変更後 */

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

export const SettingLayout = () => {
  return (
    <div>
      {/* この中にサイドバーやヘッダーなど共通部分 */}
      <Sidebar />
      <main>
        {/* ページごとの内容はここに差し込むOutletを使用する */}
        <Outlet />
      </main>
    </div>
  );
};

export const SomeSettingPage = () => {
  return <PageContent />;
};

// ルート設定でReact RouterのLayout Routes機能を使用する
export const SettingRoutes = () => {
  return (
    <Routes>
      <Route path="settings" element={<SettingLayout />}>
        <Route index element={<SomeSettingPage />} />
        <Route path="tenant" element={<SettingTenantPage />} />
        <Route path="tenant_user" element={<SettingTenantUserPage />} />
        {/* ...その他の設定ページ */}
      </Route>
    </Routes>
  );
};

この変更で、ページ固有のコンテンツだけを切り替え、SettingLayoutコンポーネントは再生成しないように実装することが実現できます。以下で具体的な内容を解説します。

上記のコードでは、

  • path="settings"<Route>親ルート
  • その内側にネストして書かれている <Route>子ルート

と呼ぶことが適しているかと思います。

ここでは、ブラウザのURLがsettingsから始まっている間(例:/settings/settings/tenantなど)は、path="settings"のルートが常に有効になっていて、そのelementに指定した<SettingLayout />が表示され続けます。一方で、<SettingLayout />の中にある<Outlet />の中身だけは、現在のURLに応じて入れ替わります。たとえば、/settingsのときは<SomeSettingPage />/settings/tenantのときは<SettingTenantPage /><Outlet />の位置にレンダリングされます。

こうすることで、/settings/settings/tenantなど、/settings配下のURLの間は、この親ルートに対応するSettingLayoutコンポーネントが同じインスタンスとして使われ続けることになります。つまり、サイドバーのスクロール位置などの状態が維持される、という狙いどおりの挙動が実現できます。

Storybookの差分を出さないためのDecorator調整

レイアウトの持ち方を変えるときに気になったのが、既存のStorybookに差分が出ないかどうかでした。これまでの実装では、各ページコンポーネント内でレイアウト(サイドバーを含む)コンポーネントを使用しており、各ページコンポーネントのStorybookでは「ページコンポーネントをそのまま描画するだけ」で、レイアウトコンポーネントも一緒に表示されていました。

つまり、「ページコンポーネント = レイアウト込みのページ」という構造になっていました。

しかし、今回の変更でページコンポーネントからレイアウトコンポーネントを外し、Layout Routesによってルーティング側でレイアウトコンポーネントをかぶせるようにしました。そのため、Storybook上ではレイアウトコンポーネントなしの「中身だけのページ」が表示されることになります。これは、ページの設定画面の全てのページコンポーネントのStorybookで、レイアウト側のUIが失われるといった差分が出ることを意味します。

このレイアウトの差分を避けたい理由は主に次の2つです。

  1. ページ全体の体験の可視化: ページコンポーネントにレイアウトが含まれていた方が、Storybook上でページ全体の体験がわかりやすく、デザインレビューや動作確認がしやすくなります
  2. VRTによる安全なリリース: Bill OneではChromaticのVRT(Visual Regression Test)を利用しており、ページコンポーネントはPRごとにスナップショットを撮って差分を検出する運用になっています。今回の取り組みは大量のページに影響するため、UI自体に直接的な変更がないことをVRTで確認できた方が安全にリリースできます

つまり、今回の取り組みでは、

レイアウト構造を変えても、ページコンポーネントの見た目(Storybook上のスナップショット)には差分が出ないこと

がCIの期待値になります。とはいえ、すべてのStoryを手作業で直すのは現実的ではありません。これに対し、StorybookのDecorator側でレイアウトを補う方針を取りました。

storybook.js.org

レイアウトコンポーネントを再現するDecoratorを用意する

やったことは、「設定ページのStoryにレイアウトコンポーネントを表示するDecorator」を用意したことです。

イメージとしては次のようなコードです。

import type { Decorator } from "@storybook/react-vite"
import type { ReactElement } from "react"
import { reactRouterNestedAncestors, reactRouterParameters } from "storybook-addon-remix-react-router"

 // React RouterのLayout + Outlet構造をStorybookで再現するDecorator
export const withLayoutRoute = (layoutElement: ReactElement): Decorator => {
  return (Story, context) => {
    // レイアウトを親、Storyを子としてネストされたルート構造を作成
    const routing = reactRouterNestedAncestors({ element: layoutElement })

    // StorybookのreactRouter設定として渡す
    context.parameters.reactRouter = reactRouterParameters({
      ...context.parameters.reactRouter,
      routing,
    })

    return <Story />
  }
}

export const settingsRouteDecorators: () => Decorator[] = () => [
  ...someSettingsDecorators(),
  withLayoutRoute(<SettingLayout />),
]


// ある設定画面のページコンポーネントのStoryのイメージ
export default {
  component: SomeSettingPage,
  decorators: settingsRouteDecorators(),
} satisfies Meta<typeof  SomeSettingPage>;

storybook-addon-remix-react-routerが提供しているreactRouterNestedAncestorsがあったことでレイアウト相当部分をDecoratorで再現できました。reactRouterNestedAncestorsは、指定したレイアウトコンポーネントの<Outlet />にStoryを差し込んだ状態のルーティングをまとめて作ってくれるヘルパーです。

storybook.js.org

こうすることで、各ページコンポーネントのStoryではレイアウトの関心を極力持ち込まずにページコンポーネント固有のコンテンツに集中できるようになりました。実際のアプリケーションではLayout Routesがレイアウトを管理し、StorybookではDecoratorが同じ役割を担う構造です。

なぜreactRouterNestedAncestorsをDecoratorにまとめたのか

今回のような構造はwithLayoutRouteのようにDecoratorとして作らず、Storyごとに直接reactRouterNestedAncestorsを書いてしまうこともできます。例えば、次のような書き方でもレイアウトコンポーネントの<Outlet />にページコンポーネントを差し込んだStoryを定義できます。

export default {
  component: SomeSettingPage,
  parameters: {
    reactRouter: reactRouterParameters({
      routing: reactRouterNestedAncestors({
        path: "settings/hoge",
        element: <SettingLayout />,
      }),
    }),
  },
} satisfies Meta<typeof SomeSettingPage>;

この書き方でも目的は達成できますが、設定画面のようにページ数が多いケースでは次のような辛さが出てきます。

  • 各StoryごとにreactRouterNestedAncestorsの呼び出しを書く必要がある
  • レイアウトコンポーネントを差し替えたいとき、全Storyを検索&置換する必要がある

さらに、今回のケースでは、もともと設定画面向けのStoryにsettingsRouteDecoratorsがすでにセット済みでした。そのため、既存のsettingsRouteDecoratorswithLayoutRouteを1行追加するだけで、すべての設定画面のStoryにレイアウトを適用できる、というのも大きなメリットでした。加えて、withLayoutRouteのような関数にしておくことで、今後同じようにLayout Routeを使った画面構成が増えたときにも、このDecoratorを再利用できるようになります。

その結果、ChromaticのVRT上では、ページコンポーネントのスナップショットに差分が出ない一方で、内部実装としてはLayout Routesベースへ安全に移行できました。また、ページコンポーネントはレイアウトを意識せず、「そのページ単体の責務」に集中できるようになりました。

レイアウト構造を大きく変えるリファクタリング時は、実装だけでなくStorybookやテストなどの前提も一緒に変わる可能性があります。そのため、「どうやってStoryとVRTを保持するか」を実装前に頭に入れておくと、安心してリファクタリングできることを学びました。

ページコンポーネントのStoryが揃っていない問題

実装中には、Storybookのカバレッジ不足という課題もあり、デグレチェックには少し手間がかかりました。設定画面のページコンポーネントすべてに対してStoryが書かれているわけではなかったため、レイアウトの変更を安全に確認したくても、

  • Storyがないページはローカル環境で実際に画面を見る
  • もしくは、不足しているStoryを先に作成してから、本題のレイアウト変更のPRを出す

といった対応が必要でした。

自動テストが担保されていれば、ある程度は不安なく、心理的安全性を保ちながらデリバリーできます。しかし、ページ単位でStoryが用意されていない場合、「このケース、大丈夫かな?」と思ってもその場でサッと確認しづらく、どうしても目検チェックに頼らざるを得ません。規模の大きい開発では、この「目検頼り」が積み重なって、特にスピードに影響してくると感じました。

Bill Oneのフロントエンドには、

「ページコンポーネントにはStoryを用意し、VRTを有効にした状態でコミットする」

というルールがコーディングガイドラインに記載されています。ただ、このルールは途中から導入された経緯があり、古くから存在するコンポーネントの中には、まだStory / VRTが揃っていないものもあります。今回のような大規模なレイアウト変更やリファクタリングを行うときには、そういった「過去とのギャップ」がそのまま不安要素になりやすいです。改めて、

  • 日頃からStory / VRTを整えておくこと
  • 「テストしやすさ」も含めて設計・運用しておくこと

が、後から大きな変更を入れるうえでの土台になると強く実感しました。

まとめ

今回は、「サイドバーのスクロール位置が戻ってしまう」といった、日常的に使うユーザーにとっては大きなストレスになっていた問題を、React RouterのLayout Routes機能を使って解決しました。

この取り組みを通じて学んだことは大きく3つです。

1. 影響範囲の大きさに臆さず、必要な改善は実施する

開発中に感じる小さな違和感は、ユーザーも同じように感じている可能性が高いと思います。今回は「毎回スクロールし直すのがつらい」という自分の感覚を起点に、PdMやCSからも以前から上がっていたフィードバックに向き合うことができました。リリース後には多くの開発者やデザイナーからも、感謝の言葉をいただくことが多かったです。影響範囲が大きくても、ユーザー体験の改善に直結する課題には積極的に取り組む価値があると、改めて実感しました。

2. 根本原因を正しく理解し、適切な技術で解決する

単にスクロール位置を保持するだけなら、JavaScriptで無理やり制御するという手段もあります。しかし今回は「レイアウトコンポーネントが毎回再生成される」という根本原因を見極め、React RouterのLayout Routesという仕組みを使って構造的に解決しました。表面的な対処法ではなく、アーキテクチャレベルで問題を解消することで、保守性の高い実装になったと思います。

3. リファクタリング時は、テストや開発環境も含めて設計する

構造を大規模に変更する際は、実装だけでなくStorybookやVRTといった開発・テスト環境への影響も考慮する必要があります。今回はwithLayoutRouteというDecoratorを用意することで、既存のStoryを壊さずに移行できました。また、Storyのカバレッジ不足が変更の不安要素になることも体験し、日頃からテストを整えておくことの重要性を改めて認識しました。

今回の改善により、ユーザーは設定画面をストレスなく行き来できるようになり、開発者としても違和感なく設定画面に触れられるようになりました。こういった改善の積み重ねが、より良いプロダクトを作っていくのだと実感した取り組みでした。自分にとっても、とても価値のある改善ができてよかったです!

Sansan技術本部ではカジュアル面談を実施しています

技術本部では中途・新卒採用向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話します。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。

© Sansan, Inc.