😀

【Next.js14】オプティミスティックUI更新でチャットメッセージ・いいね機能のUX改善

2023/11/18に公開

はじめに

今回ですが、タイトルにあるようにNext.jsでいいね機能を実装した際のUX改善実装になります。
概要としては、Appディレクトリにて、APIでいいね機能を作成し、フロント側で呼び出すということをしています。その際に、サーバーとのやり取りを待つのではなく、先にUIを更新して後からサーバーと同期をするというような実装をしています。

※次の項でより詳細な説明をしています

オプティミスティック(楽観的)UI更新とは

簡単な説明になってしまいますが、サーバーからのレスポンスを待つことなくフロント側でUIのデータを先に、更新する処理をいいます。
要するに、サーバーとのやり取りを待ってから更新すると多少の時間ユーザーを待たせてしまうため、より快適にアプリを使ってもらうために見た目の部分だけ先に更新したように見せるということです。

ただ、上述の内容だけの実装だとサーバーとのデータ整合性が保たれないので、データ整合性を保つために、オプティミスティックUI更新後にサーバーとのデータフェッチを行い、整合性を保つようにしないといけません。
また、エラーの場合はtry-catch文catchでUIを更新前に戻すという処理も必要になります。

そのため、大幅にUXの改善は望めるですが、処理や記述が複雑になるといったデメリットがあります。

簡単にではありますが、以上がオプティミスティック更新の説明になります。
以下の記事により詳細なオプティミスティック更新の内容(メリットや図解)があり、非常にわかりやすいので、こちらも読むといいかもしれません。

Optimistic Update (楽観的更新)でストレスのないUXを実現する

メッセージ投稿機能

まずは、メッセージ投稿機能です。
オプティミスティック更新部分の実装を見る前に実際の画面となるコンポーネントの実装から紹介します。
(イメージがしやすくなるかと思います)

MessageList.tsx
'use client'

import React from 'react'

import MessageItem from '@/components/messages/MessageItem'
import useMessages from '@/hooks/useMessages'
import type { Message } from '@/types/Message'

type MessageListProps = {
  roomId: string
}

const MessageList: React.FC<MessageListProps> = ({ roomId }) => {
  const { data: messages } = useMessages(roomId)

  return (
    <div className="bg-gray-100 h-[calc(100vh-96px-90px)] px-10 pt-[46px] pb-0 overflow-scroll">
      {messages?.map((message: Message) => (
        <MessageItem key={message.id} message={message} />
      ))}
    </div>
  )
}

export default MessageList

useMessagesはチャットルームに紐づくメッセージの一覧を取得するuseSWRでデータフェッチするフックです。

MessageItem.tsx
'use client'

import { format } from 'date-fns'
import Image from 'next/image'
import React from 'react'
import { AiFillHeart, AiOutlineHeart } from 'react-icons/ai'

import useHandleLike from '@/hooks/useHandleLike'
import type { Message } from '@/types/Message'

type MessageItemProps = {
  message: Message
}

const MessageItem: React.FC<MessageItemProps> = ({ message }) => {
  const { isCurrentUserMessage, liked, likeCount, handleLike } = useHandleLike(
    message.id,
    message.userId,
  )

  return (
    <div className="mt-[10px] mx-0 mb-0">
      <div className={`flex ${isCurrentUserMessage && 'justify-end'}`}>
        <div className="text-black text-[10px] sm:text-xs md:text-sm">
          {message.user.name}
        </div>
        <div className="text-gray-400 text-[10px] sm:text-xs md:text-sm pl-[10px]">
          {format(new Date(message.createdAt), 'yyyy/MM/dd hh:mm:ss')}
        </div>
      </div>
      {message.content && (
        <div className="mt-[10px]">
          <div
            className={`text-black text-xs sm:text-sm ${
              isCurrentUserMessage && 'text-right'
            }`}
          >
            {message.content}
          </div>
        </div>
      )}
      {message.image && (
        <div
          className={`mt-[10px] ${isCurrentUserMessage && 'flex justify-end'}`}
        >
          <Image
            width={100}
            height={100}
            src={message.image}
            alt="message image"
            className="max-w-full h-auto"
          />
        </div>
      )}
      <div
        className={`flex justify-end my-3  ${
          isCurrentUserMessage ? 'w-full' : 'w-1/2'
        }`}
      >
        {liked ? (
          <AiFillHeart
            size={24}
            className="text-red-500 cursor-pointer"
            onClick={handleLike}
          />
        ) : (
          <AiOutlineHeart
            size={24}
            className="text-red-500 cursor-pointer"
            onClick={handleLike}
          />
        )}
        <span className="text-red-500">{likeCount}</span>
      </div>
    </div>
  )
}

export default MessageItem

MessageItemが実際にオプティミスティック更新した際に更新内容が反映されるコンポーネントです。
このコンポーネントのメッセージ表示といいね数・いいね時のアイコンの変化がオプティミスティックUI更新の対象となります。

メッセージのオプティミスティックUI更新

渡しの場合は、カスタムフック内に処理を記述しました。

useMessages.ts
import { useCallback } from 'react'
import useSWR from 'swr'

import fetcher from '@/libs/fetcher'
import type { Message } from '@/types/Message'

const useMessages = (roomId: string) => {
  const { data, error, isLoading, mutate } = useSWR<Message[]>(
    `/api/rooms/${roomId}/messages`,
    fetcher,
  )

  const addMessage = useCallback(
    (message: Message) =>
      mutate((messages) => [...(messages || []), message], false),
    [mutate],
  )

  return { data, addMessage, error, isLoading, mutate }
}

export default useMessages

オプティミスティックUI更新で使用するのは、addMessageです。
やっていることは引数に疑似データとしてのmessageを受け取り、現時点で画面表示されているメッセージ一覧に追加する処理を行っています。

ちなみにuseSWRmutateはローカルデータを更新するために使用するものです。
引数を渡すと、引数に渡したものを新しいデータとして、ローカルキャッシュを更新します。
(引数を指定しないと、ローカルキャッシュのみ更新します)
また、第二引数にfalseを指定することで、データ更新後の再検証を行わないようにできます。
これにより不要なネットワークリクエストの発生を防ぐことができます。

useCreateMessage.ts
import { useRouter } from 'next/navigation'
import { useCallback } from 'react'
import type { UseFormReset } from 'react-hook-form'
import toast from 'react-hot-toast'

import useCurrentUser from '@/hooks/useCurrentUser'
import useMessages from '@/hooks/useMessages'
import type { MessageInput } from '@/types/MessageInput'

const useCreateMessage = (roomId: string) => {
  const { data: currentUser } = useCurrentUser()
  const { addMessage } = useMessages(roomId)
  const router = useRouter()

  const handleCreateMessage = useCallback(
    async (
      data: MessageInput,
      reset: UseFormReset<MessageInput>,
      setBase64: (prev: string) => void,
    ) => {
      if (!currentUser) return

      const newMockMessage = {
        id: '3b40b0b9-eaa2-44ba-b361-92ca653fb667',
        userId: currentUser.id,
        roomId,
        content: data.content,
        image: data.image,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
        user: currentUser,
      }

      // オプティミスティックUI更新
      addMessage(newMockMessage)

      setBase64('')

      try {
        await fetch('/api/messages', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            userId: currentUser?.id,
            roomId,
            content: data.content,
            image: data.image,
          }),
        })
      } catch (err) {
        toast.error('Failed create message')
        console.log(err)
      } finally {
        reset()
        router.refresh()
      }
    },
    [addMessage, currentUser, roomId, router],
  )

  return { handleCreateMessage }
}

export default useCreateMessage

このカスタムフックでオプティミスティックUI更新処理を実装しています。
全体の流れとしては以下になります。

  • オプティミスティックUI更新を行う
  • サーバー(API)との通信をする
  • router.refresh()でデータを最新の状態とする(サーバーとのデータ整合性を保つ役割)

まず、オプティミスティックUI更新をするためにnewMockMessageで擬似的にMessage型のデータを作成します。このときにIDは固定値とします。
そうしないと、特定のIDのときにAPIにリクエストを送らないという分岐をすることが難しくなるためです。

次に、先ほど説明したaddMessageでオプティミスティックUI更新をします。
そして、APIと通信し、MessageをDBに保存する処理を行います。
最終的に、next/navigationrouter.refresh()で最新のデータとなるよう、強制的にページを再読み込み(コンポーネントの再レンダリング)を行い、データ整合性を担保します。

これでメッセージ投稿のオプティミスティックUI更新処理が実装できます。

ちなみに、実際にメッセージを投稿するフォームのコンポーネントは以下のようになっています。

Form.tsx
'use client'

import { zodResolver } from '@hookform/resolvers/zod'
import React, { useCallback, useState } from 'react'
import type { SubmitHandler } from 'react-hook-form'
import { useForm } from 'react-hook-form'

import Button from '@/components/Button'
import Input from '@/components/Input'
import ImageUploader from '@/components/messages/ImageUploader'
import useCreateMessage from '@/hooks/useCreateMessage'
import type { MessageInput } from '@/types/MessageInput'
import { messageScheme } from '@/types/MessageInput'

type FormProps = {
  roomId: string
}

const Form: React.FC<FormProps> = ({ roomId }) => {
  const [base64, setBase64] = useState('')

  const {
    handleSubmit,
    control,
    reset,
    formState: { isSubmitting },
  } = useForm<MessageInput>({
    resolver: zodResolver(messageScheme),
    defaultValues: { content: '', image: '' },
  })

  const { handleCreateMessage } = useCreateMessage(roomId)

  const onSubmit: SubmitHandler<MessageInput> = useCallback(
    async (data) => await handleCreateMessage(data, reset, setBase64),
    [handleCreateMessage, reset],
  )

  return (
    <form
      className="bg-gray-300 h-[90px] py-5 px-10 flex"
      onSubmit={handleSubmit(onSubmit)}
    >
      <div className="bg-white flex w-full relative">
        <Input
          id="content"
          name="content"
          type="text"
          label="type a message"
          control={control}
          disabled={isSubmitting}
          isChat
          isRoom
        />
        <ImageUploader
          base64={base64}
          setBase64={setBase64}
          name="image"
          disabled={isSubmitting}
          control={control}
        />
      </div>
      <Button type="submit" label="送信" isChat />
    </form>
  )
}

export default Form

いいね機能

続いて、いいねのオプティミスティックUI更新処理を見ていきましょう。
こちらもイメージしやすいよう画面から説明します。といっても、前項ででてきたMessageItem.tsxになるのですが、念のためこちらでもコードを載せておきます。

MessageItem.tsx
'use client'

import { format } from 'date-fns'
import Image from 'next/image'
import React from 'react'
import { AiFillHeart, AiOutlineHeart } from 'react-icons/ai'

import useHandleLike from '@/hooks/useHandleLike'
import type { Message } from '@/types/Message'

type MessageItemProps = {
  message: Message
}

const MessageItem: React.FC<MessageItemProps> = ({ message }) => {
  const { isCurrentUserMessage, liked, likeCount, handleLike } = useHandleLike(
    message.id,
    message.userId,
  )

  return (
    <div className="mt-[10px] mx-0 mb-0">
      <div className={`flex ${isCurrentUserMessage && 'justify-end'}`}>
        <div className="text-black text-[10px] sm:text-xs md:text-sm">
          {message.user.name}
        </div>
        <div className="text-gray-400 text-[10px] sm:text-xs md:text-sm pl-[10px]">
          {format(new Date(message.createdAt), 'yyyy/MM/dd hh:mm:ss')}
        </div>
      </div>
      {message.content && (
        <div className="mt-[10px]">
          <div
            className={`text-black text-xs sm:text-sm ${
              isCurrentUserMessage && 'text-right'
            }`}
          >
            {message.content}
          </div>
        </div>
      )}
      {message.image && (
        <div
          className={`mt-[10px] ${isCurrentUserMessage && 'flex justify-end'}`}
        >
          <Image
            width={100}
            height={100}
            src={message.image}
            alt="message image"
            className="max-w-full h-auto"
          />
        </div>
      )}
      <div
        className={`flex justify-end my-3  ${
          isCurrentUserMessage ? 'w-full' : 'w-1/2'
        }`}
      >
        {liked ? (
          <AiFillHeart
            size={24}
            className="text-red-500 cursor-pointer"
            onClick={handleLike}
          />
        ) : (
          <AiOutlineHeart
            size={24}
            className="text-red-500 cursor-pointer"
            onClick={handleLike}
          />
        )}
        <span className="text-red-500">{likeCount}</span>
      </div>
    </div>
  )
}

export default MessageItem

オプティミスティックUI更新が反映される箇所は以下の部分です。

<div
    className={`flex justify-end my-3  ${
        isCurrentUserMessage ? 'w-full' : 'w-1/2'
    }`}
>
    {liked ? (
        <AiFillHeart
            size={24}
            className="text-red-500 cursor-pointer"
            onClick={handleLike}
          />
        ) : (
          <AiOutlineHeart
            size={24}
            className="text-red-500 cursor-pointer"
            onClick={handleLike}
          />
    )}
    <span className="text-red-500">{likeCount}</span>
</div>

いいねのオプティミスティックUI更新処理

こちらは複数のカスタムフックを呼び出しているので、メッセージ投稿より複雑になっていますが、かなりUXは良くなった処理なので、やってみると勉強になります。

useHandleLike.ts
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'

import useCurrentUser from '@/hooks/useCurrentUser'
import useIsLiked from '@/hooks/useIsLiked'
import useLikeCount from '@/hooks/useLikeCount'

const useHandleLike = (messageId: string, userId: string) => {
  const { data: currentUser } = useCurrentUser()
  const isCurrentUserMessage = currentUser?.id === userId

  const { data: likeCountData, isLoading } = useLikeCount(messageId)
  const { data: isLiked, isLoading: isLikedLoading } = useIsLiked(
    messageId,
    currentUser?.id as string,
  )

  const [liked, setLiked] = useState(false)
  const [likeCount, setLikeCount] = useState(0)

  const router = useRouter()

  useEffect(() => {
    if (!isLoading) setLikeCount(likeCountData || 0)
    if (!isLikedLoading) setLiked(isLiked as boolean)
  }, [isLiked, isLikedLoading, isLoading, likeCountData, messageId])

  const handleLike = useCallback(async () => {
    // UI更新
    const newLiked = !liked
    const newLikeCount = liked ? likeCount - 1 : likeCount + 1
    setLiked(newLiked)
    setLikeCount(newLikeCount)

    try {
      if (!liked) {
        await fetch('/api/like', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            userId: currentUser?.id,
            messageId: messageId,
          }),
        })
      } else {
        await fetch('/api/like', {
          method: 'DELETE',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            userId: currentUser?.id,
            messageId: messageId,
          }),
        })
      }
    } catch (err) {
      // エラーの場合UIを元に戻す
      setLiked(!liked)
      // エラーが発生した場合、likeCountを元に戻す
      setLikeCount(liked ? likeCount + 1 : likeCount - 1)
    } finally {
      router.refresh()
    }
  }, [currentUser?.id, likeCount, liked, messageId, router])

  return { isCurrentUserMessage, liked, isLiked, likeCount, handleLike }
}

export default useHandleLike

ひとまず、ここで呼び出している関連するカスタムフックの説明は後回しにし、まずはこのuseHandleLikeについて説明します。

例のごとく、全体の流れを見ていきましょう

  • useStateでcurrentUserがいいね済みのメッセージかどうかを判定する値といいね数を保持する値を定義(このStateをMessageItem.tsxの判定や表示で使用します)
  • useEffect内でAPIからフェッチしてきた実際の値を上記のStateに反映
  • オプティミスティックUI更新(likedを逆値にする + likedの値に応じていいね数を増減させる)
  • サーバー(API)との通信をする(この際likedの値に応じていいねするAPIと通信するのか、いいねを外すAPIと通信するかを決める)
  • エラーの場合、元のUIに戻す(オプティミスティックUI更新した状態を元に戻すため、likedは逆値に設定し、likeCountは同様にlikedの値に応じて増減させる)
  • router.refresh()でデータを最新の状態とする(サーバーとのデータ整合性を保つ役割)

このuseHandleLikeの解説は上記の全体の流れでかなり解像度が高くなると思いますので、次に関連するカスタムフックの処理を見ていきましょう。

useIsLiked.ts
import useSWR from 'swr'

import fetcher from '@/libs/fetcher'

const useIsLiked = (messageId: string, userId: string) => {
  const { data, error, isLoading, mutate } = useSWR<boolean>(
    messageId === '3b40b0b9-eaa2-44ba-b361-92ca653fb667'
      ? null
      : `/api/like/${messageId}/${userId}`,
    fetcher,
  )

  return { data, error, isLoading, mutate }
}

export default useIsLiked

こちらのuseIsLikedはcurrentUserがいいね済みかどうかを判定するAPIとの通信を行うフックです。
ここでmessageIdによりAPIとやり取りするかどうかを決めていることに注目してください。

このようにするために、メッセージのオプティミスティックUI更新時の疑似データのIDを固定値としたのです。
なぜ、これが必要なのかというと、疑似データのIDは当然UI更新のためだけのものなので、実際のDBには存在しないからです。
つまり、存在しないIDをパラメータとしてリクエストを送信しないようにするわけです。
(存在しないIDをパラメータとしてリクエストすると404エラーが返却されます)

さらに、これは私の実装に起因する部分になるかもしれないのですが、実際のDBのIDはObjectIdという型なので、オプティミスティックUI更新処理後にサーバーからデータフェッチした際に、以下のエラーとなり、エラー画面に遷移してしまいます。
Error: Objects are not valid as a React child (found: object with keys {message}). If you meant to render a collection of children, use an array instead.

上記のような事象を避けるために、uuid()などを使用せずに、IDは固定値としました。

useLikeCount.ts
import useSWR from 'swr'

import fetcher from '@/libs/fetcher'

const useLikeCount = (messageId: string) => {
  const { data, error, isLoading, mutate } = useSWR<number>(
    messageId === '3b40b0b9-eaa2-44ba-b361-92ca653fb667'
      ? null
      : `/api/like/${messageId}`,
    fetcher,
  )

  return { data, error, isLoading, mutate }
}

export default useLikeCount

続いて関連するカスタムフックとしては、こちらになります。
こちらはいいね数を取得するAPIと通信するためのフックです。

こちらもuseIsLikedと同様にmessageIdによってAPIとやりとしするかどうかを決める実装をしています。
このような実装となっている理由も同じです。

いいねのオプティミスティックUI更新処理は以上となります。

終わりに

これまで解説したような実装をすることで、UXがよくストレスのないアプリケーションを作成することができます。

実際にVercelデプロイした本番環境でもリアルタイムに送信したメッセージが表示されたり、いいねされたり、いいね数が増減したりして、我ながら「サクサク感が出て良さげだな」と感じました。

参考文献

Optimistic Update (楽観的更新)でストレスのないUXを実現する

Discussion