🐻

Zustand vanilla store を使って通話ロジックを React から分離した話

に公開

株式会社IVRy(アイブリー)のエンジニアの kinashi です。

React の状態管理むずかしいですよね。複雑な状態遷移や外部 SDK を扱う場合、責務分離や、テストに悩んだことがある方も少なくないかと思います。

アイブリーでは電話という少し特殊なドメインを扱っています。Twilio を使ったブラウザ通話処理を @twilio/voice-sdk と React を組み合わせて実装しています。イベントの種類が多く、状態も mutable に変化するため、コンポーネントやフックが複雑化している問題がありました。

https://github.com/twilio/twilio-voice.js

この記事では状態管理を Zustand の vanilla store に移行し、通話まわりのロジックを React から分離するリファクタリングをした話を紹介したいと思います。

https://zustand.docs.pmnd.rs/apis/create-store

状態管理の課題

通話処理では様々なイベントを監視しながら state を管理し、UI に反映する必要があります。

  • 通話の状態(mutable なクラスインスタンスが保持している)
  • 通話中の電話番号情報
  • 保留やミュートの状態
  • 通話開始時間
  • ネットワークの状態やエラー

etc.

イベント駆動

Voice SDK の DeviceCall は EventEmitter ベースの class です。
https://github.com/twilio/twilio-voice.js/blob/2.17.0/lib/twilio/call.ts#L87

イベントリスナーを設定することで着信や通話開始を検知でき、通話の状態はインスタンス内部で管理されています。

const device = new Device(token)

device.on('incoming', (call) => {
  call.on('accept', handleAccept)
  call.on('disconnect', handleDisconnect)
})

<button onClick={() => call.accept()}>応答</button>

通話への応答や終話処理などインスタンスに対してのアクションも基本的にはメソッドを呼び出したあと、非同期で accept, disconnect などのイベントを待ちうけて state を更新します。

インスタンス内部で保持している通話の状態を React 側で使いたい場合は useSyncExternalStore を使って同期する必要があります。

状態遷移の複雑さ

通話は以下のような状態遷移をします。イベントごとに適切な処理と state の更新が必要です。

着信 (incoming) → 応答 (accept) → 通話中 (open) → 終了 (disconnect)
                ↘ 拒否 (reject) → 終了
                ↘ かけ元が切った or ほかの人が通話に応答(cancel) → 終了

イベントの種類が多く、それぞれにハンドラーを用意して state を更新する必要があるため、ハンドラーや useEffect が肥大化していく問題がありました。
これに加えて保留や転送などの機能もあり、より処理が複雑になっています。

React だけで処理していた実装

外部の store を使わず、useState を使っていたコードの実装イメージです。

イベント登録には useEffect が必要ですが、依存配列の管理が難しく、メモ化を誤ると意図しない再実行が発生します。また、state の変更が別の useEffect を発火させ、処理の流れが追いにくくなる問題もありました。

テストにおいても、ハンドラーを切り出すと setState を引数で渡す必要があり、renderHook を使ったテスト自体が複雑になってしまっていました。

イベントハンドラー

const useHandleIncoming = ({
  setCall,
}: {
  setCall: (call: Call | undefined) => void
}) => useCallback((call: Call) => setCall(call), [setCall])

Context

省略していますが、実際のコードでは20以上のイベントをリッスンしています

const useCall = () => {
  const [device, setDevice] = useState<Device>()
  const [call, setCall] = useState<Call>()
  const callStatus = useCallStatus(call)

  const initialize = useInitialize({ setDevice })
  const handleIncoming = useHandleIncoming({ setCall })

  // initialize
  useEffect(() => {
    let cleanup: VoidFunction | undefined
    ;(async () => {
      cleanup = await initialize()
    })()
    return () => cleanup?.()
  }, [initialize])

  // Device のイベント登録
  useEffect(() => {
    if (!device) return

    device.on('incoming', handleIncoming)
    ...

    return () => {
      device.off('incoming', handleIncoming)
    }
  }, [device, handleIncoming])

  // Call のイベント登録
  useEffect(...)

  // その他 state が変わったときの処理
  useEffect(...)
  useEffect(...)

  // UI で使う値だけ返却
  return { callStatus, callStartTime }
}

export const [CallProvider, useCallContext] = constate(useCall)

この他にもエラーハンドリングや UI 用の state の管理、機能追加なども重なり処理が複雑になっていました。

外部ストアに状態を移行して React 依存を減らす

なぜ Zustand を選んだのか

主に zustand/vanilla として提供されている React 非依存の store があるという理由で Zustand を選択しました。

今回のように EventEmitter ベースの SDK を扱うケースでは、イベントハンドラー内から直接 store の state を読み書きする必要があります。Zustand の vanilla store は getState() / setState() を React 外から呼び出せるため、この要件に適しています。

後述しますが、subscribeWithSelector ミドルウェアにより特定の state の変更だけを購読できる点も、イベント駆動のアーキテクチャと相性が良いと感じました。

また、複雑なシステムを作る上でテストは重要な補助線ですが、React に依存したコードは actrenderHook を使ったテストが必要になり、テストそのものが複雑になってしまいます。React 外から store の状態を読み書きできることでテストがシンプルに書ける点も、Zustand を選んだメリットの1つでした。

チームメンバーに紹介してもらったこちらの記事も参考になりました。同様に vanilla store を使って React 外でロジックを管理しています。
https://zwit.link/posts/zustand-telegram-bot/

store

では vanilla store で管理するようにリファクタリングした実装イメージを見ていきましょう。

subscribeWithSelector を使うことで特定の state の変更だけを購読できるようになります。

https://zustand.docs.pmnd.rs/middlewares/subscribe-with-selector

const callStore = createStore<StoreState & StoreActions>()(
  subscribeWithSelector((set) => ({
    device: undefined,
    call: undefined,
    callStartTime: undefined,

    setDevice: (device) => set({ device }),
    clearDevice: () => set({ device: undefined }),
    setCall: (call) => set({ call }),
  })),
)

イベントハンドラー

イベントハンドラーも React に依存しない形でシンプルになりました。

const handleIncoming = (call: Call) => {
  callStore.getState().setCall(call)
}

初期化処理

もともと useEffect で行っていたイベント登録をこちらの処理に切り出すことができました。

const initializeDevice = async (token: string) => {
  const { setDevice, clearDevice } = callStore.getState()

  const device = new Device(token)
  await device.register()

  setDevice(device)

  // cleanup
  return () => {
    device.destroy()
    clearDevice()
  }
}

// Device のイベント登録
const subscribeDevice = () =>
  callStore.subscribe(
    (state) => state.device,
    (device, prevDevice) => {
      prevDevice?.off('incoming', handleIncoming)
      ...

      device?.on('incoming', handleIncoming)
      ...
    },
    { fireImmediately: true },
  )

// Call のイベント登録
const subscribeCall = () => callStore.subscribe(...)

export const initialize = async () => {
  const token = await fetchToken()

  const cleanup = await initializeDevice(token)
  const unsubscribeDevice = subscribeDevice()
  const unsubscribeCall = subscribeCall()

  // cleanup
  return () => {
    cleanup()
    unsubscribeDevice()
    unsubscribeCall()
  }
}

Context

ロジックの大部分は React と分離され、useEffect は初期化処理の呼び出しだけになりました。イベント購読・クリーンアップは store に閉じ込められているので、React 側では UI に必要な値だけを扱うシンプルな構造になっています。React に依存しないため、テストを書くのも容易になりました。

const useCall = () => {
  const device = useStore(callStore, (state) => state.device)
  const call = useStore(callStore, (state) => state.call)
  const callStartTime = useStore(callStore, (state) => state.callStartTime)
  const callStatus = useCallStatus()

  useEffect(() => {
    let cleanup: VoidFunction | undefined
    ;(async () => {
      cleanup = await initialize()
    })()

    return () => {
      cleanup?.()
    }
  }, [])

  // UI で使う値だけ返却
  return { callStatus, callStartTime }
}

export const [CallProvider, useCallContext] = constate(useCall)

devtools

Redux DevTools と連携できるミドルウェアが提供されていて、開発中にデバッグがしやすいというメリットもありました。
各イベントごとに store の event という値を更新するようにすることで、ログ送信と共にイベントが起きた瞬間のインスタンスの値が見られるようにしました。
replacer を使うことで、見たいプロパティだけをシリアライズできます。

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

const devtoolsOptions: DevtoolsOptions = {
  name: 'callStore',
  enabled: import.meta.env.MODE !== 'production',
  serialize: {
    options: {
      undefined: true,
    },
    replacer: (key: string, value: unknown) => {
      try {
        if (key === 'device' && value) {
          const device = value as Device
          return {
            state: device.state,
            isBusy: device.isBusy,
            edge: device.edge,
          }
        }
      } catch (error) {
        console.error(error)
      }

      return value
    },
  },
}

export const callStore = createStore<StoreState & StoreActions>()(
  subscribeWithSelector(
    devtools(
      (set, get) => ({
        ...initialState,
        setEvent(params) {
          set({ event: params }, false, 'setEvent')
        },
      }),
      devtoolsOptions,
    ),
  ),
)

まとめ

Zustand の vanilla store を使って、React から UI 以外のロジックを分離した設計を紹介しました。

  • mutable なインスタンスを含め、状態管理を store に集約した
  • subscribeWithSelector があるのでイベント購読処理も React と分離できた
  • React 側は useStore で購読するだけのシンプルな構造になった
  • テストは renderHook 不要で、純粋関数を直接テスト可能になった

外部 SDK や mutable なインスタンスを扱う際の設計の一例として参考になれば幸いです。

さいごに

IVRy のアドベントカレンダーは今年も、職種に依らず全職種でやっています!
他の記事も面白いので読んでいただけたら嬉しいです。
https://adventar.org/calendars/11553

AIブログリレーもやっているので、こちらも要チェックです!
https://adventar.org/calendars/11552

IVRyテックブログ

Discussion