Welcome to the final series of the Slack clone tutorial. In part 2, we covered how to build Slack-like navigation, channel lists, channel screens, a reaction picker, and an action sheet. In part 3, we will build the various search screens and the message thread screen.
Below are a few resources to assist along the way:
- Slack Clone Repo v2.0 (latest version updated in 2026)
- Official Slack Clone Repo v1.0
- Official Slack Clone Repo for Expo v1.0
- Documentation for React Navigation
- Stream Chat Component Library
Create the Threads Screen
- The
MessageListcomponent of the project accepts the prop functiononThreadSelect, which is attached to theonPresshandler for reply count text below the message bubble. If you check our/src/screens/ChannelScreen.js component, you will see navigation logic to thesrc/screens/ThreadScreen.js added to theonThreadSelectprop on theMesaageListcomponent. - The
Threadis provided out-of-the-box fromstream-chat-react-native. If you look at the source code, it's a set ofMessage(parent message bubble),MessageList, and aMessageInputcomponent. You can customize these underlying components using the props,additionalParentMessageProps,additionalMessageListProps, andadditionalMessageInputProps. We can use this thread component easily for our purpose.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192import React, {useEffect, useState} from 'react'; import {View, SafeAreaView, StyleSheet} from 'react-native'; import { Channel, Thread, } from 'stream-chat-react-native'; import {useNavigation, useRoute, useTheme} from '@react-navigation/native'; import {MessageSlack} from '../components/MessageSlack'; import { getChannelDisplayName, ChatClientService, truncate, } from '../utils'; import {ModalScreenHeader} from '../components/ModalScreenHeader'; export function ThreadScreen() { const {params} = useRoute(); const channelId = params?.channelId ?? null; const threadId = params?.threadId ?? null; const {colors} = useTheme(); const chatClient = ChatClientService.getClient(); const navigation = useNavigation(); const [channel, setChannel] = useState(null); const [thread, setThread] = useState(); const [isReady, setIsReady] = useState(false); const goBack = () => { navigation.goBack(); }; useEffect(() => { const getThread = async () => { const res = await chatClient.getMessage(threadId); setThread(res.message); }; getThread(); }, [chatClient, threadId]); useEffect(() => { if (!channelId) { navigation.goBack(); return; } const initChannel = async () => { const _channel = chatClient.channel('messaging', channelId); await _channel.watch(); setChannel(_channel); setIsReady(true); }; initChannel(); }, [channelId, threadId]); if (!isReady || !thread || !channel) { return null; } return ( <SafeAreaView style={{backgroundColor: colors.background}}> <View style={styles.channelScreenContainer}> <ModalScreenHeader title={'Thread'} goBack={goBack} subTitle={truncate(getChannelDisplayName(channel, true), 35)} /> <View style={[styles.chatContainer, {backgroundColor: colors.background}]}> <Channel channel={channel} thread={thread} threadList keyboardVerticalOffset={80} MessageSimple={MessageSlack} forceAlignMessages="left" allowThreadMessagesInChannel> <Thread /> </Channel> </View> </View> </SafeAreaView> ); } const styles = StyleSheet.create({ channelScreenContainer: {flexDirection: 'column', height: '100%'}, chatContainer: { flexGrow: 1, flexShrink: 1, }, });
Create the Search Screens
There are three search action sheets to be implemented in this tutorial.
- Message Search UI
- Jump to Channel UI
- Channel Search UI
Create the Message Search Screen
Let’s implement a global search for messages on the MessageSearchScreen in your project’s /src/screens/MessageSearchScreen.js.
Note: The official Slack app provides richer features, such as search within a specific channel or by attachments. Here, we are keeping it limited to a global search, though channel-specific searches are also possible using the Stream Search API.
- The global message search is relatively heavy on the backend, so the search won’t happen on onChangeText. It only happens when the user explicitly presses the search button. The TextInput component has a returnKeyType prop, which we need for our use case.
- The component uses the
searchendpoint available on chat clients. Please check the docs for the message endpoint. - The search results display a list of messages. When tapped, they open the channel screen for a specific message. We are going to build a separate screen for this
TargettedMessageChannelScreen. The component is quite similar to theChannelScreen, but it queries the channel at a specific message (provided through props) instead of the latest message. - Users can see a list of past messages when they visit this screen. Every search text is stored in
AsyncStorage.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270import React, {useEffect, useRef, useState} from 'react'; import { View, StyleSheet, FlatList, TextInput, TouchableOpacity, ActivityIndicator, SafeAreaView, } from 'react-native'; import { AsyncStore, ChatClientService, getChannelDisplayName, } from '../utils'; import {useNavigation, useTheme} from '@react-navigation/native'; import {SCText} from '../components/SCText'; import {ListItemSeparator} from '../components/ListItemSeparator'; export const MessageSearchScreen = () => { const {colors, dark} = useTheme(); const navigation = useNavigation(); const inputRef = useRef(null); const [results, setResults] = useState(null); const [recentSearches, setRecentSearches] = useState([]); const [loadingResults, setLoadingResults] = useState(false); const [searchText, setSearchText] = useState(''); const addToRecentSearches = async q => { const _recentSearches = [...recentSearches]; _recentSearches.unshift(q); const sliced = _recentSearches.slice(0, 7); setRecentSearches(sliced); await AsyncStore.setItem('@slack-clone-recent-searches', sliced); }; const removeFromRecentSearches = async index => { const _recentSearches = [...recentSearches]; _recentSearches.splice(index, 1); setRecentSearches(_recentSearches); await AsyncStore.setItem('@slack-clone-recent-searches', _recentSearches); }; const search = async q => { if (!q) { setLoadingResults(false); return; } const chatClient = ChatClientService.getClient(); try { const res = await chatClient.search( {members: {$in: [chatClient.user.id]}}, q, {limit: 10, offset: 0}, ); setResults(res.results.map(r => r.message)); } catch (error) { setResults([]); } setLoadingResults(false); addToRecentSearches(q); }; const startNewSearch = () => { setSearchText(''); setResults(null); setLoadingResults(false); inputRef.current?.focus(); }; useEffect(() => { const loadRecentSearches = async () => { const recent = await AsyncStore.getItem( '@slack-clone-recent-searches', [], ); setRecentSearches(recent); }; loadRecentSearches(); }, []); return ( <SafeAreaView style={[styles.safeAreaView, {backgroundColor: colors.background}]}> <View style={styles.container}> <View style={[ styles.headerContainer, {backgroundColor: colors.backgroundSecondary}, ]}> <TextInput ref={ref => { inputRef.current = ref; }} returnKeyType="search" autoFocus value={searchText} onChangeText={text => { setSearchText(text); setResults(null); }} onSubmitEditing={({nativeEvent: {text}}) => { setLoadingResults(true); search(text); }} placeholder="Search for message" placeholderTextColor={colors.text} style={[ styles.inputBox, { backgroundColor: dark ? '#363639' : '#dcdcdc', borderColor: dark ? '#212527' : '#D3D3D3', color: colors.text, }, ]} /> <TouchableOpacity style={styles.cancelButton} onPress={() => navigation.goBack()}> <SCText>Cancel</SCText> </TouchableOpacity> </View> {results && results.length > 0 && ( <View style={[ styles.resultCountContainer, {backgroundColor: colors.background, borderColor: colors.border}, ]}> <SCText>{results.length} Results</SCText> </View> )} <View style={[ styles.recentSearchesContainer, {backgroundColor: colors.background}, ]}> {!results && !loadingResults && ( <> <SCText style={[ styles.recentSearchesTitle, {backgroundColor: colors.backgroundSecondary}, ]}> Recent searches </SCText> <FlatList keyboardShouldPersistTaps="always" ItemSeparatorComponent={ListItemSeparator} data={recentSearches} renderItem={({item, index}) => ( <TouchableOpacity onPress={() => setSearchText(item)} style={styles.recentSearchItemContainer}> <SCText style={styles.recentSearchText}>{item}</SCText> <SCText onPress={() => removeFromRecentSearches(index)}> X </SCText> </TouchableOpacity> )} /> </> )} {loadingResults && ( <View style={styles.loadingIndicatorContainer}> <ActivityIndicator size="small" color="black" /> </View> )} {results && ( <View style={styles.resultsContainer}> <FlatList keyboardShouldPersistTaps="always" contentContainerStyle={{flexGrow: 1}} ListEmptyComponent={() => ( <View style={styles.listEmptyContainer}> <SCText>No results for "{searchText}"</SCText> <TouchableOpacity onPress={startNewSearch} style={styles.resetButton}> <SCText>Start a new search</SCText> </TouchableOpacity> </View> )} data={results} renderItem={({item}) => ( <TouchableOpacity onPress={() => { navigation.navigate('TargettedMessageChannelScreen', { message: item, }); }} style={[ styles.resultItemContainer, {backgroundColor: colors.background}, ]}> <SCText style={styles.resultChannelTitle}> {getChannelDisplayName(item.channel, true)} </SCText> <View style={styles.resultMessageContainer}> <SCText style={{fontWeight: '700', fontSize: 14}}> {item.user?.name} </SCText> <SCText style={{fontSize: 14, marginTop: 2}}> {item.text} </SCText> </View> </TouchableOpacity> )} /> </View> )} </View> </View> </SafeAreaView> ); }; const styles = StyleSheet.create({ safeAreaView: {flex: 1, height: '100%'}, container: {flexDirection: 'column', height: '100%'}, headerContainer: {flexDirection: 'row', width: '100%', padding: 10}, inputBox: { flex: 1, margin: 3, padding: 10, borderWidth: 0.5, borderRadius: 10, }, cancelButton: {justifyContent: 'center', padding: 5}, resultCountContainer: {padding: 15, borderBottomWidth: 0.5}, recentSearchesContainer: { marginTop: 10, marginBottom: 10, flexGrow: 1, flexShrink: 1, }, recentSearchesTitle: {padding: 5, fontSize: 13}, recentSearchItemContainer: { padding: 10, justifyContent: 'space-between', flexDirection: 'row', }, recentSearchText: {fontSize: 14}, loadingIndicatorContainer: { flexGrow: 1, flexShrink: 1, alignItems: 'center', justifyContent: 'center', }, resultsContainer: {flexGrow: 1, flexShrink: 1}, listEmptyContainer: {flex: 1, alignItems: 'center', justifyContent: 'center'}, resetButton: { padding: 15, paddingTop: 10, paddingBottom: 10, marginTop: 10, borderColor: '#696969', borderWidth: 0.5, borderRadius: 5, }, resultItemContainer: {padding: 10}, resultChannelTitle: { paddingTop: 10, paddingBottom: 10, fontWeight: '700', color: '#8b8b8b', }, resultMessageContainer: {paddingLeft: 10}, });
Next, assign the MessageSearchScreen and TargettedMessageChannelScreen components to their respective ModalStack.Screen in App.js.
12345678910111213141516171819202122232425262728const 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> ); };
Create the Jump to Channel and Channel Search Screens
We can create a standard component for the “Jump to channel” and channel search screens. These screens also use other components such as the messaging avatar and the modal screen header. Let’s add these utility components first.
Add the User Avatar
Create the avatar component in /src/components/DirectMessagingConversationAvatar.js.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182import React from 'react'; import {View, StyleSheet, Image} from 'react-native'; import {ChatClientService} from '../utils'; import {useTheme} from '@react-navigation/native'; import {PresenceIndicator} from './ChannelListItem'; export const DirectMessagingConversationAvatar = ({channel}) => { const chatClient = ChatClientService.getClient(); const {colors} = useTheme(); const otherMembers = Object.values(channel.state.members).filter( m => m.user.id !== chatClient.user.id, ); if (otherMembers.length >= 2) { return ( <View style={styles.stackedAvatarContainer}> <Image style={styles.stackedAvatarImage} source={{uri: otherMembers[0].user.image}} /> <Image style={[ styles.stackedAvatarImage, styles.stackedAvatarTopImage, {borderColor: colors.background}, ]} source={{uri: otherMembers[1].user.image}} /> </View> ); } if (otherMembers.length === 0) { return <View style={styles.avatarImage} />; } return ( <View style={styles.avatarImage}> <Image style={styles.avatarImage} source={{uri: otherMembers[0].user.image}} /> <View style={[ styles.presenceIndicatorContainer, {borderColor: colors.background}, ]}> <PresenceIndicator online={otherMembers[0].user.online} backgroundTransparent={false} /> </View> </View> ); }; const styles = StyleSheet.create({ stackedAvatarContainer: { height: 45, width: 45, marginTop: 5, }, stackedAvatarTopImage: { position: 'absolute', borderWidth: 3, bottom: 0, right: 0, }, stackedAvatarImage: { height: 31, width: 31, borderRadius: 5, }, avatarImage: {height: 45, width: 45, borderRadius: 5}, presenceIndicatorContainer: { position: 'absolute', bottom: -5, right: -10, borderWidth: 3, borderRadius: 100 / 2, }, });
The avatar component displays the other member’s picture with their presence indicator for one-to-one conversations. For group conversation, it shows stacked avatars of two of its members.
Add the Modal UI Header
This is a common header for modal screens, with a close button on the left and a title centered. You can find the implementation in /src/components/ModalScreenHeader.js.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465import React from 'react'; import {TouchableOpacity, View, StyleSheet} from 'react-native'; import {useTheme} from '@react-navigation/native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {SCText} from './SCText'; export const ModalScreenHeader = ({goBack, title, subTitle}) => { const {colors} = useTheme(); const insets = useSafeAreaInsets(); return ( <View style={[ styles.container, { backgroundColor: colors.background, paddingTop: insets.top > 0 ? 10 : 5, }, ]}> <View style={styles.leftContent}> <TouchableOpacity onPress={() => { goBack && goBack(); }}> <SCText style={styles.hamburgerIcon}>✕</SCText> </TouchableOpacity> </View> <View> <SCText style={styles.channelTitle}>{title}</SCText> {subTitle && ( <SCText style={styles.channelSubTitle}>{subTitle}</SCText> )} </View> <View style={{width: 50}} /> </View> ); }; const styles = StyleSheet.create({ container: { padding: 15, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', borderBottomWidth: 0.5, borderBottomColor: 'grey', }, leftContent: { position: 'absolute', left: 20, }, hamburgerIcon: { fontSize: 20, }, channelTitle: { textAlign: 'center', fontWeight: '900', fontSize: 17, }, channelSubTitle: { textAlign: 'center', fontWeight: '900', fontSize: 13, }, });
Now, let's create a ChannelSearchScreen, /src/screens/ChannelSearchScreen.js that can be used as both "Jump to channel screen" and "channel search".
There are two main differences between these screens. You can control them through the channelsOnly prop.
- The "Jump to channel screen" doesn't have a header.
- The channel search screen doesn't have a horizontal list of recent members of direct messaging conversations.
Also, we need to display a list of recent conversations when a user opens this modal. We can use the cached list of recent conversations in CacheService (which we populated in the ChannelList component via the useWatchedChannels hook) to avoid extra calls to the queryChannels API endpoint.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170import React, {useState} from 'react'; import { View, SafeAreaView, StyleSheet, FlatList, TextInput, TouchableOpacity, } from 'react-native'; import {useNavigation, useRoute, useTheme} from '@react-navigation/native'; import debounce from 'lodash/debounce'; import {CacheService, ChatClientService} from '../utils'; import {SCText} from '../components/SCText'; import {ChannelListItem} from '../components/ChannelListItem'; import {ModalScreenHeader} from '../components/ModalScreenHeader'; import {DirectMessagingConversationAvatar} from '../components/DirectMessagingConversationAvatar'; export const ChannelSearchScreen = () => { const {colors, dark} = useTheme(); const navigation = useNavigation(); const {params} = useRoute(); const channelsOnly = params?.channelsOnly ?? false; const chatClient = ChatClientService.getClient(); const [results, setResults] = useState(CacheService.getRecentConversations()); const [text, setText] = useState(''); const onChangeText = async newText => { setText(newText); if (!newText) { return setResults(CacheService.getRecentConversations()); } const result = await chatClient.queryChannels({ type: 'messaging', $or: [ {'member.user.name': {$autocomplete: newText}}, {name: {$autocomplete: newText}}, ], }); setResults(result); }; const onChangeTextDebounced = debounce(onChangeText, 1000, { leading: true, trailing: true, }); const renderChannelRow = channel => { return ( <ChannelListItem channel={channel} client={chatClient} key={channel.id} currentUserId={chatClient.user.id} showAvatar presenceIndicator={false} changeChannel={channelId => { navigation.navigate('ChannelScreen', {channelId}); }} /> ); }; return ( <SafeAreaView style={{backgroundColor: colors.background}}> <View> {channelsOnly && ( <ModalScreenHeader goBack={navigation.goBack} title="Channels" /> )} <View style={styles.headerContainer}> <TextInput autoFocus onChangeText={onChangeTextDebounced} value={text} placeholder="Search" placeholderTextColor={colors.text} style={[ styles.inputBox, { color: colors.text, backgroundColor: colors.background, borderColor: colors.border, borderWidth: dark ? 1 : 0.5, }, ]} /> <TouchableOpacity style={styles.cancelButton} onPress={() => navigation.goBack()}> <SCText>Cancel</SCText> </TouchableOpacity> </View> {!text && !channelsOnly && ( <View style={styles.recentMembersContainer}> <FlatList keyboardShouldPersistTaps="always" showsHorizontalScrollIndicator={false} data={CacheService.getOneToOneConversations()} renderItem={({item}) => ( <TouchableOpacity style={styles.memberContainer} onPress={() => { navigation.navigate('ChannelScreen', { channelId: item.id, }); }}> <DirectMessagingConversationAvatar channel={item} /> <SCText style={styles.memberName}> {item.data?.name || 'DM'} </SCText> </TouchableOpacity> )} horizontal /> </View> )} <View style={styles.searchResultsContainer}> <SCText style={styles.searchResultsContainerTitle}>Recent</SCText> <FlatList showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="always" data={results} renderItem={({item}) => renderChannelRow(item)} /> </View> </View> </SafeAreaView> ); }; const styles = StyleSheet.create({ headerContainer: { flexDirection: 'row', width: '100%', padding: 10, }, inputBox: { flex: 1, margin: 3, padding: 10, borderWidth: 0.5, shadowColor: '#000', shadowOffset: {width: 0, height: 1}, shadowOpacity: 0.2, shadowRadius: 1.41, elevation: 2, borderRadius: 6, }, cancelButton: {alignSelf: 'center', padding: 5}, recentMembersContainer: { borderBottomColor: 'grey', borderBottomWidth: 0.3, paddingTop: 10, paddingBottom: 10, }, memberContainer: { padding: 5, width: 70, alignItems: 'center', }, memberName: {marginTop: 5, fontSize: 10, textAlign: 'center'}, searchResultsContainer: {paddingTop: 10}, searchResultsContainerTitle: { paddingLeft: 10, fontWeight: '500', paddingBottom: 10, paddingTop: 10, }, });
Congratulations!
You’ve completed part 3, the final step of our tutorial on building a Slack clone using Stream’s Chat API with React Native.
We hope you found this tutorial helpful! Check out all the related links and our documentation on AI, moderation, push notifications, and video integration to extend the app you built in this tutorial.
