Chat SDK
React Native Chat App: DMs, Typing Indicators, and Presence with Expo and TypeScript
Direct messages, typing indicators, and presence are important aspects of any messenger. However, handling WebSocket reconnect logic, offline message queue, platform-specific keyboard bugs, and message receipt state machines were a challenge that used to take several weeks. The feature list wasn’t the tricky part – building it to work well was.
However, with 2026’s Expo SDK 52, TypeScript, and a modern React Native chat SDK it is possible to ship all four features within just one day. This tutorial will build each of these features from scratch on a clean Expo app: direct 1:1 messaging, typing indicators (the native one in the Gifted Chat library as well as a custom one using Reanimated 3, which supports Web), real-time presence events, and a channel leaving flow via long press.
In this tutorial
- What you’ll build
- 4 architectural decisions before you write code
- Setup: Expo + TypeScript + SDK
- Types first
- SDK singleton client
- Feature 1: Direct messaging channels
- Feature 2: Typing indicator
- Feature 3: Presence notifications
- Feature 4: Leave-channel dialog
- Bonus: AI assistant with SSE streaming
- 7 mistakes to avoid
- Deploy with EAS Build + OTA
- Extending with Ethora modules
What You’ll Build
- A react native chat app on Expo SDK 52+ with TypeScript and React Native New Architecture
- Direct 1:1 channels created by tapping a user’s avatar in a group thread
- Typing indicator – Gifted Chat’s native isTyping prop + a custom Reanimated 3 animated-dots version that works on Expo Web
- Real-time presence: in-app notification when a participant enters or leaves the open channel
- Long-press leave-channel with a React Native Paper confirmation dialog
- Bonus: AI assistant bubble with token-by-token streaming via SSE
- Deploy-ready with EAS Build and OTA updates
4 Architectural Decisions Before You Write Code
React Native New Architecture
New Architecture – Fabric renderer plus TurboModules – is the default in Expo SDK 52+. If you’re on an older codebase still using the bridge, migrate before adding chat. The render-performance gap on long message lists is real: FlatList on the Old Architecture will drop frames on threads above a few hundred messages, which is exactly where a React Native chat feature lives.
Expo Managed vs bare React Native
Use Expo Managed for this greenfield tutorial – it’s the fastest setup and handles native config automatically. Switch to Expo Prebuild if you need a custom native module that Expo doesn’t expose. Bare React Native is only worth the overhead if you have deep native constraints that Prebuild can’t handle; for a React Native chat app, Managed covers everything.
TypeScript is non-negotiable
Chat message shapes, user objects, and channel types change constantly during development. Without types, refactoring the message status enum from ‘sent’ | ‘read’ to ‘sending’ | ‘sent’ | ‘delivered’ | ‘read’ | ‘failed’ means hunting string literals across the codebase. With types, the compiler finds every usage instantly.
Chat SDK over raw WebSocket
Rolling your own real-time messaging means you own reconnection backoff, offline message queuing, delivery receipt state, push notification routing, and cross-platform keyboard handling – for weeks. A messaging SDK, like Ethora, handles all of this.
Setup: Expo SDK 52 + TypeScript + Chat SDK
# Create a new Expo project with TypeScript template
npx create-expo-app@latest chat-app --template blank-typescript
cd chat-app
# Core dependencies
npx expo install expo-notifications @shopify/flash-list react-native-reanimated
npx expo install react-native-gifted-chat # popular neutral UI library
npx expo install react-native-paper # for the leave-channel dialog
npm install @ethora/sdk lodash.debounce
npm install --save-dev @types/lodash.debounce
# Environment variable
echo 'EXPO_PUBLIC_ETHORA_APP_ID=your_app_id' > .envYour project structure after setup:
chat-app/
├── app/ # Expo Router
│ ├── (tabs)/
│ │ └── index.tsx # channel list
│ └── chat/
│ └── [id].tsx # chat screen
├── src/
│ ├── chat/
│ │ ├── client.ts # SDK singleton
│ │ └── types.ts # TypeScript models
│ └── components/
│ ├── TypingIndicator.tsx
│ └── LeaveDialog.tsx
├── app.json
└── .env The Expo Router file-based routing means each screen is a file – no manual navigation stack to maintain. The chat/[id].tsx pattern handles any channel by ID.
Types First – Define the Chat Schema
Define these before touching any component. Touching the SDK without types is how you end up with any spreading through state you’ll regret in three weeks.
// src/chat/types.ts
export type ChannelType = 'PUBLIC' | 'DIRECT';
export type User = {
id: string;
displayName: string;
avatarUrl?: string;
};
export type Channel = {
id: string;
type: ChannelType;
name?: string;
members: User[];
};
export type ChatMessage = {
id: string;
channelId: string;
author: User;
text: string;
createdAt: number;
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
};
export type TypingEvent = {
userId: string;
channelId: string;
isTyping: boolean;
};
export type PresenceEvent = {
user: User;
channelId: string;
event: 'entered' | 'left';
};Direct channels have no server-side name – that’s by design, not an omission. The display name is computed from the member list on the client, which you’ll see in the next section.
SDK Singleton Client
Instantiate once, reuse everywhere. The lazy singleton pattern avoids multiple SDK instances when React re-renders, which causes duplicate socket connections on hot reload.
// src/chat/client.ts
import { EthoraChat } from '@ethora/sdk';
let client: EthoraChat | null = null;
export function getClient(): EthoraChat {
if (!client) {
client = new EthoraChat({
appId: process.env.EXPO_PUBLIC_ETHORA_APP_ID!,
});
}
return client;
}
export function channelDisplayName(c: {
type: string;
name?: string;
members: { displayName: string }[];
}): string {
if (c.type === 'DIRECT') return c.members.map(m => m.displayName).join(', ');
return c.name ?? 'Channel';
}Feature 1: Direct Messaging Channels
A direct channel is a private 1:1 conversation between a fixed set of members. Two properties set it apart from a public channel: it’s created idempotently – calling create with the same member set twice returns the same channel – and members can’t be added after creation. The channel belongs to whoever is in it at creation time.
The UX trigger: user taps another participant’s avatar in a group thread. That calls openDirect, which creates (or retrieves) the direct channel and navigates to it.
// app/chat/[id].tsx
import { router } from 'expo-router';
import { getClient } from '@/src/chat/client';
async function openDirect(otherUserId: string) {
const channel = await getClient().createChannel({
type: 'DIRECT',
memberIds: [otherUserId],
});
router.push(`/chat/${channel.id}`);
}Pass this to Gifted Chat’s onPressAvatar prop – that’s the same hook the source tutorial used, and it still works in 2026.
// Screen title — computed client-side, no server name on direct channels
<Stack.Screen options={{ title: channelDisplayName(channel) }} />The idempotency is handled server-side by Ethora (and every other modern SDK) – creating a direct channel with the same two members always returns the same channel ID, so the navigation is safe to call multiple times without creating duplicates.
Feature 2: Typing Indicator
Two versions: Gifted Chat’s built-in isTyping prop, which is the fastest to wire up, and a custom Reanimated 3 component that renders on Expo Web (where Gifted Chat’s native indicator doesn’t).
Receiving typing events
// app/chat/[id].tsx
import { useState, useEffect } from 'react';
import type { User, TypingEvent } from '@/src/chat/types';
const [typingUser, setTypingUser] = useState<User | null>(null);
useEffect(() => {
const room = getClient().joinRoom(channelId);
const off = room.onTyping((evt: TypingEvent) => {
if (evt.userId === currentUser.id) return;
setTypingUser(evt.isTyping ? findUser(evt.userId) : null);
});
return () => off(); // cleanup — critical, see mistakes section
}, [channelId]);Sending typing events – debounced
Without debouncing, you emit a typing event on every keystroke – roughly 5 events per second on a fast typist, which will saturate the socket channel with noise.
import { useCallback } from 'react';
import debounce from 'lodash.debounce';
const sendTyping = useCallback(
debounce((isTyping: boolean) =>
getClient().setTyping(channelId, isTyping), 300),
[channelId],
);
// In the GiftedChat onInputTextChanged prop:
onInputTextChanged={(text) => sendTyping(text.length > 0)}Custom animated indicator with Reanimated 3
The Reanimated 3 version runs on iOS, Android, and Expo Web. Gifted Chat’s native isTyping prop doesn’t render on Web – if your Expo project targets Web at all, you need this.
// src/components/TypingIndicator.tsx
import Animated, {
useSharedValue, useAnimatedStyle,
withRepeat, withTiming,
} from 'react-native-reanimated';
import { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import type { User } from '@/src/chat/types';
export default function TypingIndicator({ user }: { user: User | null }) {
const opacity = useSharedValue(0.3);
useEffect(() => {
opacity.value = withRepeat(withTiming(1, { duration: 500 }), -1, true);
}, []);
const animStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
if (!user) return null;
return (
<View style={styles.row}>
<Animated.Text style={[styles.dots, animStyle]}>•••</Animated.Text>
<Text style={styles.label}> {user.displayName} is typing</Text>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', paddingHorizontal: 10, paddingBottom: 4 },
dots: { fontSize: 18, color: '#888' },
label: { fontSize: 13, color: '#888', alignSelf: 'flex-end' },
});Pass it to GiftedChat’s renderFooter prop:
<GiftedChat
messages={messages}
renderFooter={() => <TypingIndicator user={typingUser} />}
// ...
/>Feature 3: Presence Notifications
Presence has two distinct meanings worth separating early. Membership presence is persistent – a user is a member of the channel. Session presence is transient – a user currently has the channel open on their device. This tutorial implements session presence: an in-app notification fires when another participant opens or closes the same channel you’re currently in.
useEffect(() => {
const room = getClient().joinRoom(channelId);
const off = room.onPresence((evt: PresenceEvent) => {
if (evt.user.id === currentUser.id) return; // ignore self
sendToast(`${evt.user.displayName} ${evt.event} the chat`);
});
return () => off();
}, [channelId, currentUser.id]);sendToast can be any in-app notification – Expo Notifications for a system notification, or a lightweight toast library like react-native-toast-message for an in-screen banner. System notifications require permission; toasts don’t. For a session-level “this person just joined” signal, in-screen toasts are usually the better UX choice.
For an online status dot on avatars, track presence state in a Map:
const [onlineUsers, setOnlineUsers] = useState<Map<string, boolean>>(new Map());
// In the onPresence handler:
setOnlineUsers(prev => {
const next = new Map(prev);
next.set(evt.user.id, evt.event === 'entered');
return next;
});If you’re rendering a member list alongside the chat, swap FlatList for FlashList – it renders 500-item member lists 3-5× faster than FlatList on New Architecture, and the perf gap shows immediately on group chat member sheets.
Feature 4: Leave-Channel with Confirmation Dialog
Long-press on a channel in the list, confirm, leave. The confirmation step matters – accidental leave on a channel with DM history is the kind of bug that generates support tickets.
// app/(tabs)/index.tsx
import { Portal, Dialog, Button } from 'react-native-paper';
import { useState } from 'react';
import type { Channel } from '@/src/chat/types';
const [leaveTarget, setLeaveTarget] = useState<Channel | null>(null);
async function confirmLeave() {
if (!leaveTarget) return;
await getClient().leaveChannel(leaveTarget.id);
setLeaveTarget(null);
reloadChannels();
}
// In the FlashList renderItem:
<ChannelRow
item={item}
onLongPress={() => setLeaveTarget(item)}
/>
// Portal keeps the dialog above all other content:
<Portal>
<Dialog visible={!!leaveTarget} onDismiss={() => setLeaveTarget(null)}>
<Dialog.Title>Leave channel?</Dialog.Title>
<Dialog.Content>
{/* Show channel name so users know what they're leaving */}
</Dialog.Content>
<Dialog.Actions>
<Button onPress={() => setLeaveTarget(null)}>Cancel</Button>
<Button textColor="red" onPress={confirmLeave}>Leave</Button>
</Dialog.Actions>
</Dialog>
</Portal>The Portal from React Native Paper renders the dialog above the navigation stack, so it won’t be clipped by the FlashList or the tab bar. Without it, dialogs inside a scrollable list can get cut off at the list bounds.
Bonus: AI Assistant Messages with SSE Streaming
The source tutorial predates the LLM era. In 2026, most production in-app chat products have some form of AI assistant – a support bot, a product Q&A agent, or a personal assistant. The UX pattern users expect is token-by-token streaming: text appearing progressively rather than appearing all at once after a 5-second wait. Both the OpenAI API and Anthropic API stream this way via SSE.
With Ethora’s AI Bots SDK, the room event pattern is the same as typing and presence – subscribe, receive chunks, clean up:
// Send the user's message to trigger the bot
await getClient().sendMessage(channelId, userText);
// Subscribe to streaming bot response
const off = room.onBotStream((chunk: { text: string; done: boolean; messageId: string }) => {
setMessages(prev => appendStreamChunk(prev, chunk));
if (chunk.done) off(); // unsubscribe when stream completes
});appendStreamChunk finds the in-flight bot message by messageId and appends the new text to it. The bot bubble appears as soon as the first token arrives and grows until chunk.done is true – the same UX as ChatGPT’s streaming interface.
The BYO LLM part: swap between OpenAI, Anthropic, or a self-hosted Llama Guard 3 model by changing a server-side config value. The app-side event signature stays identical – no client code changes when you switch models.
AI bubble styling
Render AI messages with a distinct background color and a small robot/spark icon rather than a user avatar. Users need to be able to tell at a glance that a message came from a model, not a human – this is an explicit requirement under the EU AI Act for consumer-facing deployments.
7 Mistakes Worth Avoiding
We gathered common mistakes you should avoid. They fall into four categories of risk:
- Performance issues. Sluggish scrolling, dropped frames, excessive CPU or memory usage;
- Reliability issues. Duplicate messages, missing events, broken reconnections, memory leaks;
- Scalability issues. Unnecessary network traffic and server load that become more expensive as concurrent users increase.
- User experience issues. Delayed feedback, inconsistent behavior across platforms, and features that appear unreliable.
Avoiding these pitfalls ensures your chat remains fast, scalable, and consistent across devices.
FlatList on long threads
On New Architecture, FlatList drops frames on message lists above a few hundred items. FlashList from Shopify handles the same API but recycles list cells properly. The migration is a one-line import change and an estimatedItemSize prop.
Not debouncing typing events
Without debouncing, you emit roughly 5 events per second on a fast typist. At 10 concurrent users in a group chat, that’s 50 events per second saturating the channel. 300ms debounce drops this to one event per burst of keystrokes.
Missing cleanup in useEffect
Every joinRoom() call that isn’t matched by a cleanup off() call leaks a socket listener. They accumulate on every screen navigation and eventually cause duplicate events. The cleanup function in the useEffect return is not optional.
Rolling your own reconnection logic
The SDK handles reconnection backoff, message re-queuing on reconnect, and deduplication. Test the SDK’s reconnection on airplane-mode-off before you’re tempted to write your own. It almost certainly already does what you need.
Skipping optimistic UI on message send
The socket round-trip on a mobile network is ~200ms minimum. Without optimistic rendering – showing the message immediately with a ‘sending’ status and confirming when the server responds – every send feels laggy. Users notice 200ms in a messaging context.
Forgetting the Web target
If your Expo project targets Expo Web, test every chat UI component there. Gifted Chat’s built-in isTyping prop doesn’t render on Web – that’s why this tutorial includes the Reanimated 3 custom version.
Not testing on Old Architecture devices
New Architecture is default in Expo SDK 52+, but users on devices running old React Native builds may have apps that still use the bridge. If you’re shipping to an existing user base, check the bridgeless mode compatibility of every native module you add, including the chat SDK’s notification integration.
Deploy: EAS Build + OTA Updates
EAS Build handles the native binary compilation and App Store / Play Store submission. OTA updates (Expo Updates) let you push JavaScript changes without a new App Store review – which matters for a React Native chat app where you’ll be tuning typing indicator animation timing, adjusting toast duration, and fixing edge-case presence bugs in the first few weeks after launch without wanting to wait for app review on every fix.
Non-native vs native changes
OTA updates cover JavaScript changes only. Adding a new native module, changing the app.json permissions, or modifying ios/ or android/ directories requires a new EAS build and full review. Stick to Expo Managed workflow for as long as possible to maximize OTA eligibility.
Extending with Ethora Modules
The tutorial above gets you to a working chat app react native with DMs, typing, presence, and leave-channel. Here’s what you can add without rearchitecting:
- AI Bots SDK – the streaming bot pattern in the bonus section, configurable with any OpenAI-compatible LLM endpoint.
- RAG Crawler – point at your documentation or product catalog so the bot answers from your actual content rather than generating plausible-but-wrong answers.
- Voice and video calls – add a call button to the composer. WebRTC with a built-in SFU, no separate video SDK to integrate.
- Marketplace Chat SDK – for Shopify, WooCommerce, or Magento stores, wire product context into the conversation so the AI assistant can answer inventory questions.
- Chat Widget – the same SDK exposes an embeddable web widget for a marketing site alongside the mobile app.
So, if you’d like to build a chat, whether it’s a simple support chat on your website or a sophisticated corporate messenger or community platform, you can build it faster with Ethora. Reduce development time to weeks or even days instead of months. If you’d like to learn more, drop us a line.
More Articles
Chat SDK
Sep 11, 2026
Is Google Chat HIPAA Compliant? Here Is How
Is Google Chat HIPAA compliant? Only if you have a signed BAA with Google Workspace and configure it correctly. Consumer Google Chat is not.
AI SDK
Sep 8, 2026
Put Three AI Agents in a Group Chat: A Live Experiment
I put three AI agents in one chat room and gave only one of them a mission. Here is what happened, the response-gating and cooldown settings that made it work, and the multi-agent memory and heartbeat machinery underneath so you can build your own.
Try Out Ethora in Action
Experience Ethora's messaging with a dedicated demo from our CEO or start building your App right now!