AI チャットのユーザー体験を高める~ React で実装する『さばまるチャット』開発技術録~

2025-11-07

目次

はじめに:なぜ『さばまるチャット』を作ったか

Village AI は、データや AI 技術の最適化、地域ソリューションの提案や開発を手がけるスタートアップ企業です。
今回、入門者や社内メンバーが生成 AI と解りやすく交流できる環境づくりの一環として、ChatbotUI、React (Next.js) をベースとしたチャット UI『さばまるチャット』を開発しました。

『さばまるチャット』とは?: プロジェクトの全体像

社内の開発チームにより設計開発された、ChatGPT API を利用した生成 AI チャットアプリです。
本プロジェクトは、以前自社で開発した同じく生成AIチャットの「かごんまAI」をベースに開発されています。名前の由来は、社員が飼っている猫の名前『さばまる』から。React のコンポーネント志向を活かしたポップで親しみやすい UI に加え、next-themes を活用したテーマカラーの切替や、useState によるチャット名変更など、個人の好みに合わせた設定も可能となっています。

開発のハイライト: React/Next.js による 6つの実践的アプローチ

本プロジェクトの開発は、React (Next.js) と TypeScript を基盤としています。バックエンドの ChatGPT API との連携を主軸に据えつつ、フロントエンド開発において「いかにしてユーザー体験 (UX※1) を高めるか」という課題に直面しました。
※1:ユーザー体験(User Experience、以下UX)

単にチャットができるだけでなく、ダークモード対応、プロバイダー(AI モデル)の視覚的な区別、コンポーネントの再利用性などを実現する必要がありました。
これらを解決するため、我々はデザイン(見た目)の要求を「フロントエンドの技術的な課題」として捉え直しました。具体的には、Tailwind CSS と next-themes を組み合わせた動的なスタイリング、SVG アセットのコンポーネント化 (SVGR)、TypeScript の型システムを活用した堅牢なコンポーネント設計など、React のエコシステムを最大限に活用する工夫を随所に施しています。

以下に、UI/UX の向上と開発効率を両立させるために導入した、具体的な 6 つの技術的工夫を 3 つのカテゴリに分類して紹介します。

動的な UI とスタイリング

ユーザーの環境や選択に応じて、UI が動的に変化するための実装です。

➀ Tailwind CSS と next-themes による、動的なアイコンスタイルの適用

本システムでは、ユーザーの視覚的な負担を軽減するため、ライトモードとダークモードのテーマ切り替え機能を導入しています。AI モデルのロゴを表示する際、単純に画像や SVG を配置するだけでは、ダークモード時に暗い色のロゴが背景に溶け込んでしまい、視認性が著しく低下するという課題がありました。この問題を解決するため、next-themes パッケージが提供する useTheme フックで現在のテーマ状態を取得し、その状態に応じて Tailwind CSS のクラスを動的に切り替える工夫を施しています。
具体的には、cn ユーティリティ(クラス名を結合・マージする関数)と三項演算子を組み合わせ、テーマが “dark” の場合はアイコンに bg-white(白い背景)を、それ以外(ライトモード)の場合は border-[1px] border-black(黒い枠線)を付与することで、あらゆるテーマ環境下での視認性を確保しています。

コード抜粋

import { cn } from "@/lib/utils"
import { useTheme } from "next-themes"
import { OpenAISVG } from "../icons/openai-svg" 

export const ModelIconExample = (props) => {
  const { theme } = useTheme()

  return (
    <OpenAISVG
      className={cn(
        "rounded-sm p-1", // 共通スタイル
        props.className,
        
        // テーマに応じて動的にクラスを適用
        theme === "dark" ? "bg-white" : "border-[1px] border-black"
      )}
      width={props.width}
      height={props.height}
    />
  )
}

② switch 文による動的なコンポーネント切り替えと、アセット種別(SVG/Image)の使い分け

本システムでは、OpenAI, Google, Mistral など、複数の AI モデル提供元(プロバイダー)に対応しています。これらのロゴを統一的に扱うため、provider という props(文字列)を受け取り、それに応じて適切なアイコンを表示する ModelIcon コンポーネントを作成しました。実装には switch 文を採用し、provider の値に基づいて表示するコンポーネントを動的に切り替えています。これにより、if…else を多用する場合に比べてコードの可読性を高め、将来的なプロバイダーの追加・変更にも柔軟に対応できる拡張性を確保しました。また、アイコンのアセット(素材)種別に応じて、最適な表示方法を選択しています。ロゴがベクター形式(SVG)で利用可能な場合は SVG を React コンポーネントとして直接描画し、PNG などの画像形式でしか提供されていないロゴについては Next.js の Image コンポーネントを使用し、自動画像最適化の恩恵を受けられるように工夫しています。

import mistral from "@/public/providers/mistral.png"
import Image from "next/image"
import { OpenAISVG } from "../icons/openai-svg"

export const ModelIcon: FC<ModelIconProps> = ({
  provider,
  height,
  width,
  ...props
}) => {
  switch (provider as ModelProvider) {
    // SVGコンポーネントを直接利用
    case "openai":
      return (
        <OpenAISVG
          className={/* ... スタイル ... */}
          width={width}
          height={height}
        />
      )
    // Next.js の Image コンポーネントで画像を利用
    case "mistral":
      return (
        <Image
          className={/* ... スタイル ... */}
          src={mistral.src}
          alt="Mistral"
          width={width}
          height={height}
        />
      )
    //
  }
}

再利用性と拡張性の高いコンポーネント設計

コンポーネントを様々な場所で使い回せるようにし、保守性を高めるための工夫です。

③ スプレッド構文 (…props) と cn ユーティリティによる、柔軟なスタイル拡張

この ModelIcon コンポーネントは、サイドバーやチャット履歴など、アプリケーションの様々な場所で再利用されることを想定しています。設置場所によっては、呼び出し元のコンポーネントからマージン(例: m-2)や追加のスタイル(例: hover:opacity-80)を指定したい場合があります。この要求に柔軟に応えるため、React のスプレッド構文 (…props) を活用しています。ModelIcon が受け取った props のうち、明示的に定義したもの以外(className など)を …props として一括で受け取ります。さらに、cn ユーティリティ関数を使い、コンポーネント内部で定義された基本スタイルと、props.className として外部から渡された追加のスタイルを安全に結合(マージ)しています。これにより、コンポーネントの基本スタイルを内部にカプセル化しつつ、呼び出し側で自由にスタイルを拡張できる、再利用性の高いコンポーネント設計を実現しています。

コード抜粋

import { cn } from "@/lib/utils"
import { FC, HTMLAttributes } from "react"
import { OpenAISVG } from "../icons/openai-svg"

// 1. HTMLの標準属性 (classNameなど) も受け取れるように型定義
interface ModelIconProps extends HTMLAttributes<HTMLDivElement> {
  provider: string
  height: number
  width: number
}

// 2. propsから provider, height, width 以外を ...props として受け取る
export const ModelIcon: FC<ModelIconProps> = ({
  provider,
  height,
  width,
  ...props // ここに className などが含まれる
}) => {
  // ( ... switch文は省略 ... )
  switch (provider) {
    case "openai":
      return (
        <OpenAISVG
          className={cn(
            "rounded-sm p-1", // 内部の基本スタイル
            props.className // 3. 外部から渡されたスタイルをマージ
          )}
          width={width}
          height={height}
        />
      )
  }
}

④ スプレッド構文 (…props) による柔軟な属性の継承 (SVG)
SVG アイコンコンポーネント(SvgWorkspaceASvg など)自体も、受け取った props をそのまま内部の <svg> タグに {…props} として展開(スプレッド)しています。この設計により、コンポーネントの呼び出し元から渡された任意の属性(例: className=”m-2″)が、自動的に最終的な <svg> 要素に適用されます。これにより、アイコンコンポーネント自体が Tailwind CSS などの外部ユーティリティと完全に互換性を持ち、③の cn ユーティリティと組み合わせることで、非常に柔軟なスタイリングが可能となります。

import * as React from "react"
import type { SVGProps } from "react"

// 1. props をオブジェクトとして受け取る
const SvgWorkspaceASvg = (props: SVGProps<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width={28}
    height={28}
    
    // 2. 外部から渡された props (className や id など) をここで展開
    {...props} 
  >
    {/* ... SVG ... */}
  </svg>
)
export default SvgWorkspaceASvg

TypeScript とアセットパイプラインによる開発体験 (DX※2) の向上

コードの品質と開発効率を高めるための、ツールや型システムの活用です。
※2:開発体験(Developer Experience、以下DX)

⑤ TypeScript による型安全な SVG Props の活用

アイコンなどの SVG アセットを React コンポーネントとして扱う際、props の型として React.SVGProps<SVGSVGElement> を指定しました。これにより、width や height だけでなく、className, onClick といった HTML の SVG 要素が受け取れるすべての属性を、TypeScript の型チェックの恩恵を受けながら安全に渡すことができます。呼び出し側で属性名を間違えたり、存在しない属性を指定したりするとエディタがエラーを検知してくれるため、型安全で堅牢なコンポーネント開発が可能になります。

コード抜粋

import * as React from "react"
// 1. SVG要素の型定義を React からインポート
import type { SVGProps } from "react"

// 2. props の型として SVGProps<SVGSVGElement> を指定
const SvgWorkspaceASvg = (props: SVGProps<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    {...props}
  >
    {/* ... SVG ... */}
  </svg>
)
export default SvgWorkspaceASvg

⑥ SVGR 活用による SVG アセットの React コンポーネント化
プロジェクト内で使用する SVG アイコンは、.svg ファイルのまま <img> タグで読み込むのではなく、ビルドプロセスに SVGR (SVG to React) ツールを導入しました。これにより、SVG ファイルをプロジェクトに追加するだけで、⑤で見たような型定義や …props を備えた最適化済みの React コンポーネントに自動変換されます。開発者は SVG を「インポート可能なコンポーネント」として直接 JSX 内に記述でき、開発体験 (DX) が大幅に向上しました。また、バンドルサイズ最適化の恩恵も受けています。

// (SVGR によって自動生成された .tsx ファイルの典型的な例)

import * as React from "react"
import type { SVGProps } from "react"

// デザインツール由来の属性が JSX に変換される
const SvgWorkspaceASvg = (props: SVGProps<SVGSVGElement>) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    id="workspace_A-svg_svg___\u30EC\u30A4\u30E4\u30FC_1"
    width={28}
    height={28}
    data-name="\u30EC\u30A4\u30E4\u30FC 1"
    {...props}
  >
    {/* ... style タグや path も最適化された形で保持される ... */}
  </svg>
)
export default SvgWorkspaceASvg

ユースケース: 実際のチャットシナリオ

『さばまるチャット』の実際の使用感を、簡単なテストシナリオに沿ってご紹介します。

ステップ 1: UI の第一印象とテーマ変更
アプリにアクセスすると、猫の「さばまる」をモチーフにしたクリーンなチャット画面が表示されます。まず、設定(歯車アイコン)からテーマ切り替えを試します。デフォルトのライトモードからダークモードに切り替えると、UI 全体が即座に反転します。この時、【工夫➀】で紹介したアイコン群が、ダークモードの背景に溶け込まず、白い背景でくっきりと表示されていることが確認できます。

ステップ 2: 基本的なチャットと応答速度
次に、チャット入力欄に「React の useEffect について教えて」と入力し送信します。ローディングインジケーターの後、ChatGPT API からの応答が数秒でストリーミング表示されます。レスポンスは高速で、実用的な開発サポートツールとして機能します。

ステップ 2: 基本的なチャットと応答速度
次に、チャット入力欄に「React の useEffect について教えて」と入力し送信します。ローディングインジケーターの後、ChatGPT API からの応答が数秒でストリーミング表示されます。レスポンスは高速で、実用的な開発サポートツールとして機能します。

おわりに: 開発から得られた知見

『さばまるチャット』の開発プロジェクトは、強力な LLM(ChatGPT API)をバックエンドに持ちつつ、その価値をユーザーに最大限に届けるためには、React のコンポーネント設計からアセットパイプライン、型定義に至るまで、フロントエンド開発における総合的な工夫がいかに重要であるかを再認識する機会となりました。

React のコンポーネントベースのアーキテクチャは、今回のようなインタラクティブな UI を構築する上で非常に強力です。特に、デザインチームからの「ダークモードに対応したい」「このロゴを使いたい」といった要求を、単なる「見た目の調整」としてではなく、next-themes による状態管理、SVGR によるアセットパイプラインの構築、TypeScript による型安全な Props 設計といった「技術的な課題」として捉え、解決策を実装しました。

API との通信、状態管理、そしてコンポーネントの再利用性。これらフロントエンド開発の基本的な要素を堅実に積み上げることが、結果として「ポップで親しみやすい」というデザインコンセプトの実現に直結しました。本プロジェクトの知見は、今後の社内ツール開発やクライアントへのソリューション提案にも大いに活用できるものと確信しています。