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);
}
}
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;
}
}
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
};
}
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>
);
}
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 => ...)
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
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)
);
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
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)