Build low-latency Vision AI applications using our new open-source Vision AI SDK. ⭐️ on GitHub

Tutorial: How to Build a Slack Clone with React Native – Part 3

Threads and search are what turn chat from a stream of messages into something navigable, focused, and truly usable.

Vishal N.
Vishal N.
Published November 17, 2020 Updated March 3, 2026

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:

Create the Threads Screen

Threads Screen
  • The MessageList component of the project accepts the prop function onThreadSelect, which is attached to the onPress handler for reply count text below the message bubble. If you check our /src/screens/ChannelScreen.js component, you will see navigation logic to the src/screens/ThreadScreen.js added to the onThreadSelect prop on the MesaageList component.
  • The Thread is provided out-of-the-box from stream-chat-react-native. If you look at the source code, it's a set of Message (parent message bubble), MessageList, and a MessageInput component. You can customize these underlying components using the props, additionalParentMessageProps, additionalMessageListProps, and additionalMessageInputProps. We can use this thread component easily for our purpose.
js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import 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.

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 search endpoint 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 the ChannelScreen, 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.
js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
import 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.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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> ); };
Get started! Activate your free Stream account today and start prototyping your chat app.

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

User Avatar

Create the avatar component in /src/components/DirectMessagingConversationAvatar.js.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import 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.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import 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.

  1. The "Jump to channel screen" doesn't have a header.
  2. 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.

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
import 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!

Slack clone UIs

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.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more