Building a React Native Chat App From Scratch: Full Tutorial

Build a working React Native chat app with WebSockets and an inverted FlatList, then see what production adds and when an SDK beats DIY.

Build chat app react native

To build a chat app in React Native you need three things: a message list that renders upside down, a socket that survives bad networks, and the humility to know which parts you should not build yourself. This tutorial covers all three. We will build a working real-time chat app from scratch with React Native 0.87 and a small Node.js server, and then walk honestly through the distance between “works in the demo” and “works in production”, because that distance is where chat projects go to die.

Everything below is plain React Native and WebSocket code, no chat SDK. At the end there is a section on when that is the wrong call.

Architecture: what a chat app actually is

Strip away the UI and a chat app is a synchronization problem. There is a canonical conversation history on a server, and N devices holding partial, possibly stale copies of it. Every architectural decision follows from that framing:

  • Transport: a persistent bidirectional connection. WebSockets are the default answer; we covered the low-level details in our WebSockets in React Native guide.
  • Server: something that accepts connections, fans messages out to room members, and persists history.
  • Client state: the message list, an outbox for messages typed while offline, and reconciliation when the socket reconnects.

Step 1: a minimal chat server

You cannot build a chat client against nothing, so here is the smallest server worth having. Node.js with the ws package, in-memory history, one room:

// server.js (Node 22+, npm i ws)
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });
const history = [];

wss.on('connection', (socket) => {
  socket.send(JSON.stringify({ type: 'history', messages: history.slice(-50) }));

  socket.on('message', (raw) => {
    const msg = JSON.parse(raw);
    if (msg.type !== 'message') return;
    const stored = {
      id: crypto.randomUUID(),
      text: msg.text,
      author: msg.author,
      sentAt: Date.now(),
    };
    history.push(stored);
    const frame = JSON.stringify({ type: 'message', message: stored });
    for (const client of wss.clients) {
      if (client.readyState === 1) client.send(frame);
    }
  });
});

Note one deliberate choice: the server assigns the message id and timestamp, not the client. Client clocks lie. The server’s ordering is the ordering.

Step 2: project setup

npx create-expo-app rn-chat
cd rn-chat

React Native has a built-in, spec-compliant WebSocket implementation, so the client needs no extra dependency for transport. That is genuinely all the setup.

Step 3: the connection layer

The temptation is to open a WebSocket inside your chat screen component. Resist it. Connections outlive screens. Put the socket in a module (or context) with reconnect logic from day one:

// src/chatConnection.js
const WS_URL = 'ws://192.168.1.20:8080'; // your machine's LAN IP, not localhost

export function createConnection({ onMessage, onHistory, onStatus }) {
  let socket = null;
  let retry = 0;
  let closed = false;

  function connect() {
    onStatus('connecting');
    socket = new WebSocket(WS_URL);

    socket.onopen = () => {
      retry = 0;
      onStatus('online');
    };

    socket.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.type === 'history') onHistory(data.messages);
      if (data.type === 'message') onMessage(data.message);
    };

    socket.onclose = () => {
      if (closed) return;
      onStatus('offline');
      const delay = Math.min(1000 * 2 ** retry, 15000);
      retry += 1;
      setTimeout(connect, delay);
    };

    socket.onerror = () => socket.close();
  }

  connect();

  return {
    send(text, author) {
      if (socket?.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify({ type: 'message', text, author }));
        return true;
      }
      return false;
    },
    destroy() {
      closed = true;
      socket?.close();
    },
  };
}

Exponential backoff with a cap. Phones ride subways and elevators; connection drops are the normal case, not the exception, and a client that hammers your server with instant reconnects is a self-inflicted DDoS.

Step 4: the message list (inverted, always inverted)

Chat lists render newest at the bottom, and users expect to be pinned there as messages arrive. The standard trick: an inverted FlatList with the data array newest-first. The list renders upside down, so “top of the data” is the bottom of the screen, and new messages appear exactly where the eye expects them with zero scroll management.

// App.js
import { useEffect, useRef, useState, useCallback } from 'react';
import {
  FlatList, KeyboardAvoidingView, Platform, StyleSheet,
  Text, TextInput, Pressable, View, SafeAreaView,
} from 'react-native';
import { createConnection } from './src/chatConnection';

const ME = 'taras';

export default function App() {
  const [messages, setMessages] = useState([]);
  const [draft, setDraft] = useState('');
  const [status, setStatus] = useState('connecting');
  const conn = useRef(null);

  useEffect(() => {
    conn.current = createConnection({
      onHistory: (msgs) => setMessages([...msgs].reverse()),
      onMessage: (msg) => setMessages((prev) => [msg, ...prev]),
      onStatus: setStatus,
    });
    return () => conn.current.destroy();
  }, []);

  const send = useCallback(() => {
    const text = draft.trim();
    if (!text) return;
    if (conn.current.send(text, ME)) setDraft('');
  }, [draft]);

  return (
    <SafeAreaView style={styles.root}>
      {status !== 'online' && (
        <Text style={styles.banner}>{status}...</Text>
      )}
      <FlatList
        inverted
        data={messages}
        keyExtractor={(m) => m.id}
        renderItem={({ item }) => (
          <View style={[styles.bubble, item.author === ME && styles.mine]}>
            <Text style={styles.author}>{item.author}</Text>
            <Text>{item.text}</Text>
          </View>
        )}
      />
      <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
        <View style={styles.inputRow}>
          <TextInput
            style={styles.input}
            value={draft}
            onChangeText={setDraft}
            placeholder="Message"
            onSubmitEditing={send}
          />
          <Pressable style={styles.sendBtn} onPress={send}>
            <Text style={styles.sendTxt}>Send</Text>
          </Pressable>
        </View>
      </KeyboardAvoidingView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, backgroundColor: '#fff' },
  banner: { textAlign: 'center', padding: 4, backgroundColor: '#ffe8a3' },
  bubble: {
    alignSelf: 'flex-start', backgroundColor: '#eef1f4',
    borderRadius: 12, padding: 10, marginHorizontal: 12, marginVertical: 4,
    maxWidth: '80%',
  },
  mine: { alignSelf: 'flex-end', backgroundColor: '#d7ecff' },
  author: { fontSize: 11, color: '#667', marginBottom: 2 },
  inputRow: { flexDirection: 'row', padding: 8, gap: 8 },
  input: {
    flex: 1, borderWidth: 1, borderColor: '#ccc',
    borderRadius: 20, paddingHorizontal: 14, paddingVertical: 8,
  },
  sendBtn: { justifyContent: 'center', paddingHorizontal: 16 },
  sendTxt: { color: '#0a7cff', fontWeight: '600' },
});

Run the server, start the app on two devices or a device plus simulator, and you have real-time chat. Fifty lines of server, one screen of client. This is the moment every “we’ll build chat in a sprint” estimate is born.

Step 5: the parts the demo hides

Here is what the app above does not do, and what each item costs in practice:

  • Optimistic sends and outbox. Right now a message typed while offline is silently droppable. Production clients append it locally with a pending flag, queue it, and retry on reconnect, then reconcile against the server copy by id.
  • History sync. We fetch the last 50 messages on connect. Real apps need paginated backscroll, gap detection after long offline periods, and multi-device read state.
  • Delivery and read receipts. Every tick you see in WhatsApp is a server-side state machine per message per recipient.
  • Presence and typing indicators. Cheap to demo, expensive to make truthful at scale.
  • Push notifications. The socket dies when the app backgrounds. From that moment, message delivery is APNs/FCM’s job, with all the token lifecycle work that entails; our push notifications guide covers that rabbit hole.
  • Auth, rooms, moderation, attachments. Real login, per-room membership checks, abuse tooling the day strangers can message each other, and file upload/storage/thumbnailing.
  • Scale. One Node process with a clients loop works to a few thousand connections. Past that you are into pub/sub fan-out, sticky sessions or connection gateways, and a message archive database with retention policies.

None of this is exotic computer science. It is just a lot of unglamorous, correctness-sensitive work, and it is permanent: whoever builds it, owns it.

DIY vs SDK: the honest decision

Build from scratch when messaging IS your product, when you need behavior no SDK expresses, or when the learning itself is the point (this tutorial’s actual job). Reach for an SDK when chat is a feature inside a bigger product and your engineering time is better spent on the parts only you can build.

Full disclosure on where we stand: we build Ethora, an open source chat and AI SDK, and its @ethora/chat-component (26.7.4 at the time of writing) packages the message list, history sync, attachments and the server behind one React component you can self-host. That obviously makes us biased, so weigh it as one option among several: we compared the field honestly in Best Chat SDKs for React Native and React Native chat libraries: 7 options compared, including the cases where a competitor or plain DIY is the better fit.

FAQ

Can I use Firebase instead of my own WebSocket server?
Yes, Firestore listeners give you real-time sync without running a server, and it is a fine choice for small apps. You trade away data ownership, query flexibility for message archives, and predictable pricing at high message volume.

Should I use Socket.IO instead of raw WebSockets?
Socket.IO adds rooms, acks and fallbacks on top of WebSockets and is pleasant to use. For learning, raw WebSockets teach you more. For production, the transport choice matters far less than the sync logic around it.

Does this work with Expo?
Yes. WebSockets work in Expo Go with no native code. Push notifications are where you will need a development build.

How long does a production chat feature really take?
From scratch, with the feature list above: months, not sprints, for a small team, plus ongoing ownership. With a good SDK: days to integrate, and your maintenance burden is version bumps.

Leave a Reply 0

Your email address will not be published. Required fields are marked *