React Native AI Chat UI: Streaming and Keyboard
By Native Animation · Updated
A React Native AI chat UI needs to remain readable while a reply grows, the keyboard changes the viewport, and the reader scrolls into history. This tutorial handles that presentation boundary with a simulated chunk source, an inline cursor, a Stop action, and a conditional scroll anchor. No backend or model is included. It is not a replacement for a complete chat SDK with accounts, delivery, storage, moderation, attachments, and synchronization.
Separate transport from presentation
The demo emits four local chunks with abortable delays. It does not call a model, open a network stream, or require an API key. In your application, replace demoChunks with an adapter that emits decoded text strings. Keep credentials and model access on your backend.
Network byte chunks are not necessarily complete Unicode characters, events, JSON records, words, or markdown blocks. A transport adapter must buffer and decode its actual protocol before the UI appends text. Treat server text as untrusted content. This example renders plain Text, not HTML, executable code, or a markdown parser.
Setup
Target versions: Expo SDK 58, RN 0.88.0, Reanimated 4.7.1, and Worklets 0.13.0. Follow SDK 58 Reanimated setup and install compatible packages:
npx expo install react-native-reanimated react-native-workletsUse bunx in a Bun project. Save this file outside src/app and render it inside a bounded, safe-area screen. Pressable handles discrete actions; no Gesture Handler wrapper is needed. The host must supply keyboardVerticalOffset for its header arrangement instead of assuming the tutorial's zero offset is right in every screen.
Complete local streaming UI
import type { ComponentRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import Animated, { cancelAnimation, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
type Message = { id: string; text: string; author: 'You' | 'Assistant' };
// Local chunks only: no model, transport, authentication, or backend.
async function* demoChunks(signal: AbortSignal) {
for (const chunk of ['Start with ', 'one clear task. ', 'Then add feedback ', 'and a way to cancel.']) {
await new Promise<void>((resolve, reject) => {
const stop = () => { clearTimeout(timer); reject(new Error('Canceled')); };
const timer = setTimeout(() => { signal.removeEventListener('abort', stop); resolve(); }, 300);
if (signal.aborted) stop();
else signal.addEventListener('abort', stop, { once: true });
});
if (signal.aborted) return;
yield chunk;
}
}
function Cursor({ active }: { active: boolean }) {
const opacity = useSharedValue(1);
const reduced = useReducedMotion();
useEffect(() => {
opacity.set(active && !reduced ? withRepeat(withTiming(0.25, { duration: 500 }), -1, true) : 1);
return () => cancelAnimation(opacity);
}, [active, opacity, reduced]);
const style = useAnimatedStyle(() => ({ opacity: opacity.get() }));
return active ? <Animated.Text accessible={false} style={style}>▍</Animated.Text> : null;
}
export default function StreamingChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState('Local streaming demo.');
const [pending, setPending] = useState(false);
const list = useRef<ComponentRef<typeof ScrollView>>(null);
const anchored = useRef(true);
const request = useRef<AbortController | null>(null);
const sequence = useRef(0);
useEffect(() => () => { request.current?.abort(); request.current = null; }, []);
function latest() {
anchored.current = true;
setPending(false);
list.current?.scrollToEnd({ animated: false });
}
function stop() {
request.current?.abort();
request.current = null;
setBusy(false);
setStatus('Reply stopped. Partial text kept.');
}
async function send() {
const prompt = draft.trim();
if (!prompt || request.current) return;
const controller = new AbortController();
request.current = controller;
const id = String(++sequence.current);
setMessages((current) => [...current,
{ id: id + '-user', author: 'You', text: prompt },
{ id, author: 'Assistant', text: '' }]);
setDraft('');
setBusy(true);
setStatus('Receiving reply…');
// Sending is an explicit request to follow the new conversation tail.
anchored.current = true;
setPending(false);
let text = '';
try {
for await (const chunk of demoChunks(controller.signal)) {
if (request.current !== controller || controller.signal.aborted) return;
text += chunk;
const snapshot = text;
setMessages((current) => current.map((message) => message.id === id ? { ...message, text: snapshot } : message));
}
if (request.current === controller) setStatus('Reply complete.');
} catch {
if (request.current === controller && !controller.signal.aborted) setStatus('Reply interrupted. Partial text kept; send again to retry.');
} finally {
if (request.current === controller) { request.current = null; setBusy(false); }
}
}
return (
<KeyboardAvoidingView style={styles.root} behavior={Platform.OS === 'ios' ? 'padding' : Platform.OS === 'android' ? 'height' : undefined}
keyboardVerticalOffset={0}>
<Text accessibilityLiveRegion="polite">{status}</Text>
<ScrollView ref={list} style={{ flex: 1 }} contentContainerStyle={{ paddingVertical: 16, gap: 16 }}
keyboardShouldPersistTaps="handled" scrollEventThrottle={16}
onScroll={(event) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
anchored.current = contentSize.height - layoutMeasurement.height - contentOffset.y <= 48;
if (anchored.current) setPending(false);
}}
onLayout={() => { if (anchored.current) list.current?.scrollToEnd({ animated: false }); }}
onContentSizeChange={() => {
if (anchored.current) list.current?.scrollToEnd({ animated: false });
else setPending(true);
}}>
{messages.map((message, index) => (
<View key={message.id}>
<Text>{message.author}</Text>
<Text selectable>{message.text || 'Waiting for text…'}
<Cursor active={busy && index === messages.length - 1 && message.author === 'Assistant'} />
</Text>
</View>
))}
</ScrollView>
{pending && <Pressable accessibilityRole="button" accessibilityLabel="Show latest messages" onPress={latest} style={styles.button}><Text>Latest messages</Text></Pressable>}
<TextInput accessibilityLabel="Message" placeholder="Write a message" value={draft} onChangeText={setDraft}
multiline style={styles.input} />
<View style={styles.actions}>
<Pressable accessibilityRole="button" accessibilityLabel="Send message" accessibilityState={{ disabled: busy || !draft.trim() }}
disabled={busy || !draft.trim()} onPress={() => void send()} style={styles.button}><Text>Send</Text></Pressable>
<Pressable accessibilityRole="button" accessibilityLabel="Stop reply" accessibilityState={{ disabled: !busy }}
disabled={!busy} onPress={stop} style={styles.button}><Text>Stop</Text></Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, padding: 16, gap: 8, backgroundColor: '#FFFFFF' },
input: { minHeight: 48, maxHeight: 120, padding: 12, borderWidth: 1, borderRadius: 12, color: '#24163D' },
actions: { flexDirection: 'row', gap: 12 },
button: { minHeight: 48, minWidth: 48, padding: 12, justifyContent: 'center', borderWidth: 1, borderRadius: 12 },
});Chunk state, stopping, and obsolete responses
One message ID identifies the assistant reply from its empty state through completion. Each chunk appends to an accumulated string and replaces that message by ID. A new request gets a fresh AbortController identity. Stop aborts the active source, clears its identity, preserves the partial text, and makes Send available again. Old chunks, errors, and finally blocks cannot change a newer reply.
A transport that ignores AbortSignal may continue consuming resources, even though the identity checks keep its late output offscreen. Close its reader or subscription explicitly in the adapter. Also filter obsolete callbacks before updating external caches or stores. Canceling the cursor animation alone does not stop generation.
For fast streams, batch decoded chunks into periodic UI updates instead of calling setMessages once for every network fragment. This small example has four chunks and a short history; it deliberately uses a ScrollView. A long production conversation needs virtualization, stable row identities, bounded parsing work, and a separate prepend/history-position policy. It is not appropriate to repeatedly render thousands of messages through this sample.
Keep the cursor with the latest text
The cursor is nested in the same Text flow as the current response, so wrapping keeps it after the latest characters. It is decorative and inaccessible. It disappears when the reply finishes or stops. Reduced Motion leaves a still cursor rather than a blinking one; the words themselves never depend on a reveal animation to become readable.
Only the status label uses a polite live region. Do not announce the whole response on every chunk or make the cursor a live region. Offer an explicit Read reply action or a completion announcement when that fits your assistive-technology policy. Keep selectable full text available after completion. useReducedMotion reads startup preference; use AccessibilityInfo for live preference updates.
Scroll anchoring and keyboard avoidance
The list follows its tail only while the reader is within forty-eight points of the bottom. Scrolling upward turns that policy off. A growing reply then shows Latest messages without forcing descent. That explicit action re-enables anchoring; sending a new message also opts into following the tail. Content-size and viewport-layout callbacks apply the same anchor rule without animated scrolling on every chunk.
React Native 0.88 KeyboardAvoidingView keeps the composer in the available layout: padding on iOS, height on Android, and ordinary host viewport behavior on web in this example. Do not add a second keyboard translation or automatic inset on top of it. Check the host's header offset, Android window resize/insets, and browser viewport behavior with a real focused input. A visually simulated keyboard cannot prove those integrations.
The composer remains controlled by draft and caps its height; longer content scrolls within the input. Send rejects empty trimmed content and duplicate requests. Both Send and Stop have named forty-eight-point targets. This compact version does not implement attachment handling, input-height animation, pagination, guaranteed reader-position restoration on keyboard resize, or message delivery receipts.
Finished presentation pieces
Streaming AI Reply receives accumulated text plus thinking and done props. It adds a letter-shaped thinking shimmer, word reveal, an inline cursor, and separate paragraph, bullet, and fenced-code presentation. It does not fetch tokens or choose a model. Host code owns cancel, retry, and response identity.
Keyboard Anchored Chat supplies a controlled, virtualized conversation, growing composer, Latest messages action, and history-reader anchoring. Supply stable message IDs, controlled draft value, required labels, and onSend. The host owns message mutation, pagination, network errors, and native keyboard offset. Its abstract preview keyboard is separate from real input mode. These are two focused components with different APIs; composing structured reply rows into a chat shell requires host adaptation, not an assumed drop-in renderer prop.
Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. Browse micro-interactions, skeleton loading, and pull-to-refresh state handling for related async feedback patterns.
FAQ
Does this React Native chat UI include an AI model or backend?
No. The tutorial streams local strings. Both catalog pieces are presentation components. Your application supplies the transport, model access, security, data retention, and business logic.
Why does streaming text pull me away from old messages?
Calling scrollToEnd unconditionally on every chunk overrides the reader. Track whether they are near the bottom, preserve their choice while reading history, and offer an explicit return-to-latest control.
Can I replace a complete chat SDK with these pieces?
They do not implement accounts, message delivery, persistence, presence, media uploads, or moderation. Use them for the presentation they document alongside the services your product requires.