Next.js starter your AI actually understands. Ship internal tools in days not weeks. Pre-order $199 $499 → [Get it now]

How to build a chat app in React

Building a chat application requires managing message lists, user input, real-time updates, and smooth scrolling behavior. As the creator of CoreUI with over 10 years of React experience since 2014, I’ve built chat interfaces for applications ranging from simple customer support to complex team collaboration platforms. The most effective approach uses useState for messages, useRef for scroll management, and controlled inputs for message composition. This pattern provides a responsive, user-friendly chat experience.

Create a basic chat component with message display and input form.

import { useState } from 'react'

function ChatApp() {
  const [messages, setMessages] = useState([
    { id: 1, user: 'Alice', text: 'Hello!', timestamp: Date.now() },
    { id: 2, user: 'Bob', text: 'Hi Alice!', timestamp: Date.now() }
  ])
  const [inputValue, setInputValue] = useState('')

  const handleSend = (e) => {
    e.preventDefault()
    if (inputValue.trim()) {
      const newMessage = {
        id: messages.length + 1,
        user: 'You',
        text: inputValue,
        timestamp: Date.now()
      }
      setMessages([...messages, newMessage])
      setInputValue('')
    }
  }

  return (
    <div className="chat-app">
      <div className="messages">
        {messages.map(msg => (
          <div key={msg.id} className="message">
            <strong>{msg.user}:</strong> {msg.text}
          </div>
        ))}
      </div>
      <form onSubmit={handleSend} className="input-form">
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="Type a message..."
        />
        <button type="submit">Send</button>
      </form>
    </div>
  )
}

The messages array stores chat history. The inputValue tracks the current message being typed. The handleSend function adds new messages and clears the input. The form prevents empty messages from being sent. This provides basic chat functionality.

Adding Auto-Scroll to Latest Messages

Automatically scroll to the bottom when new messages arrive.

import { useState, useEffect, useRef } from 'react'

function ChatWithAutoScroll() {
  const [messages, setMessages] = useState([])
  const [inputValue, setInputValue] = useState('')
  const messagesEndRef = useRef(null)

  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }

  useEffect(() => {
    scrollToBottom()
  }, [messages])

  const handleSend = (e) => {
    e.preventDefault()
    if (inputValue.trim()) {
      const newMessage = {
        id: Date.now(),
        user: 'You',
        text: inputValue,
        timestamp: Date.now()
      }
      setMessages([...messages, newMessage])
      setInputValue('')
    }
  }

  return (
    <div className="chat-app">
      <div className="messages-container">
        {messages.map(msg => (
          <div key={msg.id} className="message">
            <strong>{msg.user}:</strong> {msg.text}
          </div>
        ))}
        <div ref={messagesEndRef} />
      </div>
      <form onSubmit={handleSend}>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="Type a message..."
        />
        <button type="submit">Send</button>
      </form>
    </div>
  )
}

The messagesEndRef points to a div at the bottom of messages. The scrollToBottom function scrolls it into view smoothly. The useEffect triggers scrolling whenever messages change. This ensures users always see the latest messages without manual scrolling.

Formatting Timestamps

Display human-readable message timestamps.

function ChatMessage({ message }) {
  const formatTime = (timestamp) => {
    const date = new Date(timestamp)
    const hours = date.getHours().toString().padStart(2, '0')
    const minutes = date.getMinutes().toString().padStart(2, '0')
    return `${hours}:${minutes}`
  }

  return (
    <div className="message">
      <div className="message-header">
        <strong>{message.user}</strong>
        <span className="timestamp">{formatTime(message.timestamp)}</span>
      </div>
      <div className="message-text">{message.text}</div>
    </div>
  )
}

function ChatWithTimestamps() {
  const [messages, setMessages] = useState([])
  const [inputValue, setInputValue] = useState('')

  const handleSend = (e) => {
    e.preventDefault()
    if (inputValue.trim()) {
      setMessages([...messages, {
        id: Date.now(),
        user: 'You',
        text: inputValue,
        timestamp: Date.now()
      }])
      setInputValue('')
    }
  }

  return (
    <div className="chat-app">
      <div className="messages-container">
        {messages.map(msg => (
          <ChatMessage key={msg.id} message={msg} />
        ))}
      </div>
      <form onSubmit={handleSend}>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="Type a message..."
        />
        <button type="submit">Send</button>
      </form>
    </div>
  )
}

The ChatMessage component displays individual messages. The formatTime function converts timestamps to HH:MM format. The padStart ensures two-digit formatting. This provides clear temporal context for conversations.

Adding User Avatars and Message Bubbles

Create a polished chat UI with avatars and styled message bubbles.

function ChatMessage({ message, isOwnMessage }) {
  return (
    <div className={`message ${isOwnMessage ? 'own-message' : 'other-message'}`}>
      {!isOwnMessage && (
        <img
          src={message.avatar || '/default-avatar.png'}
          alt={message.user}
          className="avatar"
        />
      )}
      <div className="message-bubble">
        {!isOwnMessage && <div className="message-user">{message.user}</div>}
        <div className="message-text">{message.text}</div>
        <div className="message-time">
          {new Date(message.timestamp).toLocaleTimeString('en-US', {
            hour: '2-digit',
            minute: '2-digit'
          })}
        </div>
      </div>
      {isOwnMessage && (
        <img
          src="/your-avatar.png"
          alt="You"
          className="avatar"
        />
      )}
    </div>
  )
}

The isOwnMessage prop controls message alignment and styling. Avatars appear on the left for others, right for own messages. The message bubble contains user name, text, and timestamp. CSS can style own messages differently from received messages using the class names.

Implementing Message Loading Indicator

Show typing indicators when someone is composing a message.

function ChatWithTypingIndicator() {
  const [messages, setMessages] = useState([])
  const [inputValue, setInputValue] = useState('')
  const [isTyping, setIsTyping] = useState(false)

  useEffect(() => {
    // Simulate other user typing
    const timer = setTimeout(() => {
      if (isTyping) {
        setMessages([...messages, {
          id: Date.now(),
          user: 'Alice',
          text: 'This is a response',
          timestamp: Date.now()
        }])
        setIsTyping(false)
      }
    }, 2000)

    return () => clearTimeout(timer)
  }, [isTyping])

  const handleSend = (e) => {
    e.preventDefault()
    if (inputValue.trim()) {
      setMessages([...messages, {
        id: Date.now(),
        user: 'You',
        text: inputValue,
        timestamp: Date.now()
      }])
      setInputValue('')
      setIsTyping(true)
    }
  }

  return (
    <div className="chat-app">
      <div className="messages-container">
        {messages.map(msg => (
          <ChatMessage key={msg.id} message={msg} />
        ))}
        {isTyping && (
          <div className="typing-indicator">
            <span></span>
            <span></span>
            <span></span>
          </div>
        )}
      </div>
      <form onSubmit={handleSend}>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="Type a message..."
        />
        <button type="submit">Send</button>
      </form>
    </div>
  )
}

The isTyping state shows when someone is typing. The typing indicator displays animated dots. The timeout simulates receiving a response. In real applications, WebSockets would signal when users are typing.

Connecting to Real-Time Backend

Integrate with WebSocket for real-time message delivery.

import { useState, useEffect, useRef } from 'react'

function RealTimeChatApp() {
  const [messages, setMessages] = useState([])
  const [inputValue, setInputValue] = useState('')
  const [isConnected, setIsConnected] = useState(false)
  const ws = useRef(null)

  useEffect(() => {
    ws.current = new WebSocket('ws://localhost:8080')

    ws.current.onopen = () => {
      setIsConnected(true)
      console.log('Connected to chat server')
    }

    ws.current.onmessage = (event) => {
      const message = JSON.parse(event.data)
      setMessages(prev => [...prev, message])
    }

    ws.current.onerror = (error) => {
      console.error('WebSocket error:', error)
    }

    ws.current.onclose = () => {
      setIsConnected(false)
      console.log('Disconnected from chat server')
    }

    return () => {
      ws.current.close()
    }
  }, [])

  const handleSend = (e) => {
    e.preventDefault()
    if (inputValue.trim() && isConnected) {
      const message = {
        user: 'You',
        text: inputValue,
        timestamp: Date.now()
      }
      ws.current.send(JSON.stringify(message))
      setMessages(prev => [...prev, message])
      setInputValue('')
    }
  }

  return (
    <div className="chat-app">
      <div className="connection-status">
        {isConnected ? '🟢 Connected' : '🔴 Disconnected'}
      </div>
      <div className="messages-container">
        {messages.map((msg, index) => (
          <ChatMessage key={index} message={msg} />
        ))}
      </div>
      <form onSubmit={handleSend}>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="Type a message..."
          disabled={!isConnected}
        />
        <button type="submit" disabled={!isConnected}>Send</button>
      </form>
    </div>
  )
}

The WebSocket connection enables real-time bidirectional communication. The onmessage handler receives messages from the server. The send method transmits messages. The connection status indicator shows whether the WebSocket is active. The input is disabled when disconnected.

Best Practice Note

This is the same chat architecture we use in CoreUI admin templates for customer support and team collaboration features. For production applications, add message persistence with a backend database, user authentication to identify senders, and message delivery receipts. Implement pagination or virtual scrolling for long conversation histories to maintain performance. Add emoji picker, file upload, and link preview features for richer conversations. Consider using libraries like Socket.io for more robust real-time communication with automatic reconnection. For styled components, check out CoreUI’s message components which provide professional chat UI elements out of the box.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author