📢

Reactでaria-live通知が安定しない問題と対処法

に公開

はじめに

Reactでアクセシビリティ対応をしていると、aria-live領域の読み上げがうまくいかないケースに遭遇することがあります。状態を更新したはずなのにスクリーンリーダーが反応しない、連続した通知が飛ばされる、といった具合です。

自分もこの問題に遭遇して調べていたところ、いくつかの対処法があることを知りました。この記事では、なぜそういった問題が起きるのかと、対処法についてまとめてみます。まだ十分に検証できていない部分もありますが、同じ問題に悩んでいる方の参考になれば幸いです。

aria-liveとは

aria-liveは、動的に変化するコンテンツをスクリーンリーダーに通知するための属性です。

<div aria-live="polite">ここの内容が変わるとスクリーンリーダーが読み上げる</div>

値は2種類あります。

挙動
polite 現在の読み上げが終わってから通知する。フォーム送信完了やステータス更新など、急ぎでない通知向け
assertive 現在の読み上げを中断して即座に通知する。エラーや警告など、すぐに伝えるべき内容向け

基本的にはpoliteを使い、本当に緊急性のある場合だけassertiveを使うのが推奨されているようです。assertiveを多用すると、ユーザーの操作を頻繁に中断してしまうためとのことです。

また、aria-atomic="true"を併用すると、領域内の一部が変わっただけでも領域全体を読み上げるよう指示できます。

<div aria-live="polite" aria-atomic="true">ここの内容が変わると全体を読み上げる</div>

Reactでaria-liveを使うときの問題

一見すると、Reactでaria-liveを使うのは簡単そうに見えます。

const [message, setMessage] = useState('')

const handleSave = async () => {
  await saveData()
  setMessage('保存しました')
}

return (
  <div>
    <button onClick={handleSave}>保存</button>
    <div aria-live="polite">{message}</div>
  </div>
)

しかし、実際に使ってみると期待通りに動かないケースがありました。以下は自分が遭遇した、あるいは調べていて見つけた問題です。

問題1:同じメッセージを再度設定しても通知されない

const handleError = () => {
  setMessage('エラーが発生しました')
}

// 1回目:通知される
handleError()

// 2回目:通知されない(DOMが変わっていないから)
handleError()

スクリーンリーダーはDOMの変更を検知して読み上げるため、同じ文字列を設定してもDOMに変化がなければ何も起きない、という理解です。

問題2:連続した更新で通知が飛ばされる

const handleMultipleActions = async () => {
  setMessage('処理1が完了しました')
  await doSomething()
  setMessage('処理2が完了しました')
  await doSomethingElse()
  setMessage('処理3が完了しました')
}

Reactは状態更新をバッチ処理することがあるため、すべての通知がスクリーンリーダーに届くとは限らないようです。

問題3:React 18のconcurrent renderingとの相性

React 18で導入されたconcurrent renderingでは、レンダリングが中断・再開されることがあります。startTransitionで囲んだ更新は「中断可能」で優先度が低いものとしてマークされます。

const [isPending, startTransition] = useTransition()

const handleSearch = (query: string) => {
  // この更新は中断可能で、優先度が低い
  startTransition(() => {
    setResults(search(query))
    setMessage(`${results.length}件見つかりました`)
  })
}

UIの反映が遅延したり、途中の状態が破棄されたりするため、リアルタイム性が求められるaria-liveとは相性が良くないようです。

なぜこういった問題が起きるのか

自分の理解では、Reactの状態管理とスクリーンリーダーの検知の間にギャップがあるためです。

  1. Reactの世界: setStateで状態を更新する
  2. Reactの内部処理: Virtual DOMの差分計算、バッチ処理、concurrent rendering
  3. ブラウザのDOM: 実際のDOMが更新される
  4. スクリーンリーダー: DOMの変更を検知して読み上げる

ReactのsetStateは「状態を更新したい」という意図を伝えているだけで、実際のDOM更新はReactの都合で行われます。スクリーンリーダーが見ているのはあくまでブラウザのDOMなので、Reactの状態更新とスクリーンリーダーへの通知は必ずしも一致しません。

対処法

いくつかの対処法を調べてみました。

空にしてから再設定する

一度内容を空にしてから設定し直すことで、DOMに変更を発生させます。

単純に考えると、こう書きたくなります。

const handleError = () => {
  setMessage('')
  setMessage('エラーが発生しました')
}

しかし、React 18ではこれらのsetStateがバッチ処理で1回のレンダリングにまとめられる可能性があります。そうなると、DOMは「空」を経由せずに直接新しいメッセージに更新されるため、スクリーンリーダーが変更を検知しないかもしれません。

requestAnimationFrameで1フレーム待つことで、空文字列への更新が確実にDOMに反映されてから新しいメッセージをセットできます。

const [content, setContent] = useState('')

const announce = useCallback((message: string) => {
  setContent('')
  requestAnimationFrame(() => {
    setContent(message)
  })
}, [])

return <div aria-live="polite">{content}</div>

ただ、正直なところ、バッチ処理がどこまで影響するかは状況によって異なるかもしれません。自分の環境ではrequestAnimationFrameを使う方が安定していましたが、シンプルに連続でsetStateするだけで動くケースもあるかもしれません。

タイムスタンプを付ける

const [announcement, setAnnouncement] = useState({ message: '', timestamp: 0 })

const announce = useCallback((message: string) => {
  setAnnouncement({ message, timestamp: Date.now() })
}, [])

return (
  <div aria-live="polite" key={announcement.timestamp}>
    {announcement.message}
  </div>
)

keyを変えることでReactに「別の要素」と認識させ、DOMの再生成を強制します。ただし、要素の再生成はコストがかかることに加え、スクリーンリーダーから見ると「監視していたlive regionが消えて、新しい要素が現れた」という扱いになるため、通知が安定しない可能性があります。

MutationObserverを使う

より確実にDOM変更を検知したい場合は、MutationObserverを使う方法もあります。

const ref = useRef<HTMLDivElement>(null)
const [content, setContent] = useState('')
const pendingRef = useRef<string | null>(null)

useEffect(() => {
  const el = ref.current
  if (!el) return

  const observer = new MutationObserver(() => {
    if (pendingRef.current !== null && el.textContent === '') {
      const message = pendingRef.current
      pendingRef.current = null
      queueMicrotask(() => setContent(message))
    }
  })

  observer.observe(el, { childList: true, characterData: true, subtree: true })
  return () => observer.disconnect()
}, [])

const announce = useCallback((message: string) => {
  pendingRef.current = message
  setContent('')
}, [])

return <div ref={ref} aria-live="polite">{content}</div>

ただ、useEffect内でref.currentを使っているため、ESLintのreact-hooks/exhaustive-depsルールとの相性が良くありません。requestAnimationFrame版で問題なければ、そちらの方がシンプルかと思います。

どのアプローチを選ぶか

正直なところ、どれがベストかは自分にもわかりません。以下は調べた範囲での整理です。

アプローチ メリット デメリット
requestAnimationFrame シンプル タイミングが保証されない
keyの変更 確実にDOMが変わる 要素の再生成コストがかかる
MutationObserver DOM変更を確実に検知できる 実装が複雑、ESLintとの相性が悪い

自分であれば、まずはrequestAnimationFrameで試してみて、それで問題があれば他の方法を検討する、という順序で進めるかと思います。

連続した通知を順番に読み上げさせたい場合はキューを実装する方法も考えられますが、スクリーンリーダーが読み上げる時間を待つためにsetTimeoutを使うことになり、コストが高くなります。実際のところ、連続通知が必要なケースがどれくらいあるかは疑問で、多くの場合は最後のメッセージだけ通知できれば十分かもしれません。

注意点

この記事の内容にはいくつか注意点があります。

スクリーンリーダーごとの挙動差

この記事で紹介した方法が、すべてのスクリーンリーダーで同じように動作するかは検証できていません。

主要なOSにはスクリーンリーダーが標準搭載されています。

  • macOS/iOS: VoiceOver(Cmd + F5で起動)
  • Windows: Narrator(Win + Ctrl + Enterで起動)
  • ChromeOS/Chrome: ChromeVox(Ctrl + Alt + Zで起動)

また、サードパーティ製のNVDA(Windows、無料)やJAWS(Windows、有料)も広く使われています。これらはそれぞれ挙動が異なる可能性があるため、実際にアクセシビリティ対応を行う際は、対象とするスクリーンリーダーでの検証が必須かと思います。

aria-liveの配置

aria-live属性を持つ要素は、ページ読み込み時点でDOMに存在している必要があるようです。動的に追加されたaria-live要素は、スクリーンリーダーに認識されないことがあります。

// 良い例:常にDOMに存在
<div aria-live="polite">{message}</div>

// 避けたい例:条件付きでレンダリング
{message && <div aria-live="polite">{message}</div>}

concurrent renderingとの付き合い方

React 18のconcurrent renderingを使っている場合、aria-liveの更新はstartTransitionの外で行うのが無難なようです。

const handleSearch = (query: string) => {
  // 検索結果の更新はtransitionで
  startTransition(() => {
    setResults(search(query))
  })

  // aria-liveへの通知はtransitionの外で
  announce(`検索中...`)
}

おわりに

Reactの状態管理とスクリーンリーダーの間にあるギャップは、意識しないと気づきにくい問題だと感じました。requestAnimationFrameで空にしてから再設定する方法が一番シンプルで、まずはそこから試してみるのが良さそうです。

ただ、正直なところ、自分もまだ十分に検証できていない部分が多いです。スクリーンリーダーでの実際の検証なしに「これで大丈夫」とは言えません。もっと良いやり方や、この記事の内容に間違いがあれば、コメントで教えていただけると助かります。

Discussion