スマートフォンを操作する開発者。画面には文字サイズ変更と永続化のアイコンが表示されている。背景にはReactのUIコンポーネント、アクセシビリティ設定、E2Eテストの概念が描かれており、設定変更が全画面に反映されない問題を解決するフロントエンド開発の解決策を示唆しています。

はじめに

設定画面で文字サイズのトグルを切り替えたのに、画面の見た目が変わらない。
あるいは変わったように見えても、リロードすると元に戻ってしまう。
こうした不具合は、実運用しているサービスでも意外とよく起きます。

原因を整理すると、多くの場合は次の2点セットです。

  • 選んだ値を永続化していない(初期値が固定のまま)
  • スタイルの適用先が、設定画面の中だけで画面全体をカバーしていない

ローカルな useState だけでもトグルのUIは正しく動いて見えるため、レビューで見落としやすいタイプの不具合です。
この記事では、修正のパターンをコード付きで整理します。

こんな人におすすめ

  • 設定画面のトグルを実装したが、リロードで値が戻ってしまう
  • テーマや文字サイズなど、全画面に効かせたい設定を React で実装している
  • CSS のコード分割(遅延ロード)で、スタイルが効いたり効かなかったりして困った
  • アクセシビリティ系の設定を追加する際のE2Eテストの考え方を知りたい

原因1:状態がコンポーネントの中で完結している

典型的な壊れ方は、次のような実装です。

type FontSize = 'small' | 'normal' | 'large'

function SettingsOverview() {
  // 初期値が固定。保存も復元もされない
  const [fontSize, setFontSize] = useState<FontSize>('normal')

  return (
    <div className={`settings-overview settings-font-${fontSize}`}>
      <button
        aria-pressed={fontSize === 'large'}
        onClick={() => setFontSize('large')}
      >
        大
      </button>
    </div>
  )
}

トグルを押すと fontSize が変わり、ボタンの見た目も更新されます。
そのため、その場で動かす限りは正常に見えます。
ただしこの値は、画面を離れたりリロードしたりした時点で失われます。

修正1:読み込みと保存を専用モジュールに切り出す

保存・復元の処理を小さなモジュールにまとめ、useState の初期化関数と変更ハンドラの両方から呼びます。

// fontSizePreference.ts
export type FontSizePreference = 'small' | 'normal' | 'large'

// E2Eテスト側にも同じ文字列がある。変更したら両方そろえること
export const FONT_SIZE_STORAGE_KEY = 'app:font-size'

export function readFontSizePreference(): FontSizePreference {
  try {
    const value = localStorage.getItem(FONT_SIZE_STORAGE_KEY)
    if (value === 'small' || value === 'large') return value
  } catch {
    // storage が使えない環境では既定値にフォールバック
  }
  return 'normal'
}

export function saveFontSizePreference(next: FontSizePreference): void {
  try {
    localStorage.setItem(FONT_SIZE_STORAGE_KEY, next)
  } catch {
    // 保存に失敗しても画面の動作は止めない
  }
  document.documentElement.dataset.fontSize = next
}

コンポーネント側は次のようになります。

const [fontSize, setFontSize] = useState<FontSizePreference>(() =>
  readFontSizePreference()
)

function changeFontSize(next: FontSizePreference) {
  setFontSize(next)
  saveFontSizePreference(next)
}

ポイントは、初期値に関数を渡して、マウント時に一度だけ保存済みの値を読むことです。
try/catch で包んでおくと、プライベートブラウジングなどで storage が使えない場合も落ちません。

なお、設定画面を開く前の画面にも反映したい場合は、アプリ起動時にも readFontSizePreference() を呼んで html に属性を立てておく必要があります。
設定画面のマウントを待つと、そこを開くまで反映されません。

原因2:クラスの付け替えは、そのコンポーネントの内側にしか効かない

className で settings-font-large を付ける方式は、そのコンポーネントの子孫にしか効きません。
アプリ全体の文字サイズを変えたいのに、設定画面だけが大きくなる、という状態になります。

修正2:ルート要素に属性を立てて、CSS を一本化する

適用先を html 要素に移し、CSS 側は属性セレクタで書きます。

html[data-font-size='small'] {
  font-size: 93.75%;
}

html[data-font-size='large'] {
  font-size: 112.5%;
}

コンポーネントからはクラスの付け替えをやめます。

// 見た目のスケーリングはグローバルな CSS ルールに任せる
<div className="settings-overview">

フォントサイズを rem ベースで書いていれば、html の font-size を変えるだけで全画面の文字が連動して拡大縮小します。
逆に px 固定で書かれた箇所は追従しないので、後述のレイアウト崩れの確認が重要になります。

原因3:遅延ロードされる CSS に書いたルールは全画面に効かない

もう一つ見落としやすいのが、CSS を書く場所です。
ルートに属性を立てる方式にしても、そのルールを設定サブページ専用の CSS に置いていると問題が残ります。
コード分割されたチャンクは、そのページを開くまで読み込まれないためです。

全画面で効かせたいルールは、常時読み込まれるエントリ CSS に置きます。
元の場所には、後から同じ過ちを繰り返さないよう注記を残しておくと安全です。

/* 文字の大きさの html[data-font-size] ルールはエントリCSSへ移動済み。
   このファイルは特定サブページからのみ読み込まれる lazy chunk のため、
   全画面共通で効かせるルールをここに置かない。 */

E2Eテストで「見た目」と「実体」を一緒に固定する

アクセシビリティ系の設定は、壊れても気づきにくい領域です。
Playwright で次の3点をまとめて検証しておくと、再発を防ぎやすくなります。

import { test, expect } from '@playwright/test'

// アプリ側のソースは import できないため、キー名を重複定義している。
// FONT_SIZE_STORAGE_KEY を変更したらここも必ず更新すること
const FONT_SIZE_STORAGE_KEY = 'app:font-size'

test('文字サイズ「大」が保存され、リロード後も反映される', async ({ page }) => {
  await page.goto('/settings')
  const largeButton = page.getByRole('button', { name: '大' })
  await largeButton.click()

  // 見た目:ルート属性が立っている
  await expect(page.locator('html')).toHaveAttribute('data-font-size', 'large')
  // 実体:aria-pressed と保存値が一致している
  await expect(largeButton).toHaveAttribute('aria-pressed', 'true')
  const saved = await page.evaluate(
    (key) => localStorage.getItem(key),
    FONT_SIZE_STORAGE_KEY
  )
  expect(saved).toBe('large')

  // 永続化:リロードしても維持される
  await page.reload()
  await expect(page.locator('html')).toHaveAttribute('data-font-size', 'large')
})

test('最大サイズでも横スクロールが発生しない', async ({ page }) => {
  await page.addInitScript(
    (key) => localStorage.setItem(key, 'large'),
    FONT_SIZE_STORAGE_KEY
  )
  await page.goto('/')
  const overflow = await page.evaluate(
    () => document.documentElement.scrollWidth > document.documentElement.clientWidth
  )
  expect(overflow).toBe(false)
})

見た目のクラスや属性だけを確認すると、「見た目だけ動いて実体が伴っていない」不具合をすり抜けます。
aria-pressed や保存された値まで同じテストで確認するのが肝です。

つまづきやすいポイント

  • storage キー名の重複定義:page.evaluate の中ではアプリのソースを import できません。キー名が文字列で二重管理になるため、変更時に追随させる旨をコメントで明記しておくと事故を防げます
  • px 固定のレイアウトとのズレ:文字だけが大きくなり、幅が固定のままだと折り返しや横スクロールが発生します。最大スケールでの横スクロール有無をE2Eで固定化しておくと安心です
  • CSS の置き場所:属性セレクタに直しても、遅延ロードされるチャンク側に置いたままだと、読み込み前の画面で効きません
  • 起動時の適用漏れ:属性を立てる処理が設定画面のマウント時だけだと、リロード直後に別の画面を開いたときに反映されません

まとめ

設定トグルが効かないときは、「永続化」と「適用範囲」の2点を疑うのが近道です。
保存と復元は専用モジュールにまとめ、useState の初期化関数と変更ハンドラの両方から呼びます。
見た目の切り替えは html の data-* 属性と、常時読み込まれる CSS に任せると、全画面に波及させやすくなります。
コントラストやアニメーション抑制といった他のアクセシビリティ設定も、同じパターンで広げられます。