How to build a chat app in Angular
Building a chat app in Angular demonstrates real-time communication with WebSockets, infinite scroll for message history, and reactive state management for incoming messages. As the creator of CoreUI with Angular development experience since 2014, I’ve built the chat interface components in CoreUI Angular templates that handle message threading, timestamps, and auto-scroll to the latest message. The architecture uses a WebSocket service that manages the connection and exposes an observable of incoming messages, keeping the chat component clean and focused on rendering. Proper cleanup of WebSocket connections on component destroy prevents memory leaks in long-running applications.
Create a WebSocket chat service.
// chat.service.ts
import { Injectable, OnDestroy } from '@angular/core'
import { Subject, Observable } from 'rxjs'
import { AuthService } from './auth.service'
export interface ChatMessage {
id: string
senderId: number
senderName: string
text: string
timestamp: Date
}
@Injectable({ providedIn: 'root' })
export class ChatService implements OnDestroy {
private ws: WebSocket | null = null
private messages$ = new Subject<ChatMessage>()
private status$ = new Subject<string>()
readonly messages: Observable<ChatMessage> = this.messages$.asObservable()
readonly status: Observable<string> = this.status$.asObservable()
constructor(private auth: AuthService) {}
connect(roomId: string): void {
const token = this.auth.getToken()
this.ws = new WebSocket(`${location.origin.replace('http', 'ws')}/ws/chat/${roomId}?token=${token}`)
this.ws.onopen = () => this.status$.next('connected')
this.ws.onclose = () => this.status$.next('disconnected')
this.ws.onerror = () => this.status$.next('error')
this.ws.onmessage = (event) => {
try {
const message: ChatMessage = JSON.parse(event.data)
this.messages$.next({
...message,
timestamp: new Date(message.timestamp)
})
} catch {
console.error('Invalid message format')
}
}
}
sendMessage(text: string): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'message', text }))
}
}
disconnect(): void {
this.ws?.close()
this.ws = null
}
ngOnDestroy(): void {
this.disconnect()
}
}
Using Subject instead of BehaviorSubject means new subscribers only receive future messages, not historical ones — which is the correct behavior for a live chat stream. Past messages should be loaded separately from an HTTP endpoint.
Chat Room Component
Display messages and send new ones.
// chat-room.component.ts
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core'
import { CommonModule } from '@angular/common'
import { ReactiveFormsModule, FormControl, Validators } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
import { CardModule, FormModule, ButtonModule } from '@coreui/angular'
import { Subscription } from 'rxjs'
import { ChatService, ChatMessage } from './chat.service'
import { AuthService } from '../auth/auth.service'
@Component({
selector: 'app-chat-room',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, CardModule, FormModule, ButtonModule],
template: `
<c-card style="height: 80vh; display: flex; flex-direction: column">
<c-card-header>{{ roomId }}</c-card-header>
<div #messageContainer class="flex-grow-1 overflow-auto p-3">
<div *ngFor="let msg of messages"
[class.text-end]="msg.senderId === currentUserId"
class="mb-2"
>
<div [class]="msg.senderId === currentUserId ? 'bg-primary text-white' : 'bg-light'"
class="d-inline-block rounded p-2" style="max-width: 75%"
>
<small class="d-block fw-bold">{{ msg.senderName }}</small>
{{ msg.text }}
<small class="d-block text-end opacity-75">{{ msg.timestamp | date:'HH:mm' }}</small>
</div>
</div>
</div>
<c-card-footer>
<form class="d-flex gap-2" (ngSubmit)="send()">
<input cFormControl [formControl]="messageControl" placeholder="Type a message..." />
<button cButton color="primary" type="submit" [disabled]="!connected">Send</button>
</form>
</c-card-footer>
</c-card>
`
})
export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewChecked {
@ViewChild('messageContainer') private container!: ElementRef
messages: ChatMessage[] = []
connected = false
roomId = ''
currentUserId = 0
messageControl = new FormControl('', Validators.required)
private sub = new Subscription()
constructor(
private chat: ChatService,
private auth: AuthService,
private route: ActivatedRoute
) {}
ngOnInit(): void {
this.roomId = this.route.snapshot.params['roomId']
this.currentUserId = this.auth.getCurrentUser().id
this.chat.connect(this.roomId)
this.sub.add(this.chat.messages.subscribe(msg => {
this.messages.push(msg)
}))
this.sub.add(this.chat.status.subscribe(status => {
this.connected = status === 'connected'
}))
}
ngAfterViewChecked(): void {
// Auto-scroll to bottom on new messages
const el = this.container?.nativeElement
if (el) el.scrollTop = el.scrollHeight
}
send(): void {
if (this.messageControl.valid && this.messageControl.value) {
this.chat.sendMessage(this.messageControl.value)
this.messageControl.reset()
}
}
ngOnDestroy(): void {
this.sub.unsubscribe()
this.chat.disconnect()
}
}
ngAfterViewChecked auto-scrolls to the bottom after each render cycle when new messages arrive. Subscription.add() registers multiple subscriptions so they all unsubscribe together in ngOnDestroy.
Best Practice Note
This is the same chat component structure used in CoreUI Angular templates. For production, load message history from a REST endpoint on component init, then switch to WebSockets for live messages. Add optimistic rendering — display the sent message immediately before the server echoes it back, then deduplicate when the echo arrives. For scaling to many concurrent connections, use Socket.IO with Redis adapter to distribute messages across multiple Node.js instances.



