💬

React Hooksもりもり構成のチャット機能を考える[React 19 / Next.js 15]

2024/06/04に公開

はじめに 🚩

この記事では、Tanstack Query や SWR などのライブラリに頼らずに、React 標準の Hooks をふんだんに活用してチャット機能を実装する方法を紹介します。

RC(Release Candidate)段階ではありますが、React 19 で追加された useActionStateuseOptimistic を使うことで、よりインタラクティブで快適な UI/UX を実現する方法を探っていきます。
また筆者の過去の記事ではそれぞれの Hooks に焦点を当てた記事を書いているので、そちらもあわせて参照してください。

https://zenn.dev/tsuboi/articles/3aa0af72b6f7e5

https://zenn.dev/tsuboi/articles/0fc94356667284

実装例 📝

はじめに完成した状態のデモアプリを示します。

alt text

楽観的更新によりチャットメッセージが送信されるとデータベースにデータが保存されたかどうかに関わらず即座に表示され、メッセージの送信中かどうかによってアイコンが切り替わっています。

また、サインアウトすると、すべてのメッセージが「他のユーザーによって送信されたもの」として扱われています。UX 的には良くないですが、デモアプリということで許容しています。

前準備

React Server Components (RSC) の利用を前提としているため、フレームワークである Next.js を利用します。またデータベースには Neon DB を利用し、データベースのアクセスには Drizzle ORM を利用します。

Neon DB と Drizzle ORM を導入する方法については、以下の公式ドキュメントを参照してください。

https://orm.drizzle.team/learn/tutorials/drizzle-with-neon

ユーザーを識別するために、認証ライブラリとして Auth.js(v5) を使用します。導入方法については、筆者が過去に書いた Zenn 本のチャプター 1 から 4 を参照してください。(なお、本書では ORM として Prisma を使用しているため、アダプター部分などは適宜読み替えてください)

https://zenn.dev/tsuboi/books/3f7a3056014458

主要なファイルのコードを以下に示します。以降メッセージ機能全体を管理する Messages コンポーネントの詳細を深掘りするため、この記事を読み進める前にコードを確認しておくことをお勧めします。

app/layout.tsx
import { Toaster } from '@/components/ui/sonner';
import './globals.css';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { SessionProvider } from 'next-auth/react';
import { auth } from '@/auth';
import { Navbar } from '@/components/navbar';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth();
  return (
    <SessionProvider session={session}>
      <html lang='ja'>
        <body className={inter.className}>
          <Navbar />
          {children}
          <Toaster />
        </body>
      </html>
    </SessionProvider>
  );
}
app/page.tsx
import Messages from '@/components/chat/messages';
import { getMessages } from '@/lib/services/get-messages';

export default async function Home() {
  const messages = await getMessages();
  return (
    <main className='container'>
      <Messages messages={messages} />
    </main>
  );
}
components/navbar.tsx

query-string は URL の生成とクエリパラメータの追加、削除、変更を行うためのライブラリです。

'use client';

import qs from 'query-string';
import { useSession, signIn, signOut } from 'next-auth/react';
import { Button } from '@/components/ui/button';

export const Navbar = () => {
  const { data } = useSession();

  const onClick = () => {
    const callbackUrl = qs.stringifyUrl({
      url: process.env.NEXT_PUBLIC_APP_URL!,
      query: {
        loginState: data ? 'signedOut' : 'signedIn',
      },
    });

    if (data) {
      return signOut({ callbackUrl });
    }

    signIn('github', { callbackUrl });
  };

  return (
    <nav className='flex items-center border-b shadow-sm container py-4'>
      <div className='ml-auto'>
        <Button onClick={onClick}>{data ? 'Sign out' : 'Sign in'}</Button>
      </div>
    </nav>
  );
};
components/chat/messages.tsx ★
'use client';

import React, { useOptimistic, useReducer } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { MessageBubble } from './message-bubble';
import { InsertMessage, InsertUser } from '../../db/schema';
import MessageFooter from './message-footer';
import AutomaticScroller from '../automatic-scroller';
import { useSession } from 'next-auth/react';

export type OptimisticMessage = InsertMessage & {
  image?: InsertUser['image'];
  name?: InsertUser['name'];
  isSending?: boolean;
  hasFailed?: boolean;
};

type Props = {
  messages: (InsertMessage & {
    image: InsertUser['image'];
    name: InsertUser['name'];
  })[];
};

export default function Messages({ messages }: Props) {
  const { data } = useSession();
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (currMessages: OptimisticMessage[], newMessage: OptimisticMessage) => {
      return [
        ...currMessages,
        {
          ...newMessage,
          isSending: true,
        },
      ];
    }
  );

  const [failedMessages, addFailedMessage] = useReducer(
    (currMessages: OptimisticMessage[], failedMessage: OptimisticMessage) => {
      return [
        ...currMessages,
        {
          ...failedMessage,
          hasFailed: true,
        },
      ];
    },
    []
  );

  const allMessages = failedMessages
    .filter((message) => {
      return !optimisticMessages
        .map((m) => {
          return m.id;
        })
        .includes(message.id);
    })
    .concat(optimisticMessages);

  return (
    <div className='grid border mt-6 border-border'>
      <AutomaticScroller className='grid h-80 content-start gap-4 overflow-auto border-b border-border p-4'>
        {allMessages.length === 0 && (
          <span className='text-center text-muted-foreground'>No messages</span>
        )}
        {allMessages.map((message) => {
          return (
            <MessageBubble
              key={message.id}
              message={message}
              addOptimisticMessage={addOptimisticMessage}
              userId={data?.user?.id}
            />
          );
        })}
      </AutomaticScroller>
      <ErrorBoundary
        fallback={<p className='p-4 text-end'>⚠️Something went wrong</p>}
      >
        <MessageFooter
          addFailedMessage={addFailedMessage}
          addOptimisticMessage={addOptimisticMessage}
          userId={data?.user?.id}
        />
      </ErrorBoundary>
    </div>
  );
}

useOptimistic の利用箇所

messages.tsx では、useOptimistic を利用して、楽観的なメッセージの追加を管理しています。

export type OptimisticMessage = InsertMessage & {
  image?: InsertUser['image'];
  name?: InsertUser['name'];
  isSending?: boolean;
  hasFailed?: boolean;
};

const [optimisticMessages, addOptimisticMessage] = useOptimistic(
  messages,
  (currMessages: OptimisticMessage[], newMessage: OptimisticMessage) => {
    return [
      ...currMessages,
      {
        ...newMessage,
        isSending: true,
      },
    ];
  }
);

返り値

  • optimisticMessages: 楽観的更新を反映し、画面に表示するそれぞれのメッセージそのものの値です。
  • addOptimisticMessage: 楽観的更新を行い、新しいメッセージを追加するための関数です。

引数

useOptimistic は、2 つの引数を取ります。

  1. 第一引数 (state): 初期値として渡すメッセージの配列です。
  2. 第二引数 (updateFn): 楽観的更新が行われるための関数です。この関数は現在のメッセージリスト (currMessages) と新しいメッセージ (newMessage) を受け取り、更新されたメッセージリストを返します。

このように、useOptimistic を利用することで、新しいメッセージが送信されると、addOptimisticMessage が呼び出され(isSending: true のフラグが立てられ)optimisticMessages に新しいメッセージが追加され、即座に表示されます。

失敗したメッセージと楽観的に追加されたメッセージを統合

失敗したメッセージを管理するために、useReducer を使用しています。
現在の失敗したメッセージリスト (currMessages) と新しい失敗したメッセージ (failedMessage) を受け取り、新しいリストを返す reducer 関数を第一引数として取ります。useReducer の返り値として、現在の失敗したメッセージリスト (failedMessages) と新しい失敗したメッセージを追加するための dispatch 関数 (addFailedMessage) が得られます。

const [failedMessages, addFailedMessage] = useReducer(
  (currMessages: OptimisticMessage[], failedMessage: OptimisticMessage) => {
    return [
      ...currMessages,
      {
        ...failedMessage,
        hasFailed: true,
      },
    ];
  },
  []
);

あとは、failedMessages と optimisticMessages を統合して、画面に表示するメッセージリストを作成します。

const allMessages = failedMessages
  .filter((message) => {
    return !optimisticMessages.map((m) => m.id).includes(message.id);
  })
  .concat(optimisticMessages);

MessageBubble コンポーネント

allMessages を展開して得られた個々のチャットメッセージを表示する MessageBubble コンポーネントは以下のように実装しています。

type Props = {
  message: OptimisticMessage;
  addOptimisticMessage: (message: OptimisticMessage) => void;
  userId?: string;
};

export function MessageBubble({
  message,
  addOptimisticMessage,
  userId,
}: Props) {
  const isMe = message.userId === userId;

  const handleAction = async (formData: FormData) => {
    // 省略
  };

  return (
    <div className={cn('flex pb-2', isMe ? 'justify-end' : 'justify-start')}>
      <div className='flex items-center gap-2'>
        {!isMe && message.image && (
          <Avatar>
            <AvatarImage
              src={message.image ?? undefined}
              alt={message.name ?? ''}
            />
            <AvatarFallback className='uppercase'>
              {message.name?.charAt(0) ?? ''}
            </AvatarFallback>
          </Avatar>
        )}
        <div
          className={cn(
            'text-sm p-2 rounded-lg flex items-end gap-1',
            isMe ? 'bg-blue-600 text-secondary' : 'bg-muted text-primary',
            message.hasFailed && 'bg-red-600 text-secondary p-0'
          )}
        >
          {!message.hasFailed ? (
            <span>{message.content}</span>
          ) : (
            <form className='flex-row' action={handleAction}>
              <Button
                size='icon'
                variant='outline'
                className='hover:underline bg-destructive/60 text-secondary'
                type='submit'
              >
                <RefreshCcw className='size-4' />
              </Button>
              <input type='hidden' name='content' value={message.content} />
              <input
                type='hidden'
                name='createdAt'
                value={message.createdAt?.toISOString()}
              />
            </form>
          )}
          {isMe &&
            !message.hasFailed &&
            (message.isSending ? (
              <ClockIcon className='size-4' />
            ) : (
              <CheckCheckIcon className='size-4' />
            ))}
        </div>
      </div>
    </div>
  );
}

ここで注目すべき点は、メッセージが送信直後isSending フラグが立てられ、メッセージが送信された後に、hasFailed フラグが立てられることです。これにより、メッセージが送信されると即座に表示されアイコンも楽観的更新中かどうかを示すことができます。

また、メッセージが送信に失敗した場合、再送信ボタンが表示され、再送信が可能です。こちらの詳細は おまけのセクション に記載しています。

useActionState の利用箇所

message-footer.tsx では、useActionState を利用して、メッセージ送信アクションの状態を管理しています。

message-footer.tsx の全コード
import React, {
  startTransition,
  useActionState,
  useRef,
  useState,
} from 'react';
import { Input } from '../ui/input';
import { Button } from '../ui/button';
import { createId } from '@paralleldrive/cuid2';
import { OptimisticMessage } from './messages';
import { submitMessage } from '@/lib/actions/submit-message';
import { toast } from 'sonner';

type Props = {
  addOptimisticMessage: (_message: OptimisticMessage) => void;
  addFailedMessage: (_message: OptimisticMessage) => void;
  userId?: string;
};

export default function MessageFooter({
  addOptimisticMessage,
  addFailedMessage,
  userId,
}: Props) {
  const [state, submitMessageAction] = useActionState(submitMessage, {
    success: false,
  });
  const [defaultValue, setDefaultValue] = useState(state.content);

  const formRef = useRef<HTMLFormElement>(null);

  const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!userId) return;

    setDefaultValue('');
    const message = {
      content: e.currentTarget.content.value,
      createdAt: new Date(),
      userId,
      id: createId(),
    } satisfies OptimisticMessage;

    startTransition(async () => {
      addOptimisticMessage(message);
      const formData = new FormData(e.currentTarget);
      formRef.current?.reset();

      const result = await submitMessage(
        {
          success: false,
        },
        formData
      );

      if (result.error) {
        toast.error(result.error);
        addFailedMessage(message);
      }
    });
  };

  return (
    <>
      <form
        ref={formRef}
        action={submitMessageAction}
        className='flex items-center space-x-2 p-2 border-t'
        onSubmit={onSubmit}
      >
        <Input
          className='flex-1'
          autoComplete='off'
          required
          defaultValue={defaultValue}
          minLength={1}
          name='content'
          placeholder='Type a message...'
        />
        <Button type='submit' size='sm' variant='outline'>
          Send
        </Button>
      </form>
      {state.error && (
        <noscript className='px-6 pb-6 text-end text-destructive/60'>
          {state.error}
        </noscript>
      )}
    </>
  );
}
const [state, submitMessageAction] = useActionState(submitMessage, {
  success: false,
});

返り値

  • state: 現在のアクションの状態を保持します。初期状態は success: false です。
  • submitMessageAction: form の action プロパティや、フォーム内の任意の button の formAction プロパティとして渡すことができる新しいアクションです。

引数

useActionState は、3 つの引数を取ります。

  1. 第一引数 (action): フォームが送信されたりボタンが押されたりしたときに呼び出される関数。ここでは Server Actions の submitMessage が渡されています。※ 以下に実装コードを記載します。
  2. 第二引数 (initialState): state の初期値として使いたい値。ここでは { success: false } が渡されています。
  3. 第三引数 (permalink?): 省略可能な引数で、このフォームが書き換えの対象とするユニークなページ URL を含んだ文字列です。ここでは省略していますし、使用頻度も低いと思われます。
lib/actions/submit-message.ts ※
'use server';

import { revalidatePath } from 'next/cache';
import { messageSchema } from '@/validations/messageSchema';
import { getMessages } from '../services/get-messages';
import { db } from '@/db/drizzle';
import { messages as messageTable } from '@/db/schema';
import { createId } from '@paralleldrive/cuid2';
import { auth } from '@/auth';

type State = {
  success: boolean;
  error?: string;
  content?: string;
};

export async function submitMessage(
  _prevState: State,
  formData: FormData
): Promise<State> {
  const session = await auth();
  const result = messageSchema.safeParse({
    content: formData.get('content'),
    createdById: session?.user.id,
  });

  if (!result.success) {
    return {
      error: 'Invalid message!',
      success: false,
    };
  }

  const messages = await getMessages(result.data.createdById);

  // 強制的にエラーを出すための設定:送信できる上限を10に設定
  if (messages.length > 10) {
    return {
      error: 'You have reached the maximum number of messages!',
      success: false,
    };
  }

  await db.insert(messageTable).values([
    {
      content: result.data.content,
      userId: result.data.createdById,
      createdAt: new Date(),
      id: createId(),
    },
  ]);

  revalidatePath('/');

  return {
    success: true,
  };
}

このように useActionState を利用することで、フォームが送信されると、submitMessageAction が呼び出され、submitMessage 関数が実行されます。その結果に基づいて state が更新され、エラーメッセージが表示されたり、成功メッセージが表示されたりします。

Progressive Enhancement の維持

また useActionState を利用することでうれしい点として、Progressive Enhancement を維持できることが挙げられます。JavaScript が無効な場合でも、フォームの送信が可能ということです。

以下の gif は、メッセージの送信中に JavaScript を無効にした場合の挙動を示しています。JavaScript が無効になったことで AutomaticScroller が機能しなくなり、楽観的なメッセージの追加が行われなくなっていますが、フォームの送信はできており最低限の機能は満たされています。

JS の実行が無効化された環境の検証方法

JS の実行が無効化された環境での検証方法として、Chrome の DevTools で JavaScript を無効化する方法を以下に示します。

  1. DevTools を開く
  2. Command + Shift + P を押して、コマンドパレットを開く
  3. Disable JavaScript と入力して、Disable JavaScript を選択

alt text

startTransition API の利用箇所

MessageFooter コンポーネントでもう1点取り上げておきたいのが、onSubmit 内部のstartTransition 関数です。(pending 状態も取りたいということであればuseTransitionを使用します)
useTransition も React 標準 Hooks であり、React 18 で追加され、React 19 ではstartTransition に渡すコールバック関数を非同期関数にすることができるようになりました。

const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  if (!userId) return;

  setDefaultValue('');
  const message = {
    content: e.currentTarget.content.value,
    createdAt: new Date(),
    userId,
    id: createId(),
  } satisfies OptimisticMessage;

  startTransition(async () => {
    addOptimisticMessage(message);
    const formData = new FormData(e.currentTarget);
    formRef.current?.reset();

    const result = await submitMessage({ success: false }, formData);

    if (result.error) {
      toast.error(result.error);
      addFailedMessage(message);
    }
  });
};

このように、メッセージ送信時に startTransition を使用して、楽観的なメッセージ追加やフォームリセットなどの更新をトランジションとして扱います。これにより、優先度の高い他の更新が同時に発生した場合でも、UI が応答しなくなるのを防ぎます。

まとめ 📌

この記事では、非同期状態管理ライブラリに頼らず主に React 19 で追加された新しい Hooks を活用して、チャット機能をどのように実装するかを紹介しました。
また今どきのチャット機能だと、リアルタイム通信が求められることが多いと思うので、そちらは別途記事にしたいと考えています。(Web フレームワークのHonoに WebSocket 用のヘルパー関数があるので、使ってみたい)

おまけ:ロールバック処理の対応 🎁

楽観的更新は操作が成功することを前提に UI を先に更新しますが、実際には失敗することもあるため、適切なエラーハンドリングが必要です。更新が頻繁に失敗する場合、楽観的更新を使用するとユーザーが混乱したり、不信感を抱く可能性があるため、慎重に実装することが重要です。
既に上で取り上げたコードで対応は済んでいますが、そのロールバック処理に焦点を当てて説明します。

実装箇所は、MessageBubble コンポーネントのhandleAction関数です。

message-bubble.tsx の全コード
import React from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { CheckCheckIcon, ClockIcon, RefreshCcw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { submitMessage } from '@/lib/actions/submit-message';
import { toast } from 'sonner';
import { Button } from '../ui/button';
import { OptimisticMessage } from './messages';
import { useSession } from 'next-auth/react';

type Props = {
  message: OptimisticMessage;
  addOptimisticMessage: (message: OptimisticMessage) => void;
  userId?: string;
};

export function MessageBubble({
  message,
  addOptimisticMessage,
  userId,
}: Props) {
  const isMe = message.userId === userId;

  const handleAction = async (formData: FormData) => {
    if (!userId) return;

    addOptimisticMessage({
      content: message.content,
      createdAt: message.createdAt,
      userId,
      id: message.id,
      image: message.image,
      name: message.name,
    });

    const result = await submitMessage({ success: false }, formData);

    if (result.error) {
      toast.error(result.error);
    }
  };

  return (
    <div className={cn('flex pb-2', isMe ? 'justify-end' : 'justify-start')}>
      <div className='flex items-center gap-2'>
        {!isMe && message.image && (
          <Avatar>
            <AvatarImage
              src={message.image ?? undefined}
              alt={message.name ?? ''}
            />
            <AvatarFallback className='uppercase'>
              {message.name?.charAt(0) ?? ''}
            </AvatarFallback>
          </Avatar>
        )}
        <div
          className={cn(
            'text-sm p-2 rounded-lg flex items-end gap-1',
            isMe ? 'bg-blue-600 text-secondary' : 'bg-muted text-primary',
            message.hasFailed && 'bg-red-600 text-secondary p-0'
          )}
        >
          {!message.hasFailed ? (
            <span>{message.content}</span>
          ) : (
            <form className='flex-row' action={handleAction}>
              <Button
                size='icon'
                variant='outline'
                className='hover:underline bg-destructive/60 text-secondary'
                type='submit'
              >
                <RefreshCcw className='size-4' />
              </Button>
              <input type='hidden' name='content' value={message.content} />
              <input
                type='hidden'
                name='createdAt'
                value={message.createdAt?.toISOString()}
              />
            </form>
          )}
          {isMe &&
            !message.hasFailed &&
            (message.isSending ? (
              <ClockIcon className='size-4' />
            ) : (
              <CheckCheckIcon className='size-4' />
            ))}
        </div>
      </div>
    </div>
  );
}
const handleAction = async (formData: FormData) => {
  if (!userId) return;

  addOptimisticMessage({
    content: message.content,
    createdAt: message.createdAt,
    userId,
    id: message.id,
    image: message.image,
    name: message.name,
  });

  const result = await submitMessage({ success: false }, formData);

  if (result.error) {
    toast.error(result.error);
  }
};

この関数は、メッセージの再送信が行われた際に呼び出されます。この関数内で、楽観的なメッセージを追加し、メッセージの送信を試みます。もし送信に失敗した場合、エラーメッセージを表示します。
この関数を呼び出す箇所は、MessageBubble コンポーネントの JSX 内にあります。各々の楽観的更新を含むメッセージ(OptimisticMessage)の hasFailed フラグが立てられ、再送信ボタンが表示されます。

{
  !message.hasFailed ? (
    ...
  ) : (
    <form className='flex-row' action={handleAction}>
      <Button
        size='icon'
        variant='outline'
        className='hover:underline bg-destructive/60 text-secondary'
        type='submit'
      >
        <RefreshCcw className='size-4' />
      </Button>
      <input type='hidden' name='content' value={message.content} />
      <input
        type='hidden'
        name='createdAt'
        value={message.createdAt?.toISOString()}
      />
    </form>
  );
}

動作確認のため、Server Actions の submitMessage 関数において、送信したメッセージの数が 10 件を超えるとエラーを返すように設定しています。

'use server';

export async function submitMessage(
  _prevState: State,
  formData: FormData
): Promise<State> {
  // 他の処理は省略

  const messages = await getMessages(result.data.createdById);

  // 送信できる上限を10に設定
  if (messages.length > 10) {
    return {
      error: 'You have reached the maximum number of messages!',
      success: false,
    };
  }

  // 他の処理は省略
}

メッセージの送信が 10 件を超えるとエラーメッセージが表示され、再送信ボタンが表示され、このボタンを押すと再送信を試みます。

alt text

chot Inc. tech blog

Discussion