Build chat as a set of transport-neutral components. The UI should receive normalized message data and callbacks; sockets, persistence, moderation, and API clients belong outside the component tree. These recipes address common production chat problems with generic domain types.
Start With a Durable Message Contract
Keep the client-generated ID after the server assigns its canonical ID. It is the bridge between an optimistic row, an idempotency key, and the eventual server event.
export type DeliveryState = "sending" | "sent" | "failed";
export type ChatAttachment = {
id: string;
kind: "image" | "file";
url: string;
name: string;
width?: number;
height?: number;
};
export type ChatMessage = {
id: string;
clientId: string;
authorId: string;
authorName: string;
authorAvatarUrl: string | null;
body: string;
createdAt: string;
delivery: DeliveryState;
replyToId: string | null;
attachments: readonly ChatAttachment[];
};
Render only sanitized plain text or a known rich-text schema. Never render arbitrary server HTML in a WebView.
Build an Accessible Message Row
The row exposes reply and retry as visible controls. Do not make swipe or long-press the only way to reach an action.
import { memo } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Image } from "expo-image";
type MessageRowProps = {
id: string;
authorName: string;
avatarUrl: string | null;
body: string;
createdAt: string;
delivery: "sending" | "sent" | "failed";
isOwn: boolean;
onReply(id: string): void;
onRetry(id: string): void;
};
const initials = (name: string) =>
name
.split(/\s+/)
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase();
export const MessageRow = memo(function MessageRow({
id,
authorName,
avatarUrl,
body,
createdAt,
delivery,
isOwn,
onReply,
onRetry,
}: MessageRowProps) {
const time = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(new Date(createdAt));
const status = delivery === "sending" ? "Sending" : delivery === "failed" ? "Not sent" : time;
return (
<View style={[styles.row, isOwn ? styles.ownRow : null]}>
{!isOwn ? (
avatarUrl ? (
<Image
source={{ uri: avatarUrl }}
alt={`${authorName}'s profile photo`}
cachePolicy="memory-disk"
recyclingKey={`${id}:${avatarUrl}`}
style={styles.avatar}
/>
) : (
<View accessibilityLabel={authorName} accessibilityRole="image" style={styles.avatarFallback}>
<Text>{initials(authorName)}</Text>
</View>
)
) : null}
<View style={[styles.content, isOwn ? styles.ownContent : null]}>
{!isOwn ? <Text style={styles.author}>{authorName}</Text> : null}
<View style={[styles.bubble, isOwn ? styles.ownBubble : styles.otherBubble]}>
<Text selectable style={[styles.body, isOwn ? styles.ownBody : null]}>{body}</Text>
</View>
<View style={styles.metaRow}>
<Text accessibilityLiveRegion="polite" style={delivery === "failed" ? styles.error : styles.meta}>
{status}
</Text>
<Pressable accessibilityRole="button" onPress={() => onReply(id)} style={styles.smallAction}>
<Text style={styles.actionLabel}>Reply</Text>
</Pressable>
{delivery === "failed" ? (
<Pressable accessibilityLabel="Retry sending message" accessibilityRole="button" onPress={() => onRetry(id)} style={styles.smallAction}>
<Text style={styles.actionLabel}>Retry</Text>
</Pressable>
) : null}
</View>
</View>
</View>
);
});
const styles = StyleSheet.create({
row: { flexDirection: "row", alignItems: "flex-end", gap: 8, paddingHorizontal: 12, paddingVertical: 4 },
ownRow: { justifyContent: "flex-end" },
avatar: { width: 32, height: 32, borderRadius: 16 },
avatarFallback: { width: 32, height: 32, borderRadius: 16, alignItems: "center", justifyContent: "center", backgroundColor: "#E8E8ED" },
content: { maxWidth: "82%", gap: 2 },
ownContent: { alignItems: "flex-end" },
author: { fontSize: 12, fontWeight: "600", color: "#575761" },
bubble: { paddingHorizontal: 12, paddingVertical: 9, borderRadius: 18, borderCurve: "continuous" },
ownBubble: { backgroundColor: "#2F5BEA", borderBottomRightRadius: 5 },
otherBubble: { backgroundColor: "#ECECF1", borderBottomLeftRadius: 5 },
body: { fontSize: 16, lineHeight: 21, color: "#111116" },
ownBody: { color: "#FFFFFF" },
metaRow: { flexDirection: "row", alignItems: "center", gap: 4 },
meta: { fontSize: 12, color: "#6B6B75" },
error: { fontSize: 12, color: "#B42318" },
smallAction: { minWidth: 44, minHeight: 44, alignItems: "center", justifyContent: "center" },
actionLabel: { fontSize: 12, fontWeight: "600", color: "#2F5BEA" },
});
Replace the example colors with semantic tokens from the host app. If the sender bubble uses a dark brand color, provide a matching foreground token rather than assuming black text.
Add Reactions Without Tiny Targets
The visible pill can stay compact while the pressable keeps a 44-point target. Selected state must be exposed to assistive technology, not communicated by color alone.
import { Pressable, StyleSheet, Text, View } from "react-native";
type Reaction = { emoji: string; count: number; reactedByViewer: boolean };
export function ReactionBar({
reactions,
onToggle,
onAdd,
}: {
reactions: readonly Reaction[];
onToggle(emoji: string): void;
onAdd(): void;
}) {
return (
<View accessibilityRole="toolbar" style={reactionStyles.row}>
{reactions.map((reaction) => (
<Pressable
key={reaction.emoji}
accessibilityLabel={`${reaction.emoji}, ${reaction.count} reactions`}
accessibilityRole="button"
accessibilityState={{ selected: reaction.reactedByViewer }}
onPress={() => onToggle(reaction.emoji)}
style={reactionStyles.target}
>
<View style={[reactionStyles.pill, reaction.reactedByViewer ? reactionStyles.selected : null]}>
<Text>
{reaction.emoji} {reaction.count}{reaction.reactedByViewer ? " · You" : ""}
</Text>
</View>
</Pressable>
))}
<Pressable accessibilityLabel="Add a reaction" accessibilityRole="button" onPress={onAdd} style={reactionStyles.target}>
<Text style={reactionStyles.addLabel}>Add</Text>
</Pressable>
</View>
);
}
const reactionStyles = StyleSheet.create({
row: { flexDirection: "row", flexWrap: "wrap", gap: 4 },
target: { minWidth: 44, minHeight: 44, alignItems: "center", justifyContent: "center" },
pill: { borderWidth: 1, borderColor: "#D7D7DE", borderRadius: 14, borderCurve: "continuous", paddingHorizontal: 8, paddingVertical: 4 },
selected: { borderWidth: 2, borderColor: "#2F5BEA", backgroundColor: "#E9EEFF" },
addLabel: { color: "#2F5BEA", fontWeight: "600" },
});
For animated pills, a people-details sheet, and optimistic updates across timeline and detail caches, load reaction-system-recipes.
Compose, Send, and Restore Failed Drafts
Disable sending while uploads or submission are active. A failed send restores the submitted text only if the user has not already started a new draft.
import { randomUUID } from "expo-crypto";
import { useRef, useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
type SendInput = { clientId: string; text: string; replyToId: string | null };
type PendingRetry = SendInput;
export function MessageComposer({
replyToId,
uploadsPending,
onCancelReply,
onSend,
}: {
replyToId: string | null;
uploadsPending: boolean;
onCancelReply(): void;
onSend(input: SendInput): Promise<void>;
}) {
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingRetry, setPendingRetry] = useState<PendingRetry | null>(null);
const draftRevision = useRef(0);
const body = draft.trim();
const canSend = body.length > 0 && !sending && !uploadsPending;
const updateDraft = (value: string) => {
draftRevision.current += 1;
setPendingRetry(null);
setDraft(value);
};
const submit = async () => {
if (!canSend) return;
const revisionAtSubmit = draftRevision.current;
const retryMatches = pendingRetry?.text === body && pendingRetry.replyToId === replyToId;
const clientId = retryMatches ? pendingRetry.clientId : randomUUID();
setDraft("");
setError(null);
setSending(true);
try {
await onSend({ clientId, text: body, replyToId });
setPendingRetry(null);
} catch {
if (draftRevision.current === revisionAtSubmit) {
setPendingRetry({ clientId, text: body, replyToId });
setDraft(body);
}
setError("Message not sent. Check your connection and try again.");
} finally {
setSending(false);
}
};
return (
<View style={composerStyles.container}>
{replyToId ? (
<View style={composerStyles.replyRow}>
<Text numberOfLines={1} style={composerStyles.replyText}>Replying to a message</Text>
<Pressable accessibilityLabel="Cancel reply" accessibilityRole="button" onPress={onCancelReply} style={composerStyles.action}>
<Text>Cancel</Text>
</Pressable>
</View>
) : null}
<View style={composerStyles.inputRow}>
<TextInput
accessibilityLabel="Message"
maxLength={4000}
multiline
onChangeText={updateDraft}
placeholder="Message"
style={composerStyles.input}
value={draft}
/>
<Pressable
accessibilityLabel={sending ? "Sending message" : "Send message"}
accessibilityRole="button"
accessibilityState={{ busy: sending, disabled: !canSend }}
disabled={!canSend}
onPress={submit}
style={[composerStyles.send, !canSend ? composerStyles.disabled : null]}
>
<Text style={composerStyles.sendLabel}>{sending ? "Sending…" : "Send"}</Text>
</Pressable>
</View>
{error ? <Text accessibilityLiveRegion="polite" style={composerStyles.error}>{error}</Text> : null}
</View>
);
}
const composerStyles = StyleSheet.create({
container: { gap: 6, borderTopWidth: StyleSheet.hairlineWidth, borderColor: "#D7D7DE", padding: 8, backgroundColor: "#FFFFFF" },
replyRow: { minHeight: 44, flexDirection: "row", alignItems: "center", gap: 8 },
replyText: { flex: 1, color: "#575761" },
inputRow: { flexDirection: "row", alignItems: "flex-end", gap: 8 },
input: { flex: 1, minHeight: 44, maxHeight: 140, borderWidth: 1, borderColor: "#D7D7DE", borderRadius: 18, borderCurve: "continuous", paddingHorizontal: 12, paddingVertical: 10, fontSize: 16 },
action: { minHeight: 44, paddingHorizontal: 8, alignItems: "center", justifyContent: "center" },
send: { minHeight: 44, minWidth: 64, borderRadius: 18, borderCurve: "continuous", alignItems: "center", justifyContent: "center", backgroundColor: "#2F5BEA" },
disabled: { opacity: 0.45 },
sendLabel: { color: "#FFFFFF", fontWeight: "700" },
error: { color: "#B42318" },
});
Use a keyboard-aware screen container or KeyboardAvoidingView around the timeline and composer. Do not put keyboard-height calculations inside every message row.
Virtualize a Timeline That Can Prepend History
Store messages oldest-first. Start at the end, follow new messages only while the reader is near the end, and anchor the viewport when older pages prepend.
import { useCallback } from "react";
import { Text } from "react-native";
import { LegendList } from "@legendapp/list/react-native";
export function ChatTimeline({
messages,
currentUserId,
typingLabel,
loadOlder,
onReply,
onRetry,
}: {
messages: readonly ChatMessage[];
currentUserId: string;
typingLabel: string | null;
loadOlder(): void;
onReply(id: string): void;
onRetry(id: string): void;
}) {
const renderItem = useCallback(
({ item }: { item: ChatMessage }) => (
<MessageRow
id={item.id}
authorName={item.authorName}
avatarUrl={item.authorAvatarUrl}
body={item.body}
createdAt={item.createdAt}
delivery={item.delivery}
isOwn={item.authorId === currentUserId}
onReply={onReply}
onRetry={onRetry}
/>
),
[currentUserId, onReply, onRetry],
);
return (
<LegendList
accessibilityRole="list"
contentInsetAdjustmentBehavior="automatic"
data={messages}
initialScrollAtEnd
keyboardDismissMode="interactive"
keyExtractor={(item) => item.clientId}
ListEmptyComponent={<Text>Send the first message</Text>}
ListFooterComponent={typingLabel ? <Text accessibilityLiveRegion="polite">{typingLabel}</Text> : null}
maintainScrollAtEnd
maintainVisibleContentPosition={{ data: true, size: true }}
onStartReached={loadOlder}
renderItem={renderItem}
/>
);
}
For React Native, LegendList v3 imports from @legendapp/list/react-native. If a project uses FlatList instead, preserve the same ordering, anchoring, and stable callback contracts.
Offer Keyboard-Friendly Mention Suggestions
Keep suggestion state outside the rich-text editor so the same component can support a plain TextInput, native editor, or WebView-backed editor.
import { FlatList, Pressable, StyleSheet, Text } from "react-native";
export type MentionCandidate = { id: string; displayName: string; handle: string };
export function MentionSuggestions({
query,
people,
onSelect,
}: {
query: string | null;
people: readonly MentionCandidate[];
onSelect(person: MentionCandidate): void;
}) {
if (query === null) return null;
const normalized = query.toLocaleLowerCase();
const results = people
.filter((person) => `${person.displayName} ${person.handle}`.toLocaleLowerCase().includes(normalized))
.slice(0, 8);
return (
<FlatList
accessibilityLabel="Mention suggestions"
data={results}
keyboardShouldPersistTaps="handled"
keyExtractor={(person) => person.id}
ListEmptyComponent={<Text style={mentionStyles.empty}>No people found</Text>}
renderItem={({ item }) => (
<Pressable
accessibilityLabel={`Mention ${item.displayName}, at ${item.handle}`}
accessibilityRole="button"
onPress={() => onSelect(item)}
style={mentionStyles.row}
>
<Text numberOfLines={1} style={mentionStyles.name}>{item.displayName}</Text>
<Text numberOfLines={1} style={mentionStyles.handle}>@{item.handle}</Text>
</Pressable>
)}
/>
);
}
const mentionStyles = StyleSheet.create({
row: { minHeight: 48, paddingHorizontal: 12, flexDirection: "row", alignItems: "center", gap: 8 },
name: { flex: 1, fontWeight: "600" },
handle: { color: "#6B6B75" },
empty: { minHeight: 48, padding: 12, color: "#6B6B75" },
});
Insert a structured mention node or { userId, label } token into the editor. Do not use a mutable display name as the authorization identity.
For a complete TenTap and TipTap WebView composer with a typed mention bridge, validated JSON documents, and pasted-image extraction, load rich-text-composer-recipes.
Delivery and Realtime Invariants
- Send a
clientIdas an idempotency key and reconcile byclientId, not by body text. - Treat WebSocket events as hints; validate payloads and refetch gaps after reconnect.
- Throttle typing events and expire them server-side. Never persist typing as message history.
- Mark read state from viewability plus screen focus, not merely because data was fetched.
- Keep failed rows visible with Retry. Never silently discard a message.
- Put destructive actions such as Delete behind a reversible undo window when the backend allows it.
- Use push notifications to route into a conversation and message ID; fetch authoritative content after opening.
Sources
List behavior follows the current LegendList chat and feed primitives and its scroll anchoring API. Image rendering follows Expo Image. Labels, live regions, state, and custom actions follow React Native accessibility. Keyboard behavior follows KeyboardAvoidingView. All code above is independently written.