Learn how to quickly integrate rich Generative AI experiences directly into Stream Chat. Learn More

React Native Chat App Expo Tutorial

Build a fully functioning messaging app using Stream’s React Native SDK component library. By the end of this tutorial, you’ll have a mobile app that you can easily customize in both behavior and style with minimal code changes.

example of react chat sdk

In this tutorial, we will use Stream's Expo SDK to add chat capabilities to an Expo-based React Native application.

This tutorial works with the following versions:

The current Stream Chat Expo SDK supports the React Native New Architecture only. If your app still uses the old architecture, migrate that first before integrating the SDK.

You can set up Expo on your machine and create a new project. We also have a sample project available if you want to compare your setup against a working app.

Create an App And Install Dependencies

To get started, create a new application with the Expo CLI:

bash
1
2
3
4
5
# Initialize the app npx create-expo-app MyStreamChatApp --template blank # Navigate to the app directory cd MyStreamChatApp

Install the Stream Chat Expo SDK:

bash
1
npx expo install stream-chat-expo

The SDK already includes the low-level stream-chat client, so you do not need to install stream-chat separately.

Stream Chat requires a set of peer dependencies for the core React Native UI experience. Follow the installation steps for each dependency to ensure everything is configured correctly.

Install the required peer dependencies:

bash
1
npx expo install @react-native-community/netinfo expo-image-manipulator react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-svg react-native-teleport react-native-worklets -- --force

Application Level Setup

The most important steps to get started are:

js
1
2
3
4
5
6
7
module.exports = { // other config plugins: [ // other plugins "react-native-worklets/plugin", ], };

If you are using react-native-reanimated version >=4.3.0, add the following reanimated config to your package.json as well:

package.json (json)
1
2
3
4
5
6
7
{ "reanimated": { "staticFeatureFlags": { "FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false } } }

The FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS flag changes how Reanimated commits animated styles and the order in which those updates are applied. At the moment, that behavior can conflict with the SDK, so it is safer to keep it disabled.

  • Import react-native-gesture-handler at the top of your app entry. With Expo Router, we will do that in app/_layout.tsx later in the tutorial.

  • Wrap your app root with both SafeAreaProvider and GestureHandlerRootView. We will also do that in app/_layout.tsx.

If you enable optional features such as audio recording, camera access, or media library access, make sure you also add the required platform permissions for those features.

Let's start our up our client first:

bash
1
npx expo start --dev-client

Note: stream-chat-expo does not support Expo Go.

Now you should be able to run the app:

iOS
Android
bash
1
npx expo run ios

Setup type system

If you are using TypeScript (which we highly recommend you do), declare the SDK interfaces in your app so Stream's extended types resolve correctly. The module declaration needs to take place so that types are properly resolved within your application. You can read more about this in the TypeScript guide.

To do this, you can create a new TypeScript declaration file and add the following code to it:

custom-types.d.ts (typescript)
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
import { DefaultAttachmentData, DefaultChannelData, DefaultCommandData, DefaultEventData, DefaultMemberData, DefaultMessageData, DefaultPollData, DefaultPollOptionData, DefaultReactionData, DefaultThreadData, DefaultUserData, } from "stream-chat-expo"; declare module "stream-chat" { /* eslint-disable @typescript-eslint/no-empty-object-type */ interface CustomAttachmentData extends DefaultAttachmentData {} interface CustomChannelData extends DefaultChannelData {} interface CustomCommandData extends DefaultCommandData {} interface CustomEventData extends DefaultEventData {} interface CustomMemberData extends DefaultMemberData {} interface CustomUserData extends DefaultUserData {} interface CustomMessageData extends DefaultMessageData {} interface CustomPollOptionData extends DefaultPollOptionData {} interface CustomPollData extends DefaultPollData {} interface CustomReactionData extends DefaultReactionData {} interface CustomThreadData extends DefaultThreadData {} /* eslint-enable @typescript-eslint/no-empty-object-type */ }

This will make sure that all of the SDK interfaces are properly resolved and the types are correct. Additionally, this will allow you to declare your own custom data if your integration requires it.

Setup a Basic Navigation Stack

The Stream Chat SDK does not handle navigation, but Expo Router makes it easy to set up the channel list, channel, and thread screens we need for the application.

Install the following packages to get started with Expo Router, as mentioned in their documentation.

bash
1
npx expo install expo-router react-native-screens expo-linking expo-constants expo-status-bar -- --force

After this step, follow the Expo Router installation guide to make sure the entry point, scheme, and Babel configuration are set up correctly for your app.

In particular, make sure your package.json includes the Expo Router entry point:

package.json (json)
1
2
3
{ "main": "expo-router/entry" }

We'll set up a simple stack to hold the necessary screens for navigation in our app, and start with a basic HomeScreen, which we will replace later with chat related screens.

You can copy-paste the following code into the app/_layout.tsx file:

app/_layout.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import "react-native-gesture-handler"; import { Stack } from "expo-router"; import { StyleSheet } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { SafeAreaProvider } from "react-native-safe-area-context"; export default function RootLayout() { return ( <SafeAreaProvider> <GestureHandlerRootView style={styles.container}> <Stack /> </GestureHandlerRootView> </SafeAreaProvider> ); } const styles = StyleSheet.create({ container: { flex: 1, }, });

Then create a basic HomeScreen in app/index.tsx:

app/index.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { StatusBar } from "expo-status-bar"; import { StyleSheet, Text, View } from "react-native"; export default function HomeScreen() { return ( <View style={styles.centered}> <Text>Home Screen</Text> <StatusBar style="auto" /> </View> ); } const styles = StyleSheet.create({ centered: { alignItems: "center", flex: 1, justifyContent: "center", }, });

Add Stream Chat to the Application

Before rendering any chat UI, create a StreamChat client and connect the current user. The easiest way to do that in an Expo app is with the useCreateChatClient hook.

You can find your apiKey in the Stream dashboard. For local testing you can generate a development token for a specific user ID using the token documentation here.

Create a small config file:

chatConfig.ts (js)
1
2
3
4
export const chatApiKey = "REPLACE_WITH_API_KEY"; export const chatUserId = "REPLACE_WITH_USER_ID"; export const chatUserName = "REPLACE_WITH_USER_NAME"; export const chatUserToken = "REPLACE_WITH_USER_TOKEN";

For production apps, generate user tokens on your backend and return them to the client instead of hardcoding them in the app.

Now create a small wrapper component that creates the client before rendering the app content:

components/ChatWrapper.tsx (tsx)
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
import React from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useCreateChatClient } from "stream-chat-expo"; import { chatApiKey, chatUserId, chatUserName, chatUserToken, } from "../chatConfig"; const user = { id: chatUserId, name: chatUserName, }; export const ChatWrapper = ({ children }) => { const chatClient = useCreateChatClient({ apiKey: chatApiKey, userData: user, tokenOrProvider: chatUserToken, }); if (!chatClient) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return <>{children}</>; };

Note: Make sure you use the useCreateChatClient hook only once per application. If you need the client instance somewhere down in the component tree, use the useChatContext hook exported by stream-chat-expo to access it.

Creating the App Context

Ideally, a context should store the current Channel and Thread selected by the user while moving through the app.

Create contexts/AppContext.tsx:

contexts/AppContext.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React, { useState } from "react"; export const AppContext = React.createContext({ channel: null, setChannel: (channel) => {}, thread: null, setThread: (thread) => {}, }); export const AppProvider = ({ children }) => { const [channel, setChannel] = useState(null); const [thread, setThread] = useState(null); return ( <AppContext.Provider value={{ channel, setChannel, thread, setThread }}> {children} </AppContext.Provider> ); }; export const useAppContext = () => React.useContext(AppContext);

To use the context, update app/_layout.tsx so it wraps the stack with both ChatWrapper and AppProvider:

app/_layout.tsx (tsx)
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
import "react-native-gesture-handler"; import { Stack } from "expo-router"; import { StyleSheet } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { ChatWrapper } from "../components/ChatWrapper"; import { AppProvider } from "../contexts/AppContext"; export default function RootLayout() { return ( <SafeAreaProvider> <GestureHandlerRootView style={styles.container}> <ChatWrapper> <AppProvider> <Stack /> </AppProvider> </ChatWrapper> </GestureHandlerRootView> </SafeAreaProvider> ); } const styles = StyleSheet.create({ container: { flex: 1, }, });

Configure the OverlayProvider Component

The OverlayProvider is the highest level of the Stream Chat components and must be used near the root of your application, below SafeAreaProvider.

The OverlayProvider allows users to open the full-screen image viewer and message context menu as overlays on top of the rest of the application. You can go through the available props here.

Update ChatWrapper so it wraps the app content in OverlayProvider:

components/ChatWrapper.tsx (tsx)
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
import React from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { OverlayProvider, useCreateChatClient } from "stream-chat-expo"; import { chatApiKey, chatUserId, chatUserName, chatUserToken, } from "../chatConfig"; const user = { id: chatUserId, name: chatUserName, }; export const ChatWrapper = ({ children }) => { const chatClient = useCreateChatClient({ apiKey: chatApiKey, userData: user, tokenOrProvider: chatUserToken, }); if (!chatClient) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return <OverlayProvider>{children}</OverlayProvider>; };

If you see some errors at this point, please refer to our troubleshooting guide.

Configure the Chat Component

The Chat component provides the chat client, connection state, translations, and theme to the rest of the SDK.

Update ChatWrapper again so it wraps the app content with Chat inside OverlayProvider:

components/ChatWrapper.tsx (tsx)
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
import React from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Chat, OverlayProvider, useCreateChatClient, } from "stream-chat-expo"; import { chatApiKey, chatUserId, chatUserName, chatUserToken, } from "../chatConfig"; const user = { id: chatUserId, name: chatUserName, }; export const ChatWrapper = ({ children }) => { const chatClient = useCreateChatClient({ apiKey: chatApiKey, userData: user, tokenOrProvider: chatUserToken, }); if (!chatClient) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return ( <OverlayProvider> <Chat client={chatClient}>{children}</Chat> </OverlayProvider> ); };

Configure the Channel List Component

The ChannelList component queries channels for the connected user and renders them in a FlatList.

Before configuring this component, let's set up a screen for the channel list by replacing HomeScreen in app/index.tsx with ChannelListScreen.

app/index.tsx (tsx)
1
2
3
4
5
6
7
8
9
import { Stack } from "expo-router"; export default function ChannelListScreen() { return ( <> <Stack.Screen options={{ title: "Channels" }} /> </> ); }

Now we can render the ChannelList component within ChannelListScreen.

The ChannelList can be used with no props and will return all channels to which the set user has access. In practical applications, you will probably want to show only the channels that the current user is a member of.

For such filtering purposes, you can provide a filters prop to ChannelList, which will filter the channels.

If your app does not have any channels yet, create one in the dashboard or with Chat Explorer.

Additionally, the ChannelList component takes sort props to sort the channels and options props to provide additional query options. Please check out Querying Channels in our documentation for more information and various use cases of filters, sort, and options.

app/index.tsx (tsx)
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
import { Stack } from "expo-router"; import { ChannelList } from "stream-chat-expo"; import { chatUserId } from "../chatConfig"; const filters = { members: { $in: [chatUserId], }, type: "messaging", }; const sort = { last_message_at: -1, }; const options = { limit: 20, presence: true, state: true, watch: true, }; export default function ChannelListScreen() { return ( <> <Stack.Screen options={{ title: "Channels" }} /> <ChannelList filters={filters} options={options} sort={sort} /> </> ); }

You can add the press handler for the list item within the ChannelList component using the onSelect prop. This is where you can add the logic for navigating to the channel screen, where we will render the message list and composer.

Let's implement the basic ChannelScreen component and logic for navigating from ChannelList to ChannelScreen.

Create a new route file at app/channel/[cid].tsx:

app/channel/[cid].tsx (tsx)
1
2
3
4
5
6
7
8
9
import { Stack } from "expo-router"; export default function ChannelScreen() { return ( <> <Stack.Screen options={{ title: "Channel" }} /> </> ); }

Then update ChannelListScreen so it stores the selected channel and navigates to the route:

app/index.tsx (tsx)
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
import { Stack, useRouter } from "expo-router"; import { ChannelList } from "stream-chat-expo"; import { chatUserId } from "../chatConfig"; import { useAppContext } from "../contexts/AppContext"; const filters = { members: { $in: [chatUserId], }, type: "messaging", }; const sort = { last_message_at: -1, }; const options = { limit: 20, presence: true, state: true, watch: true, }; export default function ChannelListScreen() { const router = useRouter(); const { setChannel } = useAppContext(); return ( <> <Stack.Screen options={{ title: "Channels" }} /> <ChannelList filters={filters} options={options} sort={sort} onSelect={(channel) => { setChannel(channel); router.push({ pathname: "/channel/[cid]", params: { cid: channel.cid }, }); }} /> </> ); }

Configure the Channel Component

The channel screen will comprise three main components:

  • MessageList component used to render the list of messages sent in a channel.
  • MessageComposer component used to render the input box needed to send messages, images, files, and commands to a channel.
  • Channel component that holds all data related to a channel. It also acts as a bridge between the MessageList and MessageComposer components.

The Channel component takes the channel as a prop. The MessageList and MessageComposer components don't need any props to be set, and we'll use the defaults set for these components.

app/channel/[cid].tsx (tsx)
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
import { useRef } from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Stack } from "expo-router"; import { useHeaderHeight } from "@react-navigation/elements"; import { Channel, MessageComposer, MessageList, } from "stream-chat-expo"; import { useAppContext } from "../../contexts/AppContext"; export default function ChannelScreen() { const { channel } = useAppContext(); const headerHeight = useHeaderHeight(); const headerHeightRef = useRef(headerHeight); if (!channel) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return ( <> <Stack.Screen options={{ title: "Channel" }} /> <Channel channel={channel} keyboardVerticalOffset={headerHeightRef.current} > <MessageList /> <MessageComposer /> </Channel> </> ); }

At this point, you can open a channel, send messages, upload files and images, long press a message to open the message menu, and use the built-in composer UI.

The Threads feature is similar to Slack's, which allows you to start a conversation about a particular message in a message list.

Let's first set up a separate screen for the thread within our navigation stack.

Create a new route file at app/channel/[cid]/thread/[messageId].tsx:

app/channel/[cid]/thread/[messageId].tsx (tsx)
1
2
3
4
5
6
7
8
9
import { Stack } from "expo-router"; export default function ThreadScreen() { return ( <> <Stack.Screen options={{ title: "Thread" }} /> </> ); }

As explained in the previous section, when a user long presses a message, it opens an overlay where the user can add a reaction and see many actions for the message.

MessageList accepts an onThreadSelect prop, which gets called when a user selects the "Thread Reply" action on the message overlay.

Update ChannelScreen so it stores the selected thread and navigates to ThreadScreen:

app/channel/[cid].tsx (tsx)
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
import { useRef } from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Stack, useRouter } from "expo-router"; import { useHeaderHeight } from "@react-navigation/elements"; import { Channel, MessageComposer, MessageList, } from "stream-chat-expo"; import { useAppContext } from "../../contexts/AppContext"; export default function ChannelScreen() { const router = useRouter(); const { channel, thread, setThread } = useAppContext(); const headerHeight = useHeaderHeight(); const headerHeightRef = useRef(headerHeight); if (!channel) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return ( <> <Stack.Screen options={{ title: "Channel" }} /> <Channel channel={channel} keyboardVerticalOffset={headerHeightRef.current} thread={thread} > <MessageList onThreadSelect={(message) => { setThread(message); router.push({ pathname: "/channel/[cid]/thread/[messageId]", params: { cid: channel.cid, messageId: message.id, }, }); }} /> <MessageComposer /> </Channel> </> ); }

You can now long press a message and select the "Thread Reply" action to open the thread screen, which we will configure in the next step.

Configure the Threads Screen

The Thread component must be rendered inside Channel, with the current thread set on the thread prop and threadList enabled.

This way, the Channel component is aware that it is being rendered within a thread screen and can avoid concurrency issues.

app/channel/[cid]/thread/[messageId].tsx (tsx)
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
import { useRef } from "react"; import { Text } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Stack } from "expo-router"; import { useHeaderHeight } from "@react-navigation/elements"; import { Channel, Thread } from "stream-chat-expo"; import { useAppContext } from "../../../../contexts/AppContext"; export default function ThreadScreen() { const { channel, thread, setThread } = useAppContext(); const headerHeight = useHeaderHeight(); const headerHeightRef = useRef(headerHeight); if (!channel || !thread) { return ( <SafeAreaView> <Text>Loading chat ...</Text> </SafeAreaView> ); } return ( <> <Stack.Screen options={{ title: "Thread" }} /> <Channel channel={channel} keyboardVerticalOffset={headerHeightRef.current} thread={thread} threadList > <Thread onThreadDismount={() => setThread(null)} /> </Channel> </> ); }

Thread renders its own message list and composer automatically, so you do not need to place MessageList and MessageComposer inside it yourself.

So far, we've used the built-in features to add chat capabilities to an Expo application.

You can find the full list of optional features in the installation guide.

How To Customize the Chat UI

We have concluded the basic setup of chat within the application.

Every application has different UI and UX requirements, and the default designs are not always suitable for your application. Stream's React Native Chat is designed to be flexible and easily customizable.

Overriding Components with WithComponents

The SDK ships with sensible defaults for every UI element. To swap any component with your own, wrap the relevant part of the tree with WithComponents and pass an overrides object:

tsx
1
2
3
4
5
6
7
8
import { WithComponents } from "stream-chat-expo"; <WithComponents overrides={{ MessageItemView: MyCustomMessage }}> <Channel channel={channel}> <MessageList /> <MessageComposer /> </Channel> </WithComponents>

WithComponents supports nesting - inner overrides merge over outer ones, so the closest provider wins. This lets you set app-wide defaults near the root and narrow overrides deeper in the tree (e.g. different components inside a thread screen).

Inside your custom components, use useComponentsContext() to read any other overridable component, and the various data hooks to access runtime state:

Context or StateHook

Provided By

UI component overrides

useComponentsContextWithComponents

Attachment picker state

useAttachmentPickerContextChannel
Channel stateuseChannelContextChannel

Channel list state

useChannelsContextChannelList

Per-message state

useMessageContext

message row components

Shared message rendering state

useMessagesContextChannel

Theme and semantic tokens

useTheme

OverlayProvider / Chat

Message composer state

useMessageComposerChannel

You can find a broader list of contexts and hooks in the contexts documentation.

In the next section, we will cover the context usage where we customize the message list.

You can also style the default components by simply providing a theme object containing custom styles.

We have demonstrated the power of Stream Chat React Native SDK by building open-source clones of some popular chat applications such as WhatsApp, Slack, and iMessage. The source code for all these projects is available in the react-native-samples repository.

In the following sections, we will use examples covering the basics of customizations and theming.

Customize the Channel List

The ChannelList is essentially a FlatList of channels.

To customize the channel list item, override the ChannelPreview component using WithComponents. The default is ChannelPreviewView.

Objective: Highlight unread channels while keeping the default preview UI

Let's start by creating a custom list item component, which returns the default UI component ChannelPreviewView.

app/index.tsx (tsx)
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
import { Stack, useRouter } from "expo-router"; import { ChannelList, ChannelPreviewView, WithComponents, } from "stream-chat-expo"; import { chatUserId } from "../chatConfig"; import { useAppContext } from "../contexts/AppContext"; const filters = { members: { $in: [chatUserId], }, type: "messaging", }; const sort = { last_message_at: -1, }; const options = { limit: 20, presence: true, state: true, watch: true, }; const CustomListItem = (props) => { return <ChannelPreviewView {...props} />; }; export default function ChannelListScreen() { const router = useRouter(); const { setChannel } = useAppContext(); return ( <> <Stack.Screen options={{ title: "Channels" }} /> <WithComponents overrides={{ ChannelPreview: CustomListItem }}> <ChannelList filters={filters} options={options} sort={sort} onSelect={(channel) => { setChannel(channel); router.push({ pathname: "/channel/[cid]", params: { cid: channel.cid }, }); }} /> </WithComponents> </> ); }

The unread count on channels can be accessed via the unread prop. We will use this count to conditionally add a light blue background for unread channels.

app/index.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { StyleSheet, View } from "react-native"; // ...rest of the code const CustomListItem = (props) => { const { unread } = props; const containerStyle = unread ? styles.unreadContainer : styles.previewContainer; return ( <View style={containerStyle}> <ChannelPreviewView {...props} /> </View> ); }; const styles = StyleSheet.create({ previewContainer: { backgroundColor: "#fff", }, unreadContainer: { backgroundColor: "#e6f7ff", }, });

You won't see any background color change for the unread channels yet, since the ChannelPreviewView has a white background by default.

To make the wrapped view background visible, update the OverlayProvider theme in ChatWrapper:

components/ChatWrapper.tsx (tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ...rest of the code const chatTheme = { channelPreview: { container: { backgroundColor: "transparent", }, }, }; export const ChatWrapper = ({ children }) => { // ...existing chatClient logic return ( <OverlayProvider value={{ style: chatTheme }}> <Chat client={chatClient}>{children}</Chat> </OverlayProvider> ); };

Similarly, along with customizing the entire list item component, you can also override individual components within the list item, for example, ChannelPreviewStatus, ChannelPreviewAvatar, ChannelPreviewMessage, and ChannelPreviewUnreadCount, by adding them to the overrides object. You can use the visual guide to find out which components you can customize.

Customize the Message List

All components within the MessageList and MessageComposer can be customized using WithComponents.

Objective: Replace the Default Message UI with a Custom Component

The most common use case of customizing the MessageList is to have a custom UI for the message. You can do so by overriding MessageItemView via WithComponents as shown below.

app/channel/[cid].tsx (tsx)
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
import { WithComponents } from "stream-chat-expo"; // ...rest of the code const CustomMessage = () => { return null; }; export default function ChannelScreen() { // ...rest of the code const headerHeightRef = useRef(headerHeight); return ( <> <Stack.Screen options={{ title: "Channel" }} /> <WithComponents overrides={{ MessageItemView: CustomMessage }}> <Channel channel={channel} keyboardVerticalOffset={headerHeightRef.current} thread={thread} > <MessageList onThreadSelect={(message) => { setThread(message); router.push({ pathname: "/channel/[cid]/thread/[messageId]", params: { cid: channel.cid, messageId: message.id, }, }); }} /> <MessageComposer /> </Channel> </WithComponents> </> ); }

Now that we have configured the component, let's render the message on the UI. You can access the message object from the MessageContext. The MessageContext also gives you access to a boolean isMyMessage which you can use to style the message UI conditionally.

You can also access plenty of other useful properties and callbacks from this context, such as setQuotedMessage, handleReaction, and onLongPress. Please check the MessageContext documentation for the full list.

app/channel/[cid].tsx (tsx)
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
import { Pressable, StyleSheet, Text } from "react-native"; import { useMessageContext } from "stream-chat-expo"; // ...rest of the code const CustomMessage = () => { const { contextMenuAnchorRef, isMyMessage, message, onLongPress } = useMessageContext(); return ( <Pressable onLongPress={onLongPress} ref={contextMenuAnchorRef} style={isMyMessage ? styles.myMessage : styles.message} > <Text>{message.text}</Text> </Pressable> ); }; const styles = StyleSheet.create({ message: { alignSelf: "flex-start", backgroundColor: "#ededed", borderRadius: 10, margin: 10, padding: 10, width: "70%", }, myMessage: { alignSelf: "flex-end", backgroundColor: "#ADD8E6", borderRadius: 10, margin: 10, padding: 10, width: "70%", }, });

This is a really simplified version of a custom message UI that displays only text. You can obviously add functionalities such as onPress, onLongPress handlers, and message actions according to your needs.

Generally, you wouldn't need to customize the entire message UI, but only the required parts such as MessageStatus, MessageAuthor, and MessageTimestamp.

For this purpose, you can check the component customization guide to decide which key to pass in the overrides object. You can access MessageContext at every message-level component.

Also, we would recommend you to check the following guides for more advanced customizations:

Conclusion

That concludes the customization section for the Expo Chat SDK. You now have a good overview of how to do a basic setup around the chat components, and customize them to match your design and UX requirements. We have covered only the basic things, but the possibilities are endless.

Final Thoughts

In this chat app tutorial, we built a fully functioning Expo messaging app with our SDK's component library. We also showed how easy it is to customize behavior and styles of the chat app components with minimal code changes.

Both the chat SDK for React Native and the API have plenty of features available to support more advanced use-cases such as push notifications, content moderation, rich messages, and more. Please check out our React Native tutorial and iOS tutorial too. To get some inspiration for your app, download our free chat interface UI kit.

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding for free

No credit card required.
If you're interested in a custom plan or have any questions, please contact us.