SF
藤本祥の記録

なぜ zustand に保存したメッセージの追加が即座UIに反映されなかったのか?

Published: 2025/9/27

問題の概要

Zustand のストアから関数(セレクター)を使ってデータを取得する際、データが更新されてもコンポーネントが再レンダリングされない問題が発生しました。具体的には、チャットメッセージを追加してもリアルタイムで画面に反映されない状況でした。

発生した問題のコード例

以下のコードでは、メッセージが追加されても sortedMessages が更新されません:

// ❌ 問題のあるコード
export const useChat = (params: { character: { id: string } }) => {
  // getMessagesByCharacterId は関数の参照なので、常に同じ
  const { getMessagesByCharacterId } = useChatMessagesStore()

  const sortedMessages = useMemo(() => {
    // この関数を呼び出してもリアクティブではない
    const messages = getMessagesByCharacterId(params.character.id)

    return [...messages].sort((a, b) => {
      const aTime = a.createdAt instanceof Date
        ? a.createdAt.getTime()
        : new Date(a.createdAt).getTime()
      const bTime = b.createdAt instanceof Date
        ? b.createdAt.getTime()
        : new Date(b.createdAt).getTime()
      return bTime - aTime
    })
  }, [params.character.id, getMessagesByCharacterId]) // 関数は常に同じ参照
}

なぜメッセージの追加が反映されなかったのか

1. 関数の参照は不変

getMessagesByCharacterId は関数であり、ストアのデータが変わっても関数自体の参照は変わりません。

2. useMemo の依存配列の問題

依存配列に関数を入れても、関数の参照は常に同じなので再計算されません。

3. Zustand の仕組み

Zustand はセレクター関数で指定された状態の部分だけを監視します。上記のコードでは、ストアから取得したのは「関数」であり、「状態」ではないため、状態の変更を検知できません。

解決方法

方法1: 特定のデータを直接購読する(推奨)

// ✅ 修正後のコード
export const useChat = (params: { character: { id: string } }) => {
  const { addMessage, addMessages, getMessagesByCharacterId } = useChatMessagesStore()

  // 特定のキャラクターのメッセージを直接購読
  // これにより、このキャラクターのメッセージが変更されると自動的に再レンダリング
  const characterMessages = useChatMessagesStore(
    (state) => state.messages[params.character.id] || []
  )

  const sortedMessages = useMemo(() => {
    // 直接購読したデータを使用
    return [...characterMessages].sort((a, b) => {
      const aTime = a.createdAt instanceof Date
        ? a.createdAt.getTime()
        : new Date(a.createdAt).getTime()
      const bTime = b.createdAt instanceof Date
        ? b.createdAt.getTime()
        : new Date(b.createdAt).getTime()
      return bTime - aTime
    })
  }, [characterMessages]) // characterMessages が変わると再計算される
}

方法2: ストア全体を購読する(パフォーマンス注意)

// ⚠️ 動作するが、パフォーマンスに影響する可能性あり
export const useChat = (params: { character: { id: string } }) => {
  const { getMessagesByCharacterId } = useChatMessagesStore()

  // messages 全体を購読
  const messagesState = useChatMessagesStore((state) => state.messages)

  const sortedMessages = useMemo(() => {
    const messages = getMessagesByCharacterId(params.character.id)

    return [...messages].sort((a, b) => {
      // ソート処理
    })
  }, [params.character.id, getMessagesByCharacterId, messagesState])
}

ベストプラクティス

1. 必要なデータだけを購読

パフォーマンスを考慮し、必要最小限のデータを購読することで、不要な再レンダリングを防ぎます。

2. セレクター関数を活用

Zustand のセレクター機能を使って、特定のデータだけを効率的に取得します。

3. 空配列の扱いに注意

|| [] で毎回新しい配列を作ると無駄な再レンダリングが発生する可能性があります:

// 空配列を定数として定義してパフォーマンスを改善
const EMPTY_MESSAGES: IMessage[] = []

const characterMessages = useChatMessagesStore(
  (state) => state.messages[params.character.id] ?? EMPTY_MESSAGES
)

まとめ

今回の問題は、Zustand のリアクティビティの仕組みを正しく理解していなかったことが原因でした。

重要なポイント:

この修正により、メッセージが追加されると即座にコンポーネントが再レンダリングされ、新しいメッセージが画面に表示されるようになりました。