AIチャット型サブスクリプション管理ダッシュボード

ハッカソンで制作した初期版は、構造より開発速度を優先したため、UI・状態管理・APIロジックが密接に結びつき、修正や拡張が難しい状態でした。この再開発を通して関心の分離の重要性を学び、保守しやすく拡張可能なAIダッシュボードへ再構築しました。

使用技術
役割
UIデザイナー / フロントエンド開発者
期間
1か月、2026年4月〜5月

概要

ハッカソンの試作から、拡張可能な設計へ

このプロジェクトは、デザイナーと参加したハッカソンで制作したものです。短期間での完成を優先した結果、初期実装にはコード構成と状態管理の課題が残りました。

Hackathon setup with two laptops displaying a subscription management dashboard, as developers collaborate side by side.Billow prototype landing page

その後、デザインとアーキテクチャの両面からプロジェクトを見直しました。UIを再現するだけでなく、複雑な状態と操作を管理できるよう構成を整理し、拡張可能なフロントエンドへ再構築しました。

課題

動くものの、拡張できない構成

ハッカソン版では、動作するAIチャットを短時間で完成させることを優先しました。その結果、UI表示、状態管理、送信処理、API通信が1つのコンポーネントに集中していました。

すべてを1つのコンポーネントで処理

Diagram showing all chat logic combined in a single Chat.jsx component

試作としては機能していましたが、各処理の責任範囲が曖昧で、関心を分離できていませんでした。API通信、ローカル状態、イベント処理、UI表示が密接に結びつき、コードの理解・保守・拡張が難しい構成でした。

初期コードを簡略化した例 ↓


// API communication
const fetchWithRetry = async (payload) => { ... };

// Client state
const [messages, setMessages] = useState(initialMessages);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);

// Business logic
const sendMessage = async (userMessage) => {
  if (!userMessage.trim() || isLoading) return;
  setMessages(...);
  setInput("");
  setIsLoading(true);

  try {
    const result = await fetchWithRetry({ ... });
    setMessages(...);
  } catch (error) {
    setMessages(...);
  } finally {
    setIsLoading(false);
  }
};

const handleSend = (e) => { ... };
const handleQuickAction = (actionText) => { ... };

// View
return (
  <section>
    {messages.map(...)}
    <button ...>...</button>
    <form ...>
      <input ... />
      <button ...>...</button>
    </form>
  </section>
);

アプローチ

アーキテクチャの方針

チャット機能を拡張しやすくするため、まず各層の責任範囲を明確にしました。ファイルの大きさや画面単位ではなく、View、Logic、Service、Stateという役割ごとにコードを整理しました。

Diagram showing the chat feature separated into View, Logic, Service, and State layers

各レイヤーの役割

View:UIを構成して表示する


export default function ChatRoomPage() {
  const params = useParams();
  const chatId = params.chatId as string;

  const room = useChatStore((state) => state.rooms[chatId]);

  const { isAiThinking, handleSubmit } = useChatRoomController({
    chatId,
    room,
  });

  if (!room) return <div>Room not found</div>;

  return (
    <div>
      <h1>{room.title}</h1>
      <ScrollableChat messages={room.messages} isAiThinking={isAiThinking} />
      <ChatRoomInput handleSubmit={handleSubmit} />
    </div>
  );
}

Logic:メッセージ送信と副作用を制御する


export function useChatRoomController({ chatId, room }: Props) {
  const addMessage = useChatStore((state) => state.addMessage);
  const [isAiThinking, setIsAiThinking] = useState(false);
  const hasRequestedInitialResponseRef = useRef(false);

  useEffect(() => {
    if (!room) return;
    if (room.messages.length > 1) return;
    if (hasRequestedInitialResponseRef.current) return;

    hasRequestedInitialResponseRef.current = true;

    const initialAiRes = async () => {
      setIsAiThinking(true);
      try {
        const aiText = await callAi(room.messages);
        if (aiText) addMessage(chatId, "assistant", aiText);
      } finally {
        setIsAiThinking(false);
      }
    };

    initialAiRes();
  }, [room, chatId, addMessage]);

  const handleSubmit = async (message: string) => {
    if (!room) return;

    addMessage(chatId, "user", message);

    const historyWithNewMessage = [
      ...room.messages,
      { role: "user", content: message },
    ];

    setIsAiThinking(true);
    try {
      const aiText = await callAi(historyWithNewMessage);
      if (aiText) addMessage(chatId, "assistant", aiText);
    } finally {
      setIsAiThinking(false);
    }
  };

  return { isAiThinking, handleSubmit };
}

Service:AIとの通信処理を切り離す


export const callAi = async (messages: LocalChatMessage[]) => {
  const chat = window?.puter?.ai?.chat;
  if (typeof chat !== "function") return "";

  try {
    const res = await chat({
      messages: withSystemPrompt(messages),
    });
    return extractAiText(res);
  } catch {
    const prompt = buildPromptWithSystem(messages);
    const res = await chat(prompt);
    return extractAiText(res);
  }
};

State:コンポーネント間で会話データを共有する


type ChatStore = {
  rooms: Record<string, ChatRoom>;
  createRoom: (initialMessage: string) => string;
  addMessage: (
    chatId: string,
    role: "user" | "assistant",
    content: string
  ) => void;
};

export const useChatStore = create<ChatStore>((set) => ({
  rooms: {},

  createRoom: (initialMessage) => {
    const id = crypto.randomUUID();
    set((state) => ({
      rooms: {
        ...state.rooms,
        [id]: {
          title: initialMessage,
          messages: [{ role: "user", content: initialMessage }],
        },
      },
    }));
    return id;
  },

  addMessage: (chatId, role, content) => {
    set((state) => ({
      rooms: {
        ...state.rooms,
        [chatId]: {
          ...state.rooms[chatId],
          messages: [
            ...state.rooms[chatId].messages,
            { role, content },
          ],
        },
      },
    }));
  },
}));

複数のチャットルームに対応するにつれ、会話データを1つのコンポーネントの外でも共有する必要が生まれました。ローカル状態だけでは管理が難しくなったため、共有データをZustandのストアへ移しました。

振り返り

開発プロセスを見直して学んだこと

ハッカソンでは、まず動かすことを優先し、構造を設計する重要性を十分に理解できていませんでした。このプロジェクトを見直す中で、設計への理解とAIの使い方が大きく変わりました。AIに「とりあえず動くもの」を作らせるのではなく、明確な設計意図を持ち、判断を支える道具として使うようになりました。AIは短時間でコードを生成できますが、読みやすく保守・拡張できるコードを書くには、土台となるアーキテクチャを自分で理解する必要があります。構造を整理しながら設計すること自体の面白さも実感しました。

デザインと実装の間にある差

このプロジェクトを通して、デザインと実装の間にある差についても考え直しました。Figmaでワイヤーフレームやスタイルガイドを整理しても、一貫したUIとして正確に再現するには、実装時の細かな判断が欠かせません。デザインと実装をつなぐ工程はまだ完全には自動化できず、人の目による調整と判断が重要だと改めて感じました。単なるリファクタリングにとどまらず、AIの活用方法と、デザイン意図を実装へ反映する方法を見直す機会になりました。

その他のプロジェクト

給与確認アプリのリデザイン
UI/UX ケーススタディ

給与確認アプリのリデザイン

実際のユーザーインタビューとフィードバックをもとに、給与確認体験を調査・再設計しました。

Tastebuds - Food Blog
Web開発

Tastebuds - Food Blog

フードブログサイトをVanilla JavaScriptのMVPからReact、Next.jsへ発展させた学習過程を記録しました