DEV Community

Cover image for I Built the Same AI Chatbot in Angular AND React — Here's What I Learned
Placide
Placide

Posted on

I Built the Same AI Chatbot in Angular AND React — Here's What I Learned

Everyone talks about building AI-powered apps. But most tutorials pick one framework and stick with it.

I wanted to go deeper. I built the exact same AI chatbot — same features, same Claude API, same UX — twice. Once in Angular. Once in React. And the differences surprised me.

This isn't a "which framework is better" post. It's a honest, technical comparison of what it actually feels like to integrate AI into both ecosystems in 2026.

Let's dive in.

What We're Building

A streaming AI chatbot with:

  • A message thread that grows as the conversation evolves
  • Real-time streaming responses (token by token, like ChatGPT)
  • A loading state while the AI is thinking
  • Error handling for failed requests
  • A clean, reusable architecture

Same spec. Two frameworks. Let's see how they handle it.

The Shared Foundation — The Anthropic API

Both implementations use the same API call under the hood.

Part 1 — The Angular Implementation

The Chat Service

In Angular, the natural home for API logic is a service. With Signals, state management becomes clean and reactive:

// chat/chat.service.ts
import { Injectable, signal, computed } from '@angular/core';

export interface Message {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  timestamp: Date;
}

@Injectable({ providedIn: 'root' })
export class ChatService {
  private _messages = signal<Message[]>([]);
  private _isStreaming = signal(false);
  private _error = signal<string | null>(null);

  messages = this._messages.asReadonly();
  isStreaming = this._isStreaming.asReadonly();
  error = this._error.asReadonly();
  hasMessages = computed(() => this._messages().length > 0);

  async sendMessage(userInput: string): Promise<void> {
    if (!userInput.trim() || this._isStreaming()) return;

    // Add user message
    const userMessage: Message = {
      id: crypto.randomUUID(),
      role: 'user',
      content: userInput.trim(),
      timestamp: new Date()
    };
    this._messages.update(msgs => [...msgs, userMessage]);
    this._isStreaming.set(true);
    this._error.set(null);

    // Add empty assistant message to stream into
    const assistantMessage: Message = {
      id: crypto.randomUUID(),
      role: 'assistant',
      content: '',
      timestamp: new Date()
    };
    this._messages.update(msgs => [...msgs, assistantMessage]);

    try {
      await this.streamResponse(assistantMessage.id);
    } catch (err) {
      this._error.set('Something went wrong. Please try again.');
      // Remove the empty assistant message on error
      this._messages.update(msgs =>
        msgs.filter(m => m.id !== assistantMessage.id)
      );
    } finally {
      this._isStreaming.set(false);
    }
  }

  private async streamResponse(assistantMessageId: string): Promise<void> {
    const history = this._messages()
      .filter(m => m.content.length > 0)
      .map(m => ({ role: m.role, content: m.content }));

    const response = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'anthropic-version': '2023-06-01'
      },
      body: JSON.stringify({
        model: 'claude-sonnet-4-6',
        max_tokens: 1024,
        stream: true,
        messages: history
      })
    });

    if (!response.ok) throw new Error(`API error: ${response.status}`);

    const reader = response.body!.getReader();
    const decoder = new TextDecoder();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value);
      const lines = chunk.split('\n').filter(l => l.startsWith('data: '));

      for (const line of lines) {
        const data = line.replace('data: ', '');
        if (data === '[DONE]') return;

        try {
          const parsed = JSON.parse(data);
          const token = parsed.delta?.text ?? '';
          if (token) {
            // Update the specific assistant message with new token
            this._messages.update(msgs =>
              msgs.map(m =>
                m.id === assistantMessageId
                  ? { ...m, content: m.content + token }
                  : m
              )
            );
          }
        } catch { continue; }
      }
    }
  }

  clearChat(): void {
    this._messages.set([]);
    this._error.set(null);
  }
}
Enter fullscreen mode Exit fullscreen mode

The Angular Chat Component

// chat/chat.component.ts
import { Component, inject, signal, ElementRef, ViewChild, AfterViewChecked } from '@angular/core';
import { ChatService } from './chat.service';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-chat',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="chat-container">
      <div class="chat-header">
        <h2>AI Assistant</h2>
        @if (chat.hasMessages()) {
          <button (click)="chat.clearChat()" class="clear-btn">
            Clear chat
          </button>
        }
      </div>

      <div class="messages" #messagesContainer>
        @if (!chat.hasMessages()) {
          <div class="empty-state">
            <p>👋 Ask me anything!</p>
          </div>
        }

        @for (message of chat.messages(); track message.id) {
          <div class="message" [class]="'message--' + message.role">
            <div class="message__bubble">
              {{ message.content }}
              @if (message.role === 'assistant' &&
                   chat.isStreaming() &&
                   $last) {
                <span class="cursor">▋</span>
              }
            </div>
          </div>
        }

        @if (chat.error()) {
          <div class="error-banner">
            ⚠️ {{ chat.error() }}
          </div>
        }
      </div>

      <div class="chat-input">
        <textarea
          [(ngModel)]="inputValue"
          (keydown.enter)="onEnter($event)"
          [disabled]="chat.isStreaming()"
          placeholder="Type a message..."
          rows="1">
        </textarea>
        <button
          (click)="sendMessage()"
          [disabled]="chat.isStreaming() || !inputValue().trim()"
          class="send-btn">
          {{ chat.isStreaming() ? '...' : 'Send' }}
        </button>
      </div>
    </div>
  `
})
export class ChatComponent implements AfterViewChecked {
  chat = inject(ChatService);
  inputValue = signal('');

  @ViewChild('messagesContainer') private messagesContainer!: ElementRef;

  async sendMessage(): Promise<void> {
    const value = this.inputValue();
    if (!value.trim()) return;
    this.inputValue.set('');
    await this.chat.sendMessage(value);
  }

  onEnter(event: KeyboardEvent): void {
    if (!event.shiftKey) {
      event.preventDefault();
      this.sendMessage();
    }
  }

  ngAfterViewChecked(): void {
    this.scrollToBottom();
  }

  private scrollToBottom(): void {
    const el = this.messagesContainer?.nativeElement;
    if (el) el.scrollTop = el.scrollHeight;
  }
}
Enter fullscreen mode Exit fullscreen mode

Part 2 — The React Implementation

The Custom Hook

In React, the natural equivalent of an Angular service is a custom hook. Here's the same logic expressed the React way:

// hooks/useChat.ts
import { useState, useCallback, useRef } from 'react';

export interface Message {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  timestamp: Date;
}

export function useChat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isStreaming, setIsStreaming] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const abortControllerRef = useRef<AbortController | null>(null);

  const sendMessage = useCallback(async (userInput: string) => {
    if (!userInput.trim() || isStreaming) return;

    const userMessage: Message = {
      id: crypto.randomUUID(),
      role: 'user',
      content: userInput.trim(),
      timestamp: new Date()
    };

    const assistantMessage: Message = {
      id: crypto.randomUUID(),
      role: 'assistant',
      content: '',
      timestamp: new Date()
    };

    setMessages(prev => [...prev, userMessage, assistantMessage]);
    setIsStreaming(true);
    setError(null);

    abortControllerRef.current = new AbortController();

    try {
      const history = [...messages, userMessage].map(m => ({
        role: m.role,
        content: m.content
      }));

      const response = await fetch('https://api.anthropic.com/v1/messages', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'anthropic-version': '2023-06-01'
        },
        body: JSON.stringify({
          model: 'claude-sonnet-4-6',
          max_tokens: 1024,
          stream: true,
          messages: history
        }),
        signal: abortControllerRef.current.signal
      });

      if (!response.ok) throw new Error(`API error: ${response.status}`);

      const reader = response.body!.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value);
        const lines = chunk.split('\n').filter(l => l.startsWith('data: '));

        for (const line of lines) {
          const data = line.replace('data: ', '');
          if (data === '[DONE]') return;

          try {
            const parsed = JSON.parse(data);
            const token = parsed.delta?.text ?? '';
            if (token) {
              setMessages(prev =>
                prev.map(m =>
                  m.id === assistantMessage.id
                    ? { ...m, content: m.content + token }
                    : m
                )
              );
            }
          } catch { continue; }
        }
      }
    } catch (err: unknown) {
      if (err instanceof Error && err.name === 'AbortError') return;
      setError('Something went wrong. Please try again.');
      setMessages(prev => prev.filter(m => m.id !== assistantMessage.id));
    } finally {
      setIsStreaming(false);
    }
  }, [messages, isStreaming]);

  const clearChat = useCallback(() => {
    abortControllerRef.current?.abort();
    setMessages([]);
    setError(null);
  }, []);

  return {
    messages,
    isStreaming,
    error,
    hasMessages: messages.length > 0,
    sendMessage,
    clearChat
  };
}
Enter fullscreen mode Exit fullscreen mode

The React Chat Component

// components/Chat.tsx
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { useChat } from '../hooks/useChat';

export function Chat() {
  const { messages, isStreaming, error, hasMessages, sendMessage, clearChat } = useChat();
  const [inputValue, setInputValue] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  async function handleSend() {
    if (!inputValue.trim()) return;
    const value = inputValue;
    setInputValue('');
    await sendMessage(value);
  }

  function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  }

  return (
    <div className="chat-container">
      <div className="chat-header">
        <h2>AI Assistant</h2>
        {hasMessages && (
          <button onClick={clearChat} className="clear-btn">
            Clear chat
          </button>
        )}
      </div>

      <div className="messages">
        {!hasMessages && (
          <div className="empty-state">
            <p>👋 Ask me anything!</p>
          </div>
        )}

        {messages.map((message, index) => (
          <div key={message.id} className={`message message--${message.role}`}>
            <div className="message__bubble">
              {message.content}
              {message.role === 'assistant' &&
               isStreaming &&
               index === messages.length - 1 && (
                <span className="cursor"></span>
              )}
            </div>
          </div>
        ))}

        {error && (
          <div className="error-banner">⚠️ {error}</div>
        )}

        <div ref={messagesEndRef} />
      </div>

      <div className="chat-input">
        <textarea
          value={inputValue}
          onChange={e => setInputValue(e.target.value)}
          onKeyDown={handleKeyDown}
          disabled={isStreaming}
          placeholder="Type a message..."
          rows={1}
        />
        <button
          onClick={handleSend}
          disabled={isStreaming || !inputValue.trim()}
          className="send-btn">
          {isStreaming ? '...' : 'Send'}
        </button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Key Differences — Side by Side

1. State Management

Angular Signals                    React useState
──────────────────────────         ──────────────────────────
private _messages = signal([])     const [messages, setMessages]
messages = _messages.asReadonly()    = useState([])
_messages.update(...)              setMessages(prev => ...)
Enter fullscreen mode Exit fullscreen mode

Angular Signals give you read/write separation by design — you expose a readonly signal publicly and mutate privately. In React, useState gives you both the value and setter together — discipline around not exposing the setter is up to you.


####2. Logic Encapsulation
Angular                            React
──────────────────────────         ──────────────────────────
@Injectable() ChatService          useChat() custom hook
Singleton by default               New instance per component
inject(ChatService)                const chat = useChat()
Shared across entire app           Scoped to component tree

Enter fullscreen mode Exit fullscreen mode

3. Streaming Token Updates

Both implementations use the same approach — map over messages and update the matching one by ID. But the syntax reveals each framework's philosophy:

// Angular — explicit signal mutation
this._messages.update(msgs =>
  msgs.map(m => m.id === id ? { ...m, content: m.content + token } : m)
);

// React — state setter with previous value
setMessages(prev =>
  prev.map(m => m.id === id ? { ...m, content: m.content + token } : m)
);
Enter fullscreen mode Exit fullscreen mode

Functionally identical. Syntactically very similar. The difference is that Angular's .update() method makes the reactive intent explicit.

4. Cancellation

React's implementation adds something Angular's doesn't need — an AbortController ref:

// React — manual cancellation via ref
const abortControllerRef = useRef<AbortController | null>(null);
abortControllerRef.current = new AbortController();
// Pass signal to fetch
Enter fullscreen mode Exit fullscreen mode

In Angular, destroying the service or navigating away handles cleanup automatically through the DI lifecycle. In React, you manage it yourself — more control, more responsibility.

What I Learned

Angular felt more structured

The separation between service (logic) and component (UI) is enforced by the framework. There was never a question of "where does this code live?" — it lived in the service. Always.

React felt more flexible

The custom hook pattern is powerful but requires discipline. Nothing stops you from putting API logic directly in the component. That freedom is both React's strength and its trap.

Streaming felt the same

Honestly, the streaming implementation was nearly identical in both. The ReadableStream API is a browser primitive — both frameworks just stay out of its way.

Angular DI shines at scale

If this chatbot needed to be shared across 20 components in a large app — Angular's singleton service wins without question. In React, you'd need to lift the hook's state into Context and add a Provider.

React wins on simplicity for small scope

For a single chatbot component in isolation — React's hook is slightly less code and easier to reason about for someone new to the codebase.

Building the same AI chatbot in both Angular and React taught me one thing above all else: the AI integration part is the same. The framework differences show up in how you manage state and structure your code around it.

Angular gives you a clear home for everything — services for logic, components for UI, DI for wiring. React gives you flexibility and composability — hooks for logic, components for UI, Context when you need sharing.

Both are genuinely great choices for AI-powered frontend apps in 2026. The best developers don't pick a side — they understand both deeply enough to choose the right tool for the right context.

Have you built AI features in Angular or React? Which approach felt more natural to you? Drop it in the comments!

Top comments (0)