Simple Chat App With React Native - Part I

At the beginning of my journey with React Native, building a chat app was a challenge for me. I thought that application development would be complicated and time-consuming. I couldn’t be more wrong! I’ve started acquiring knowledge that I needed to make simple chat app, then created a repository and it just happened!

I want to share with you what I have learned over the course of past months in series that consists of the following topics:

What tools do you need?

First of all, we need a server. I have decided to use Hapi.js. Now, if you haven’t heard of it before, it’s a declarative configuration-centric framework. What does it mean? Unlike Express.js, which relies heavily on the concept of middlewares, Hapi.js gives you an opportunity to pass the configuration object that describes your route hierarchy.

Here’s how a typical setup would look like:

const Hapi = require('hapi');
const server = new Hapi.Server();
server.connection({ port: 3000, host: 'localhost' });
// route configuration as an object
const routes = [
{
method: 'GET',
path: '/',
handler: function (request, reply) {
reply('Hello, world!');
},
},
{
method: 'GET',
path: '/{name}',
handler: function (request, reply) {
reply(`Hello, ${request.params.name}`);
},
}
];
server.route(routes);
server.start((err) => {
if (err) {
throw err;
}
console.log(`Server running at: ${server.info.uri}`);
});
view raw hapi.js hosted with ❤ by GitHub

What’s great about this approach is the ability to describe the endpoints your application supports and an opportunity to reuse it across your different projects.

Let’s start by adding Hapi.js to our dependencies:

yarn add hapi
view raw addhapi.js hosted with ❤ by GitHub

NOTE: You can use npm instead.

Next, we need a database to store our users and their conversations. I chose MongoDB. Thanks to being NoSQL, it’s structure is similar to JSON objects, which makes it easy to navigate through the data. To define the schema for documents, we will use object modeling tool called Mongoose.

It’s necessary to install Mongo:‍

yarn add mongodb
yarn add mongoose
view raw installMongo.js hosted with ❤ by GitHub

And finally, we will need Socket.IO, which is a library that helps you to work with WebSockets. We will use it to implement real-time communication with our server, including receiving and ending data to connected sockets.

yarn add socket.io
view raw socketio.js hosted with ❤ by GitHub

yarn add socket.io

Setting up

Let’s create a server based on Hapi.js. It allows us to handle http protocol and Socket.IO. Next step is to listen to ‘connection’ event, which is automatically sending by the client. Then on ‘init’ assign to the <rte-code>socket<rte-code> sending user’s id from sockets object. Now, we just listen to ‘message’ event that is sent from the client when the user sends a message. The message is directly transferred between two defined sockets. Simultaneously, when the app is closed, the ‘disconnect’ event is fired.

const Hapi = require('hapi');
const mongoose = require('mongoose');
const Joi = require('joi');
import * as handlers from './handlers';
const server = new Hapi.Server();
const socketio = require('socket.io')(server.listener);
const sockets = {};
server.connection({ port: 8888 });
socketio.on('connection', (socket) => {
socket.on('init', (userId) => {
sockets[userId.senderId] = socket;
});
socket.on('message', (message) => {
if (sockets[message.receiverId]) {
sockets[message.receiverId].emit('message', message);
}
/* handler for creating message */
});
socket.on('disconnect', (userId) => {
delete sockets[userId.senderId];
});
});
/*
routes for specific handlers
*/
mongoose.connect('mongodb://localhost/db/friendChat');
server.start((err) => {
if (err) {
console.log(err);
throw err;
}
console.log(`Server running at: ${server.info.uri}`);
});

Every endpoint needs its own route with a specific handler, so we need to define it. The route could be something like:

server.route({
method: 'POST',
path: '/conversations',
handler: handlers.createConversation,
config: {
auth: 'jwt',
validate: {
payload: {
friendId: Joi.string().required(),
},
},
},
});
view raw handler.js hosted with ❤ by GitHub

Conversation or message?

At this stage, we need to catch the difference between conversation and message. Conversation is a container for messages (has a reference to the message model) being sent between two users.

{
userOneId: String,
userTwoId: String,
messages: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Message',
}],
}
view raw conversation.js hosted with ❤ by GitHub

Message model object should be similar to the one below:

{
text: String,
createdAt: {
type: Date,
default: Date.now,
},
userId: Object,
}
view raw message.js hosted with ❤ by GitHub

Create converations as a first handler

I’m sure you have noticed that first handler has to be responsible for creating conversation. Let’s dive into it. First, you need to make sure that there has been no conversation between the two users yet. Creating a conversation includes adding a new conversation object to the database for two messaging users. It looks easy! But is it really? Take a look at more handlers.

import Boom from 'boom';
import User from '../db/models/user';
import Conversation from '../db/models/conversation';
export default async function (request, reply) {
await User.findOne({ email: request.auth.credentials.email }).populate('conversations')
.then(
(user) => {
if (user) {
const isConversationExist = user.conversations.filter(conversation => (
conversation.userOneId === request.payload.friendId ||
conversation.userTwoId === request.payload.friendId
),
).length > 0;
if (isConversationExist) {
reply(Boom.badData('You already have conversation with this user'));
} else {
User.findById(request.payload.friendId).then(
(friend) => {
const newConversation = new Conversation({
userOneId: user._id,
userTwoId: friend._id,
});
newConversation.save().then((conversation) => {
user.conversations.push(conversation);
user.save();
friend.conversations.push(conversation);
friend.save();
reply({ id: conversation._id, friendId: friend._id });
});
},
);
}
} else {
reply(Boom.notFound('Cannot find user'));
}
},
);
}

Add more handlers

The container is ready for an initial message. To create it, we need to find a proper conversation by an id, which is sent from a client in the message property. Then, we are able to create a textMessage, save it and push to the messages array as a part of a conversation model.

import Conversation from '../db/models/conversation';
import Message from '../db/models/message';
const createMessage = (message) => {
Conversation.findById(message.conversationId).then((conversation) => {
const textMessage = new Message({
text: message.text,
userId: message.senderId,
});
textMessage.save().then((savedMessage) => {
conversation.messages.push(savedMessage);
conversation.save();
});
});
};
export default createMessage;

What should we do next? Imagine a case when your user starts a conversation, sends a new message and closes the app. Suddenly, he realizes he forgot to mention something important, so he re-opens the app… and gets confused: there is no conversation and no messages. Don’t worry, they’re not lost — they still exist in the database, waiting to be loaded. Naturally, we expect only a conversation with the chosen friend to load, so we need to distinguish two ids and reply to the proper one extended with the conversation id.

import Boom from 'boom';
import User from '../db/models/user';
export default async function (request, reply) {
await User.findOne({ email: request.auth.credentials.email }).populate('conversations').then(
(user) => {
if (user) {
const conversations = user.conversations.map((conversation) => {
const friendId = `${user._id}` === conversation.userOneId ?
conversation.userTwoId : conversation.userOneId;
return {
id: conversation._id,
friendId,
};
});
reply(conversations);
} else {
reply(Boom.notFound('Cannot find user'));
}
},
);
}

Closing the app’s backend, we need to implement the last handler related to loading messages for a given conversation id.

import Boom from 'boom';
import Conversation from '../db/models/conversation';
export default async function (request, reply) {
await Conversation.findById(request.params.conversationId).populate('messages').then(
(conversation) => {
if (conversation) {
reply({ id: conversation._id, messages: conversation.messages });
} else {
reply(Boom.notFound('Cannot find conversations'));
}
},
);
}
view raw loadMessages.js hosted with ❤ by GitHub

Hooray, we’re done and our backend is set!

If you’re interested in how to connect our work with frontend — make sure to follow this series, because there’s another article coming up in December. Everything you read today, you can check out in my repo. Enjoy!

Insights

Learn more about Backend

cover

Nov 4Article

Setting Up Notifications With AWS Amplify Gen 1

Learn how to integrate AWS Amplify with React Native for Android, focusing on live messaging and push notifications. This guide walks you through the setup.

cover

Aug 10Podcast

Databases for Mobile Apps: React Native & Realm

Join us on a deep-dive into the complex world of databases for mobile apps, and take a closer look at what the marriage of React Native, Realm & MongoDB can bring.

cover

Dec 28Article

Simple Chat App With React Native - Part II

Read the article and learn how to connect the frontend of the messaging app with its backend.

cover

Oct 3Article

React Native, Firebase and You

Learn how to build a full-stack project in React Native with the Firebase support. Check the benefits of using the react-native-firebase module.

cover

Mar 19Tutorial

The Hard Way vs. The React Native AI SDK Way: Stop Writing Custom Modules for Every Model

Learn how React Native AI uses a unified provider model to switch between cloud and on-device LLMs without rewriting application logic, enabling reliable AI experiences online and offline.

cover

Mar 12Tutorial

Build Smarter Apps: Tool Calling & AI Orchestration Explained

Learn how on-device models in React Native can call tools, interact with external APIs, and return structured outputs you can use directly in application logic.

cover

Mar 5Tutorial

MLC LLM + React Native: On-Device AI Without the Pain

Learn how to run third-party on-device LLMs in React Native using MLC LLM. Choose your models, run them efficiently across platforms, and keep a unified JavaScript API with React Native AI.

cover

Feb 24Tutorial

Intelligent Fallbacks with Apple Intelligence in React Native

Learn how to use Apple’s built-in Foundation Models in React Native apps, reduce memory usage, and enable fast, fully on-device AI with React Native AI Apple.

cover

Feb 10Tutorial

What Is the React Native AI SDK? A Complete Intro & Quickstart

Learn why on-device AI matters for React Native apps, how local LLMs behave offline, and what problems React Native AI is designed to solve from day one.

cover

Jan 29Tutorial

Implementing an Android TurboModule with Kotlin

Learn how to implement a React Native TurboModule on Android, from the TypeScript spec and Codegen to the Kotlin implementation and package registration.

cover

Jan 19Tutorial

Working with Different Threads in Swift TurboModules

Learn how JavaScript, native module, background, and main threads interact in React Native TurboModules, and how to use GCD queues to run async work safely in Swift.

cover

Jan 9Tutorial

How to Add Type-Safe Constants to Swift TurboModules

Learn how to expose typed, immutable constants from a Swift TurboModule using Codegen, and get full end-to-end type safety between native and JavaScript.

cover

Dec 30Tutorial

Adding Event Emitters to Your TurboModule in Swift

Learn how to emit native events from a Swift TurboModule and subscribe to them in JavaScript, including proper memory management and Codegen integration.

cover

Dec 22Tutorial

Writing Your First TurboModule in Swift

Learn how to build a fully working TurboModule in Swift, integrate it with Codegen, and bridge it through Objective‑C inside an Expo app.