🤼

意外と使える?! StorageEventで作るReactコンポーネント同期パターン

2024/12/23に公開

これは SMat Advent Calendar 2024 の12/23分の記事です。

はじめに

こんにちは、株式会社エスマット エンジニアの hi6okuni です。

最近Reactでテーブルカラム内にオートコンプリート付きのインプットを設置する機会がありました。

オートコンプリートの検索候補は、localStorageやAPIなど、様々なソースから取得するパターンが考えられます。Reactのライフサイクルにおいて、これらの検索候補を複数コンポーネントで同期し管理する場合、ZustandやContext APIといったグローバルステートを利用することが多いのではないでしょうか?(API経由の場合は、APIクライアントのキャッシュ機能を活用するのが一般的かもしれません)

今回のプロジェクトについても「テーブル内のあるカラムで入力された新規ワードは、他のカラムで即座に検索候補として表示される」という要件がありました。この要件に対して検索候補のソースはlocalStorageを利用しつつ、グローバルステートは用いない、より軽量な実装方法を模索しました。この記事では、その実装方法と、同一ページ内での即時反映をシンプルに実現するためのアプローチについて共有させていただきます。

実装の概要と動作デモ

具体的な方法としては、各コンポーネントのローカルステートとStorageEventを活用して実装をしました。各入力フィールドのローカルステートを維持しながら、localStorageの変更を検知した時、対象となる他コンポーネントでもリアルタイムに更新内容が反映される仕組みを目指しました。

実装したコンポーネントの主な特徴は以下の2点です:

  • 新しい入力値が検索候補に存在しない場合、追加して全コンポーネントに同期
  • 既存の検索候補を入力・選択した場合は、不要な更新処理をスキップ

React DevToolsの Highlight updates when components render. をオンにした状態での動作例を見てみましょう:

新しい検索候補が追加された際の再レンダリング
新しい入力値が追加された際、全ての対象コンポーネントで適切に再レンダリングが発生して検索候補がリアルタイムで更新されている

既存の検索候補を入力した際の動作
既存の検索候補を入力した場合、不要な再レンダリングは発生しない

StorageEventを利用したコンポーネント間の同期

今回の実装で最も重要なポイントは、StorageEventを利用したコンポーネント間の同期です。実装の核となる2つのポイント、StorageEventリスナーの設定StorageEventのマニュアル発火 について詳しく見ていきましょう。

1. StorageEventリスナーの設定

// storageイベントのリスナーを設定
useEffect(() => {
  const handleStorageChange = (event: StorageEvent) => {
    if (event.key === STORAGE_KEY) {
      const newValue = event.newValue ? JSON.parse(event.newValue) : []
      setSuggestions(newValue)
    }
  }

  window.addEventListener('storage', handleStorageChange)
  return () => window.removeEventListener('storage', handleStorageChange)
}, [STORAGE_KEY])

このリスナーの設定には以下のような特徴があります:

  • コンポーネントのマウント時にイベントリスナーを設定
  • STORAGE_KEYによるフィルタリングで必要な更新のみを検知
  • コンポーネントのアンマウント時に適切にクリーンアップ

通常、StorageEventは異なるウィンドウ間でのlocalStorageの変更を検知するために使用されますが、今回の実装では後述する手動でのイベント発火と組み合わせることで、同一ページ内での同期も実現しています。

2. StorageEventのマニュアル発火

// onBlurなどの関数内を想定
// 検索候補の更新
const updatedSuggestions = [newValue, ...suggestions].sort((a, b) =>
  a.localeCompare(b, 'ja', {
    sensitivity: 'base',
    ignorePunctuation: true,
  })
)

// localStorageの更新とイベントの発火
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedSuggestions))

const event = new StorageEvent('storage', {
  key: STORAGE_KEY,
  newValue: JSON.stringify(updatedSuggestions),
  oldValue: JSON.stringify(suggestions),
  storageArea: localStorage,
})
window.dispatchEvent(event)

更新の通知処理は以下のように実現しています。

  • 手動でのStorageEvent作成
    • keyプロパティで更新対象を特定
    • newValue/oldValueで変更内容を明示
    • storageAreaでlocalStorageを指定
  • イベントのdispatch
    • window.dispatchEvent()で同一ページ内の他コンポーネントに通知
    • 各コンポーネントのリスナーが反応して状態を更新

この2つの処理の組み合わせにより、以下のような同期の流れが実現されています。

オートコンプリート機能付きインプットコンポーネントの全体像
export const SampleInputAutocomplete: React.FC<
  SampleInputAutocompleteProps
> = ({ id, columnKey, placeholder, initialValue = '', forms = 'input' }) => {
  const client = useTRPCClient()
  const STORAGE_KEY = `sample_autocomplete_suggestions_${columnKey}`

  const [suggestions, setSuggestions] = useState<string[]>(() => {
    return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
  })

  const onSelectSuggestion = useCallback(
    async (selectedSuggestion: string) => {
      try {
        if (columnKey === 'A') {
          await client.serviceA.edit.mutate({
            Id: id,
            A: selectedSuggestion,
          })
        } else if (columnKey === 'B') {
          await client.serviceB.edit.mutate({
            Id: id,
            B: selectedSuggestion,
          })
        }
      } catch (error) {
        throw new Error('Error')
      }
    },
    [
      client.serviceA.edit,
      client.serviceB.edit,
      columnKey,
      id,
    ],
  )

  const onBlur = useCallback(
    async (newValue: string) => {
      // 保存処理
      try {
        if (columnKey === 'A') {
          await client.serviceA.edit.mutate({
            Id: id,
            A: newValue,
          })
        } else if (columnKey === 'B') {
          await client.serviceB.edit.mutate({
            Id: id,
            B: newValue,
          })
        }
      } catch (error) {
        throw new Error('Error')
      }

      // 重複または空文字は候補として保存しないので早期リターン
      if (!newValue.trim() || suggestions.includes(newValue)) return

      const updatedSuggestions = [newValue, ...suggestions].sort((a, b) =>
        a.localeCompare(b, 'ja', {
          sensitivity: 'base', // 大文字小文字を区別しない
          ignorePunctuation: true, // 句読点を無視
        }),
      )

      // ローカルストレージに保存
      localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedSuggestions))

      // 状態を更新
      setSuggestions(updatedSuggestions)

      // ローカルストレージに保存
      // 同じページ内の他のコンポーネントに変更を通知
      const event = new StorageEvent('storage', {
        key: STORAGE_KEY,
        newValue: JSON.stringify(updatedSuggestions),
        oldValue: JSON.stringify(suggestions),
        storageArea: localStorage,
      })
      window.dispatchEvent(event)
    },
    [
      STORAGE_KEY,
      client.serviceA.edit,
      client.serviceB.edit,
      columnKey,
      id,
      suggestions,
    ],
  )

  const onDeleteSuggestion = useCallback(
    (deletedSuggestion: string) => {
      const updatedSuggestions = suggestions.filter(
        (suggestion) => suggestion !== deletedSuggestion,
      )

      // ローカルストレージに保存
      localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedSuggestions))

      // 状態を更新
      setSuggestions(updatedSuggestions)

      // ローカルストレージに保存
      // 同じページ内の他のコンポーネントに変更を通知
      const event = new StorageEvent('storage', {
        key: STORAGE_KEY,
        newValue: JSON.stringify(updatedSuggestions),
        oldValue: JSON.stringify(suggestions),
        storageArea: localStorage,
      })
      window.dispatchEvent(event)
    },
    [STORAGE_KEY, suggestions],
  )

    // storageイベントのリスナーを設定
    useEffect(() => {
      const handleStorageChange = (event: StorageEvent) => {
        if (event.key === STORAGE_KEY) {
          const newValue = event.newValue ? JSON.parse(event.newValue) : []
          setSuggestions(newValue)
        }
      }
  
      // イベントリスナーを追加
      window.addEventListener('storage', handleStorageChange)
  
      // クリーンアップ
      return () => {
        window.removeEventListener('storage', handleStorageChange)
      }
    }, [STORAGE_KEY])

  return (
    // 汎用InputAutocompleteコンポーネントにpropsとしてsuggestionsやイベント関数を渡す
    <InputAutocomplete
      forms={forms}
      suggestions={suggestions}
      placeholder={placeholder}
      initialValue={initialValue}
      onSelectSuggestion={onSelectSuggestion}
      onDeleteSuggestion={onDeleteSuggestion}
      onBlur={onBlur}
    />
  )
}

まとめ

今回、StorageEventのマニュアル発火というそれほど一般的でない?アプローチでコンポーネント間の状態同期を実現してみました。実装自体はシンプルで、期待通りの動作を得られました。

ローカルステートとイベントリスナーの重複という観点から、多くのシチュエーションでベストプラクティスではないかもしれませんが、今回のテーブル表示件数の仕様範囲(デフォルト100件、最大1,000件)では、動作に遅延等はなく、目立った悪影響は感じられませんでした。

状態管理の実装パターンは、プロジェクトの要件や規模によって最適解が変わってきます。今回のアプローチは、グローバルスタートを利用したくないがコンポーネント間で同期を取りたい場面に対する一つの飛び道具的な選択肢として、覚えておいて損はないかもしれません!

株式会社エスマット

Discussion