Build a chat app with ReactJS + Material UI
In this article, I would like to introduce using ReactJS and material UI to build a Chat App.
Prerequisites
Make sure you've installed Node.js and Yarn in your system.
You should also have intermediate knowledge about CSS, JavaScript, and ReactJS.
*** The following steps are below to install if they are not already.
// 1. Install Homebrew
$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
// 2. Install node with brew
$ brew install node
// 3. Install yarn
$ npm install yarn --global
Setup Reactjs + TypeScript with Tailwind CSS
// 1. Create a ReactJS project with create-react-app
$ yarn create react-app react-tailwind-ts --template typescript
// 2. Install Taiwind CSS
$ yarn add -D tailwindcss@npm:@tailwindcss/postcss7-compat postcss@^7 autoprefixer@^9 @mui/material @mui/icons-material @emotion/styled @emotion/react
// 3. Install Caraco
$ yarn add @craco/craco
// 4. Install MUI
$ yarn add @mui/material @mui/icons-material @emotion/styled @emotion/react
/*
* 5. Modify files
* craco.config.js
*/
module.exports = {
style: {
postcss: {
plugins: [require('tailwindcss')("./tailwind.config.js"), require('autoprefixer')],
},
},
};
/*
* 6. Modify files
* package.json
*/
"scripts": {
"watch:css": "tailwindcss -i ./src/index.css -o ./public/output.css --watch",
"start": "craco start",
"build": "npm run clear && craco build && tailwindcss -i ./src/index.css -o ./build/output.css --minify",
"test": "craco test",
"eject": "react-scripts eject",
"lint:fix": "eslint --fix",
"lint": "next lint",
"format": "prettier --write './**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc"
}
// 7. Generate tailwind.config.js
$ yarn tailwindcss-cli@latest init
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
corePlugins: {
preflight: false,
},
content: ['./src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
/*
* 8. Add tailwind
* index.css
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
// 9. index.tsx
import ReactDOM from 'react-dom/client';
import { StyledEngineProvider, ThemeProvider, createTheme } from '@mui/material';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const darkTheme = createTheme({
palette: {},
});
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<StyledEngineProvider>
<ThemeProvider theme={darkTheme}>
<App />
</ThemeProvider>
</StyledEngineProvider>,
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
// 10. App.tsx
import { lazy, useEffect, useMemo, useRef, useState } from 'react';
import Tooltip from '@mui/material/Tooltip';
const ChatBox = lazy(() => import('./components/chat-box'));
const AvatarWithUsername = lazy(() => import('./components/avatar-with-username'));
function App() {
const [conversations, setConversations] = useState<Conversation[]>([]);
useEffect(() => {
setConversations(conversationData);
}, []);
const conversationsOpened = useMemo(() => {
return conversations.filter((c) => c.opened);
}, [conversations]);
const conversationsMinimize = useMemo(() => {
return conversations.filter((c) => !c.opened);
}, [conversations]);
const onClose = (id: string) => {
const newConversations = conversations.filter(
(conversation) => conversation.converSationId !== id,
);
setConversations(newConversations);
};
const onMinimize = (id: string) => {
const newConversations = conversations.map((conversation) => {
if (conversation.converSationId === id) {
conversation.opened = false;
}
return conversation;
});
setConversations(newConversations);
};
const openConversation = (id: string) => {
let totalOpened = 0;
const newConversations = conversations.map((conversation) => {
if (totalOpened === 2) {
conversation.opened = false;
}
if (conversation.opened) {
totalOpened += 1;
}
if (conversation.converSationId === id) {
conversation.opened = true;
}
return conversation;
});
setConversations(newConversations);
};
const onSubmit = (conversationId: string, message: string) => {
const newConversations = [...conversations].map((conversation) => {
if (conversation.converSationId === conversationId) {
conversation.messages.push({
messageId: '999',
message,
});
}
return conversation;
});
setConversations(newConversations);
}
const onRemove = (conversationId: string, messageId: string) => {
const newConversations = [...conversations].map((conversation) => {
if (conversation.converSationId === conversationId) {
conversation.messages = [...conversation.messages].filter(
(message) => message.messageId !== messageId,
);
}
return conversation;
});
setConversations(newConversations);
};
const onLoadPrevious = (conversationId: string) => {};
return (
<div className="fixed bottom-0 right-6 md:right-16">
<div className="flex gap-3">
{conversationsOpened.map((conversation) => {
return (
<ChatBox
conversationId={conversation.converSationId}
key={conversation.converSationId}
title={conversation.name}
avatar={conversation.avatar}
conversations={conversation.messages}
onClose={onClose}
onMinimize={onMinimize}
onSubmit={onSubmit}
onLoadPrevious={onLoadPrevious}
onRemove={onRemove}
/>
);
})}
</div>
<div className="fixed bottom-12 right-2">
{conversationsMinimize.map((conversation) => {
return (
<Tooltip key={conversation.converSationId} title={conversation.name} followCursor>
<div
className="mt-2 hover:cursor-pointer"
onClick={() => openConversation(conversation.converSationId)}
>
<AvatarWithUsername username={conversation.name} hiddenName={true} />
</div>
</Tooltip>
);
})}
</div>
</div>
);
}
export default App;
Build components
// 1. components/avatar-with-username.tsx
import React, { lazy, useEffect, useState } from 'react';
import Avatar, { AvatarProps } from '@mui/material/Avatar';
export interface AvatarWithUsernameProps extends AvatarProps {
username: string;
subTitle?: string;
hiddenName?: boolean;
}
const AvatarWithUsername: React.FC<AvatarWithUsernameProps> = ({
username,
subTitle,
hiddenName = false,
...props
}) => {
const [title, setTitle] = useState('');
const [secondaryTitle, setSecondaryTitle] = useState('');
useEffect(() => {
setTitle(username);
}, [username]);
useEffect(() => {
setSecondaryTitle(subTitle ?? '');
}, [subTitle]);
return (
<div className="flex">
<Avatar {...props} name={username} />
{!hiddenName && (
<div className="ml-2 grid items-center">
<span className="text-base font-bold">{title}</span>
<span className="text-sm text-stone-500">{secondaryTitle}</span>
</div>
)}
</div>
);
};
export default AvatarWithUsername;
// 2. components/chat-box-header.tsx
import { lazy } from 'react';
import Close from '@mui/icons-material/Close';
import Remove from '@mui/icons-material/Remove';
import { IconButton } from '@mui/material';
const AvatarWithUsername = lazy(() => import('./avatar-with-username'));
const ChatBoxHeader = ({
title,
onClose,
onMinimize,
}: {
title: string;
onClose: () => void;
onMinimize: () => void;
}) => {
return (
<div className="flex justify-between w-full bg-blue-950 text-white p-2">
<AvatarWithUsername username={title} subTitle={'Đnag hoạt động'} />
<div className="flex mr-3">
<IconButton onClick={onMinimize}>
<Remove className="text-white" />
</IconButton>
<IconButton onClick={onClose}>
<Close className="text-white" />
</IconButton>
</div>
</div>
);
};
export default ChatBoxHeader;
// 3. components/chat-box-footer.tsx
import { lazy, useState } from 'react';
import Send from '@mui/icons-material/Send';
import { InputAdornment } from '@mui/material';
import TextField from '@mui/material/TextField';
const ChatBoxFooter = ({ onSubmit }: { onSubmit: (message: string) => void }) => {
const [message, setMessage] = useState('');
const summitMessage = () => {
if (!message) {
return;
}
setMessage('');
onSubmit(message);
};
return (
<TextField
placeholder="Message here ... "
className="w-full"
style={{ borderRadius: 999 }}
value={message}
onChange={(event) => {
setMessage(event.target.value);
}}
onKeyDown={(event) => {
if (event.keyCode === 13) {
summitMessage();
}
}}
InputProps={{
endAdornment: (
<InputAdornment position="end" className="hover:cursor-pointer hover:text-blue-500">
<Send onClick={summitMessage} />
</InputAdornment>
),
}}
/>
);
};
export default ChatBoxFooter;
//4. components/chat-box-message.tsx
import { lazy, useState } from 'react';
import MoreVert from '@mui/icons-material/MoreVert';
import { ClickAwayListener, IconButton, Menu, MenuItem, Typography } from '@mui/material';
const AvatarWithUsername = lazy(() => import('./avatar-with-username'));
export interface MessageProps {
messageId: string;
message: string;
author?: string;
avatar?: string;
onRemove?: (messageId: string) => void;
onEdit?: (messageId: string, message: string) => void;
}
const ChatBoxMessage: React.FC<MessageProps> = ({
messageId,
message,
author,
avatar,
onEdit,
onRemove,
}) => {
const [actionMessageId, setActionMessageId] = useState<string | undefined>();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const onMouseLeave = () => {
setActionMessageId(undefined);
setAnchorEl(null);
};
const handleClickAway = () => {
setActionMessageId(undefined);
};
const removeMessage = () => {
handleClose();
onRemove && onRemove(messageId);
};
if (author) {
return (
<div className="flex my-2">
<AvatarWithUsername username={author || ''} src={avatar} hiddenName={true} />
<div className="grid">
<Typography variant="caption">{author}</Typography>
<div className={'w-fit max-w-[90%] bg-stone-200 p-2 rounded-xl'}>{message}</div>
</div>
</div>
);
}
return (
<div
className="flex justify-end my-2"
onMouseEnter={() => setActionMessageId(messageId)}
onMouseLeave={onMouseLeave}
>
{actionMessageId === messageId && (
<ClickAwayListener onClickAway={handleClickAway}>
<>
<IconButton size="small" onClick={handleClick}>
<MoreVert className="hover:bg-stone-200 rounded-full" />
</IconButton>
<Menu
anchorEl={anchorEl}
id="account-menu"
open={open}
onClose={handleClose}
onClick={handleClose}
PaperProps={{
elevation: 0,
sx: {
width: 100,
overflow: 'visible',
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
mt: 1.5,
'& .MuiAvatar-root': {
width: 32,
height: 32,
ml: -0.5,
mr: 1,
},
'&:before': {
content: '""',
display: 'block',
position: 'absolute',
top: 0,
right: 14,
width: 10,
height: 10,
bgcolor: 'background.paper',
transform: 'translateY(-50%) rotate(45deg)',
zIndex: 0,
},
},
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem onClick={handleClose}>Chỉnh sửa</MenuItem>
<MenuItem onClick={removeMessage}>Gỡ bỏ</MenuItem>
</Menu>
</>
</ClickAwayListener>
)}
<div className={'w-fit max-w-[90%] bg-blue-500 text-white p-2 rounded-xl'}>{message}</div>
</div>
);
};
export default ChatBoxMessage;
// 5. components/chat-box.tsx
import { lazy, useEffect, useRef } from 'react';
import Card from '@mui/material/Card';
const ChatBoxHeader = lazy(() => import('./chat-box-header'));
const ChatBoxFooter = lazy(() => import('./chat-box-footer'));
const ChatBoxMessage = lazy(() => import('./chat-box-message'));
export interface ChatBoxProps {
conversationId: string;
title: string;
avatar: string;
conversations: { messageId: string; message: string; author?: string; avatar?: string }[];
onClose: (id: string) => void;
onMinimize: (id: string) => void;
onSubmit: (id: string, message: string) => void;
onLoadPrevious: (id: string) => void;
onRemove: (conversationId: string, messageId: string) => void;
}
const ChatBox: React.FC<ChatBoxProps> = ({
avatar,
conversations,
conversationId,
onClose,
onMinimize,
onSubmit,
onLoadPrevious,
onRemove,
title,
}) => {
const messagesEndRef = useRef<null | HTMLDivElement>(null);
useEffect(() => {
scrollToBottom();
const conversation = document.getElementById(`conversation-${conversationId}`);
conversation?.addEventListener('scroll', (e: any) => {
const el = e.target;
if (el.scrollTop === 0) {
onLoadPrevious(conversationId);
}
});
}, []);
const scrollToBottom = () => {
if (messagesEndRef && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
};
const onSubmitMessage = (message: string) => {
onSubmit(conversationId, message);
scrollToBottom();
};
return (
<Card variant="outlined" className="w-80 md:w-96" sx={{ borderRadius: 5 }}>
<ChatBoxHeader
title={title}
onClose={() => onClose(conversationId)}
onMinimize={() => onMinimize(conversationId)}
/>
<div className="p-2 h-96 overflow-auto" id={`conversation-${conversationId}`}>
{conversations.map((conversation) => {
return (
<ChatBoxMessage
key={conversation.messageId}
messageId={conversation.messageId}
message={conversation.message}
author={conversation.author}
avatar={conversation.avatar}
onRemove={(messageId: string) => onRemove(conversationId, messageId)}
/>
);
})}
<div ref={messagesEndRef} />
</div>
<ChatBoxFooter onSubmit={onSubmitMessage} />
</Card>
);
};
export default ChatBox;
Sample data
// data.json
[
{
converSationId: '1',
name: 'Nguyễn Văn A',
avatar: '',
messages: [
{
messageId: '1',
message: 'Hello',
author: 'Nguyễn Văn A',
},
{
messageId: '2',
message: 'Hello',
},
{
messageId: '3',
message: 'Nice to meet you',
author: 'Nguyễn Văn A',
},
{
messageId: '4',
message: 'Nice to meet you, too',
},
{
messageId: '5',
message: 'What is you name?',
},
{
messageId: '6',
message: 'My name is Đinh Thành Công',
author: 'Nguyễn Văn A',
},
],
opened: true,
},
{
converSationId: '2',
name: 'Đinh Công B',
avatar: '',
messages: [],
},
{
converSationId: '3',
name: 'Hồ Thi C',
avatar: '',
messages: [],
},
{
converSationId: '650003bcf24af390b9213e59',
name: 'Mã Văn D',
avatar: '',
messages: [],
opened: true,
},
{
converSationId: '5',
name: 'Lò Văn E',
avatar: '',
messages: [],
},
{
converSationId: '6',
name: 'Tống Văn F',
avatar: '',
messages: [],
},
{
converSationId: '7',
name: 'Hà Hồ G',
avatar: '',
messages: [],
opened: true,
},
{
converSationId: '8',
name: 'Hồ Văn H',
avatar: '',
messages: [],
},
]
Run the app
We will open 2 terminals:
// Terminal 1: To run the React App
$ yarn start
// Terminal 2: To run tailwindcss with mode watch
$ yarn watch:css
Wrapping Up
And that's it on building a UI chat app. Congrats!
If you enjoyed this article, consider sharing it to help other developers. You could also visit my blog to read more articles from me.
Till next time guys, byeeeee!