In this tutorial, you will create a clone of Slack, a workplace messaging platform in React Native. The Slack application consists of three parts and includes several features.
This first part covers the following UI/UX features:
- Channel List: Contacts in popular messaging apps like WhatsApp.
- Input Box: For composing messages.
- Message Row: For displaying incoming and outgoing messages.
- Reaction List: To display message reactions.
- Giphy Card: A container for displaying GIFs.
- Enriched URL Previews.
The result will look like the following:
Note: This tutorial is a guide to building a real-world chat experience using Stream's Chat and Messaging API and SDKs—not a production-ready Slack replacement.
Resources
Here are a few links to help you along the tutorial:
- Official Slack Clone Repo v1.0
- Official Slack Clone Repo for Expo v1.0
- Documentation for React Navigation
- Stream Chat Component Library
- React Native Chat Tutorial
- Stream's React Native Chat Components
Dev Environment and Project Setup
Before getting started, ensure you have a development environment set up for React Native, Android, and iOS. You should read the Set Up Your Environment section of the official React Native docs.
Once you have a dev environment set up, create a new react-native application:
12345678# Create a new react-native project with the name SlackChatApp npx @react-native-community/cli@latest init SlackChatApp # Go to your app directory cd SlackChatApp # Add all the required dependencies for this project yarn add @react-native-community/netinfo@12.0.1 @react-navigation/native@7.1.28 @react-navigation/drawer@7.8.1 moment@2.30.1 @react-native-documents/picker@12.0.1 react-native-gesture-handler@2.30.0 react-native-image-picker@8.2.1 react-native-reanimated@4.2.1 react-native-safe-area-context@5.6.2 react-native-screens@4.23.0 stream-chat@9.32.0 stream-chat-react-native@8.12.4
Slack uses a Lato font, which is freely available on https://fonts.google.com/. For visual parity, we need to import the font into our app. To do so, create a file named react-native.config.js in the project directory and paste the following contents:
123module.exports = { assets: ['./src/fonts/'], };
You can download the fonts from the Google Fonts website. You will see a button titled Download family at the top.
Add a Channel List Navigation
The ChannelList chat component helps build the app's primary navigation. You can use it to subscribe to events for channel changes, updates, and new messages. Create a channel list and add it to a navigation drawer in src/components/ChannelList/ChannelList.js.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191import React from 'react'; import {View, StyleSheet, SectionList, TouchableOpacity} from 'react-native'; import {useNavigation, useTheme} from '@react-navigation/native'; import {ChatClientService, notImplemented} from '../../utils'; import {SVGIcon} from '../SVGIcon'; import {SCText} from '../SCText'; import {ChannelListItem} from '../ChannelListItem'; import {useWatchedChannels} from './useWatchedChannels'; export const ChannelList = () => { const client = ChatClientService.getClient(); const navigation = useNavigation(); const {colors} = useTheme(); const changeChannel = channelId => { navigation.navigate('ChannelScreen', {channelId}); }; const { activeChannelId, setActiveChannelId, unreadChannels, readChannels, directMessagingConversations, } = useWatchedChannels(client); const renderChannelRow = (channel, isUnread) => { return ( <ChannelListItem activeChannelId={activeChannelId} setActiveChannelId={setActiveChannelId} changeChannel={changeChannel} showAvatar={false} presenceIndicator isUnread={isUnread} channel={channel} client={client} key={channel.id} currentUserId={client.user.id} /> ); }; return ( <View style={styles.container}> <SectionList showsVerticalScrollIndicator={false} showsHorizontalScrollIndicator={false} style={styles.sectionList} sections={[ { title: '', id: 'menu', data: [ { id: 'threads', title: 'Threads', icon: <SVGIcon height="14" width="14" type="threads" />, handler: notImplemented, }, { id: 'drafts', title: 'Drafts', icon: <SVGIcon height="14" width="14" type="drafts" />, handler: () => navigation.navigate('DraftsScreen'), }, ], }, { title: 'Unread', id: 'unread', data: unreadChannels || [], }, { title: 'Channels', data: readChannels || [], clickHandler: () => { navigation.navigate('ChannelSearchScreen', { channelsOnly: true, }); }, }, { title: 'Direct Messages', data: directMessagingConversations || [], clickHandler: () => { navigation.navigate('NewMessageScreen'); }, }, ]} keyExtractor={(item, index) => item.id + index} SectionSeparatorComponent={() => <View style={{height: 5}} />} renderItem={({item, section}) => { if (section.id === 'menu') { return ( <TouchableOpacity onPress={() => { item.handler && item.handler(); }} style={styles.channelRow}> <View style={styles.channelTitleContainer}> {item.icon} <SCText style={styles.channelTitle}>{item.title}</SCText> </View> </TouchableOpacity> ); } return renderChannelRow(item, section.id === 'unread'); }} stickySectionHeadersEnabled renderSectionHeader={({section: {title, data, id, clickHandler}}) => { if (data.length === 0 || id === 'menu') { return null; } return ( <View style={[ styles.groupTitleContainer, { backgroundColor: colors.background, borderTopColor: colors.border, borderTopWidth: 1, }, ]}> <SCText style={styles.groupTitle}>{title}</SCText> {clickHandler && ( <TouchableOpacity onPress={() => { clickHandler(); }} style={styles.groupTitleRightButton}> <SCText style={styles.groupTitleRightButtonText}>+</SCText> </TouchableOpacity> )} </View> ); }} /> </View> ); }; const styles = StyleSheet.create({ container: { paddingLeft: 5, paddingRight: 5, flexDirection: 'column', justifyContent: 'flex-start', }, sectionList: { flexGrow: 1, flexShrink: 1, }, groupTitleContainer: { paddingTop: 14, marginLeft: 10, marginRight: 10, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, groupTitle: { fontSize: 14, }, groupTitleRightButton: { textAlignVertical: 'center', }, groupTitleRightButtonText: { fontSize: 25, }, channelRow: { paddingLeft: 10, paddingTop: 5, paddingBottom: 5, flexDirection: 'row', justifyContent: 'space-between', borderRadius: 6, marginRight: 5, }, channelTitleContainer: { flexDirection: 'row', alignItems: 'center', }, channelTitle: { padding: 5, paddingLeft: 10, }, });
The app needs to be populated with some channels. In the project’s App.js, replace the content with the following to create a chat client and pass it as a prop to the ChannelList component.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231import React, {useEffect, useState} from 'react'; import { ActivityIndicator, View, StyleSheet, SafeAreaView, Text, Pressable, LogBox, useColorScheme, } from 'react-native'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {NavigationContainer} from '@react-navigation/native'; import {createStackNavigator} from '@react-navigation/stack'; import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; import {StreamChat} from 'stream-chat'; import {Chat, OverlayProvider} from 'stream-chat-react-native'; import { ChatUserContext, ChatClientService, USER_TOKENS, USERS, } from './src/utils'; import {ChannelScreen} from './src/screens/ChannelScreen'; import {NewMessageScreen} from './src/screens/NewMessageScreen'; import {ChannelSearchScreen} from './src/screens/ChannelSearchScreen'; import {ChannelListScreen} from './src/screens/ChannelListScreen'; import {DraftsScreen} from './src/screens/DraftsScreen'; import {MentionsScreen} from './src/screens/MentionsSearch'; import {DirectMessagesScreen} from './src/screens/DirectMessagesScreen'; import {TargettedMessageChannelScreen} from './src/screens/TargettedMessageChannelScreen'; import {MessageSearchScreen} from './src/screens/MessageSearchScreen'; import {ProfileScreen} from './src/screens/ProfileScreen'; import {ThreadScreen} from './src/screens/ThreadScreen'; import {BottomTabs} from './src/components/BottomTabs'; import {DarkTheme, LightTheme} from './src/appTheme'; LogBox.ignoreAllLogs(true); const Tab = createBottomTabNavigator(); const HomeStack = createStackNavigator(); const ModalStack = createStackNavigator(); const App = () => { const scheme = useColorScheme(); const [connecting, setConnecting] = useState(true); const [connectionError, setConnectionError] = useState(null); const [retryCount, setRetryCount] = useState(0); const [user, setUser] = useState(USERS.vishal); useEffect(() => { let client; let isMounted = true; const initChat = async () => { try { client = StreamChat.getInstance('q95x9hkbyd6p', { timeout: 30000, }); await client.connectUser(user, USER_TOKENS[user.id]); if (isMounted) { ChatClientService.setClient(client); setConnectionError(null); setConnecting(false); } } catch (error) { console.error('Failed to connect to Stream Chat:', error); if (isMounted) { setConnectionError( error?.message || 'Unable to connect. Check your network and retry.', ); setConnecting(false); } } }; setConnecting(true); setConnectionError(null); initChat(); return () => { isMounted = false; if (client) { client.disconnectUser().catch(() => null); } }; }, [user, retryCount]); if (connecting) { return ( <SafeAreaView> <View style={styles.loadingContainer}> <ActivityIndicator size="small" color="black" /> </View> </SafeAreaView> ); } if (connectionError) { return ( <SafeAreaView> <View style={styles.loadingContainer}> <Text style={styles.errorTitle}>Could not connect to chat.</Text> <Text style={styles.errorBody}>{connectionError}</Text> <Pressable style={styles.retryButton} onPress={() => setRetryCount(count => count + 1)}> <Text style={styles.retryButtonText}>Retry</Text> </Pressable> </View> </SafeAreaView> ); } return ( <GestureHandlerRootView style={styles.container}> <SafeAreaProvider> <OverlayProvider> <Chat client={ChatClientService.getClient()}> <NavigationContainer theme={scheme === 'dark' ? DarkTheme : LightTheme}> <ChatUserContext.Provider value={{ switchUser: userId => setUser(USERS[userId]), }}> <HomeStackNavigator /> </ChatUserContext.Provider> </NavigationContainer> </Chat> </OverlayProvider> </SafeAreaProvider> </GestureHandlerRootView> ); }; const ModalStackNavigator = () => { return ( <ModalStack.Navigator initialRouteName="Tabs" screenOptions={{ presentation: 'modal', headerShown: false, }}> <ModalStack.Screen name="Tabs" component={TabNavigation} /> <ModalStack.Screen name="NewMessageScreen" component={NewMessageScreen} /> <ModalStack.Screen name="ChannelSearchScreen" component={ChannelSearchScreen} /> <ModalStack.Screen name="MessageSearchScreen" component={MessageSearchScreen} /> <ModalStack.Screen name="TargettedMessageChannelScreen" component={TargettedMessageChannelScreen} /> </ModalStack.Navigator> ); }; const HomeStackNavigator = () => { return ( <HomeStack.Navigator initialRouteName="ModalStack" screenOptions={{headerShown: false}}> <HomeStack.Screen name="ModalStack" component={ModalStackNavigator} /> <HomeStack.Screen name="ChannelScreen" component={ChannelScreen} /> <HomeStack.Screen name="DraftsScreen" component={DraftsScreen} /> <HomeStack.Screen name="ThreadScreen" component={ThreadScreen} /> </HomeStack.Navigator> ); }; const TabNavigation = () => { return ( <Tab.Navigator tabBar={props => <BottomTabs {...props} />} screenOptions={{headerShown: false}}> <Tab.Screen name="home" component={ChannelListScreen} /> <Tab.Screen name="dms" component={DirectMessagesScreen} /> <Tab.Screen name="mentions" component={MentionsScreen} /> <Tab.Screen name="you" component={ProfileScreen} /> </Tab.Navigator> ); }; export default App; const styles = StyleSheet.create({ loadingContainer: { height: '100%', justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20, }, container: { flex: 1, }, errorTitle: { fontSize: 17, fontWeight: '600', marginBottom: 8, }, errorBody: { textAlign: 'center', marginBottom: 16, }, retryButton: { backgroundColor: '#005FFF', borderRadius: 8, paddingHorizontal: 16, paddingVertical: 10, }, retryButtonText: { color: '#fff', fontWeight: '600', }, });
Next, you should create a hook in ChannelList.js for querying the channels. Check the full implementation in the source code on GitHub. The hook queries the channels using the Stream client. It also sorts them into three categories, which are returned as state variables: unreadChannels, readChannels, and oneOnOneConversations.
Create Channel List Items
Add a new component in a separate file named src/components/ChannelListItem.js. This creates a UI that resembles Slack.
This component will ensure different styles depending on whether it's a group channel, a one-on-one conversation, or an unread channel. It will also check whether or not it contains user mentions.
If you run your app with yarn ios or yarn android, you should see a few channels populated in the channels list, as shown in the screenshot below:
So far, the ChannelList works fine, but it's not real-time. If another user sends a message on a channel, it won't appear in your ChannelList. We need to implement event handlers in our useWatchedChannels hook for this purpose.
You can find detailed docs about Stream events here.
We are going to handle two events for tutorial purposes, but you can experiment with as many events as you want:
1. message.new - This event tells us that there is a new message on some channel (channel data is included in the event object). In this case, we want to move the channel from either readChannels or oneOnOneConversations to unreadChannels.
2. message.read - This event tells us that some channel (data available in the event object) was marked as read. In this case, we want to move the channel from unreadChannels to either readChannels or oneOnOneConversations.
Let's use the useWatchedChannels hook in src/components/ChannelList/useWatchedChannels.js to support real-time updates:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171import {useState, useEffect} from 'react'; import {CacheService, ChatClientService} from '../../utils'; export const useWatchedChannels = () => { const client = ChatClientService.getClient(); const [activeChannelId, setActiveChannelId] = useState(null); const [unreadChannels, setUnreadChannels] = useState([]); const [readChannels, setReadChannels] = useState([]); const [directMessagingConversations, setDirectMessagingConversations] = useState([]); const filters = { type: 'messaging', example: 'slack-demo', members: { $in: [client.user.id], }, }; const sort = {has_unread: -1, last_message_at: -1}; const options = {limit: 30, offset: 0, state: true}; useEffect(() => { const _unreadChannels = []; const _readChannels = []; const _directMessagingConversations = []; const fetchChannels = async () => { const channels = await client.queryChannels( { ...filters, name: {$ne: ''}, }, sort, options, ); channels.forEach(c => { if (c.countUnread() > 0) { _unreadChannels.push(c); } else { _readChannels.push(c); } }); setUnreadChannels([..._unreadChannels]); setReadChannels([..._readChannels]); setDirectMessagingConversations([..._directMessagingConversations]); CacheService.setChannels(channels); }; const fetchDirectMessagingConversations = async () => { const directMessagingChannels = await client.queryChannels( { ...filters, name: '', }, sort, options, ); directMessagingChannels.forEach(c => { if (c.countUnread() > 0) { _unreadChannels.push(c); } else { _directMessagingConversations.push(c); } }); _unreadChannels.sort((a, b) => { return a.state.last_message_at > b.state.last_message_at ? -1 : 1; }); setUnreadChannels([..._unreadChannels]); setReadChannels([..._readChannels]); setDirectMessagingConversations([..._directMessagingConversations]); CacheService.setDirectMessagingConversations(directMessagingChannels); }; async function init() { await fetchChannels(); await fetchDirectMessagingConversations(); CacheService.loadRecentAndOneToOne(); } init(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { function handleEvents(e) { if (e.type === 'message.new') { if (e.user?.id === client.user.id) { return; } const cid = e.cid; const channelReadIndex = readChannels.findIndex( channel => channel.cid === cid, ); if (channelReadIndex >= 0) { const channel = readChannels[channelReadIndex]; readChannels.splice(channelReadIndex, 1); setReadChannels([...readChannels]); setUnreadChannels([channel, ...unreadChannels]); } const dmIndex = directMessagingConversations.findIndex( channel => channel.cid === cid, ); if (dmIndex >= 0) { const channel = directMessagingConversations[dmIndex]; directMessagingConversations.splice(dmIndex, 1); setDirectMessagingConversations([...directMessagingConversations]); setUnreadChannels([channel, ...unreadChannels]); } const channelUnreadIndex = unreadChannels.findIndex( channel => channel.cid === cid, ); if (channelUnreadIndex >= 0) { const channel = unreadChannels[channelUnreadIndex]; unreadChannels.splice(channelUnreadIndex, 1); setUnreadChannels([channel, ...unreadChannels]); } } if (e.type === 'message.read') { if (e.user?.id !== client.user.id) { return; } const cid = e.cid; const channelIndex = unreadChannels.findIndex( channel => channel.cid === cid, ); if (channelIndex < 0) { return; } const channel = unreadChannels[channelIndex]; unreadChannels.splice(channelIndex, 1); setUnreadChannels([...unreadChannels]); if (!channel.data.name) { setDirectMessagingConversations([ channel, ...directMessagingConversations, ]); } else { setReadChannels([channel, ...readChannels]); } } } client.on(handleEvents); return () => { client.off(handleEvents); }; }, [client, readChannels, unreadChannels, directMessagingConversations]); return { activeChannelId, setActiveChannelId, unreadChannels, setUnreadChannels, readChannels, setReadChannels, directMessagingConversations, setDirectMessagingConversations, }; };
We have added another useEffect hook here that adds an event listener to our stream client and removes it when the component unmounts. The handleEvent is an event handler that takes some action based on the event received.
As an exercise, you can try adding handlers for other events such as
user.presence.changed,channel.updated, orchannel.deleted
Add a Channel Screen
The Channel screen consists of a header, src/components/ChannelHeader.js, MessageInput, and ChannelScreen components.
The MessageInput and ChannelScreen components are provided by Stream as part of the react-native-sdk.
Let’s update the ChannelScreen component in src/screens/ChannelScreen.js.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100import React, {useEffect, useState} from 'react'; import {View, SafeAreaView, StyleSheet} from 'react-native'; import { Channel, MessageList, MessageInput, } from 'stream-chat-react-native'; import {useNavigation, useRoute, useTheme} from '@react-navigation/native'; import {ChannelHeader} from '../components/ChannelHeader'; import {MessageSlack} from '../components/MessageSlack'; import {DateSeparator} from '../components/DateSeparator'; import { getChannelDisplayImage, getChannelDisplayName, ChatClientService, AsyncStore, } from '../utils'; export function ChannelScreen() { const {colors} = useTheme(); const {params} = useRoute(); const channelId = params?.channelId ?? null; const navigation = useNavigation(); const chatClient = ChatClientService.getClient(); const [channel, setChannel] = useState(null); const [isReady, setIsReady] = useState(false); const goBack = () => { navigation.goBack(); }; useEffect(() => { if (!channelId) { navigation.goBack(); return; } const initChannel = async () => { const _channel = chatClient.channel('messaging', channelId); await _channel.watch(); setChannel(_channel); setIsReady(true); }; initChannel(); }, [channelId]); if (!isReady || !channel) { return null; } return ( <SafeAreaView style={{backgroundColor: colors.background}}> <View style={styles.channelScreenContainer}> <ChannelHeader goBack={goBack} channel={channel} /> <View style={[styles.chatContainer, {backgroundColor: colors.background}]}> <Channel channel={channel} keyboardVerticalOffset={80} MessageSimple={MessageSlack} DateSeparator={DateSeparator} forceAlignMessages="left" doSendMessageRequest={async (cid, message) => { AsyncStore.removeItem( `@slack-clone-draft-${chatClient.user.id}-${channelId}`, ); return channel.sendMessage(message); }}> <MessageList onThreadSelect={thread => { navigation.navigate('ThreadScreen', { threadId: thread.id, channelId: channel.id, }); }} /> <MessageInput additionalTextInputProps={{ placeholderTextColor: '#979A9A', placeholder: channel?.data?.name ? 'Message #' + channel.data.name.toLowerCase().replace(' ', '_') : 'Message', }} /> </Channel> </View> </View> </SafeAreaView> ); } const styles = StyleSheet.create({ channelScreenContainer: {flexDirection: 'column', height: '100%'}, chatContainer: { flexGrow: 1, flexShrink: 1, }, });
After this change, you will see messages and an input box at the bottom of our Channel Screen.
To make the UI more like Slack's, here is a list of features from Slack's UI to add.
- The user name is shown at the top of messages.
- Avatars (circular user profile pics next to message) should be square.
- Reactions should be at the bottom of messages.
- Reaction counts should be shown next to each reaction.
- URL previews should have a thick left gray border and an offset for their content alignment.
- All messages should be displayed on the left side of the screen.
- GIFs are shown differently in Slack channels.
- The date separator between messages should be shown above a grey line.
- Send and attach buttons should be below the input box.
The Stream React Native SDK uses MessageSimple as the default message component. But you can also use a custom UI component as a message -- reference here.
To implement all the above features, navigate to the components directory, src/components, and copy the content of the following files into your project.
- MessageSlack.js
- MessageFooter.js
- MessageHeader.js
- MessageText.js
- MessageAvatar.js
- UrlPreview.js
- Giphy.js
- DateSeparator.js
After adding the above, all you need to do is pass the MessageSlack and DateSeparator to the MessageList component in App.js.
If you refresh the app, you will see the UI now has much better parity with the Slack UI.
Add an Input Field
The MessageComposer component in the React Native Chat SDK accepts Input as a custom UI component prop to be shown for the input field. You can create the custom component in src/components/InputBox.js.
It handles the following:
AutoCompleteInput- To manage input box features such as mentions, sending messages, maintaining enabled/disabled state, etc.SendButton: To send the composed messages.AttachButton: To upload media such as files, documents, images, and videos.
After creating the InputBox, it must be passed to the MessageInput in the ChannelScreen component of App.js.
Congratulations!
You have now completed part one of the three-series Slack clone tutorial using Stream's React Native Chat components.
The next part of the tutorial will cover additional UI components and their functionality, such as:
- Threads
- Channel search
- Action sheets
- Unread message notifications
We’ll see you in part two!
