Can we use JavaScript to build WhatsApp?
This is part three of our series on Chat apps.
In part one, we set up a lot of the UI for Whatsapp.
In part two, we setup the backend using Node.js, Supabase and Socket.io.
Now in part three, we’re going to make our chat app actually work.
We’ll cover:
- Setting up Redux and Redux Persist
- Getting and creating data with Supabase
- Setting up real time using Socket.io
I won’t cover every single line of code but if you have any additional questions put them in the comments!
You can see the full code for the react native app here and the full code for the backend here
Setting up Redux and Redux Persist
The first thing we need to do is to set up redux and redux persist.
Note: we were previously using react context but we wanted an easy way to persist state.
To set up state management and persistence, we’ll need to add a few dependencies:
yarn add redux @reduxjs/toolkit redux-persist @react-native-async-storage/async-storage
First we create our redux store in redux/store.ts
import { configureStore, applyMiddleware } from "@reduxjs/toolkit";
import { combineReducers } from "redux";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from "redux-persist";
import conversationsReducer from "./conversationsReducer";
import usersReducer from "./usersReducer";
const persistConfig = {
key: "root",
storage: AsyncStorage,
version: 1,
};
const reducer = combineReducers({
conversations: conversationsReducer,
users: usersReducer,
});
const persistedReducer = persistReducer(persistConfig, reducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
// this checks our state/actions for non-serializable values e.g. functions, Promises, dates
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;
We are using redux toolkit and the main change we made from their quick start guide is using AsyncStorage.
TypeScript helps you with Redux state
Below is one of the most useful lines of this tutorial. Having a RootState for your redux store is one of the most useful applications of TypeScript and helps out so much when you use useSelector later.
export type RootState = ReturnType<typeof store.getState>;
We also combined multiple reducers.
const reducer = combineReducers({
conversations: conversationsReducer,
users: usersReducer,
});
And then we make these reducers persisted
const persistedReducer = persistReducer(persistConfig, reducer);
What is redux persist actually doing?
The short version: It automatically puts your redux store into persistent storage. Anything that goes into your redux store (your state) is saved in a way that will survive them closing the app and reopening it.
In our case, we use react-native-async-storage. It works on Android, iOS, Web, MacOS and Windows. Where the state is persisted depends on the platform but the below might be helpful:
Under the hood, react-native-async-storage has written native modules that save our state on devices using for example:
Reducers
Speaking of reducers, let’s take a look at our reducers.
Here’s redux/conversationsReducer.ts
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import { Conversation, Message } from "../types";
import sortConversations from "../helpers/sortConversations";
import storage from "@react-native-async-storage/async-storage";
import { PURGE } from "redux-persist";
export interface ConversationState {
conversations: Conversation[];
currentConversation: Conversation | null;
}
const initialState: ConversationState = {
conversations: [],
currentConversation: null,
};
export const conversationsSlice = createSlice({
name: "conversations",
initialState,
reducers: {
addAllConversations: (
state: ConversationState,
action: PayloadAction<Conversation[]>
): void => {
state.conversations = sortConversations(action.payload);
},
setCurrentConversation: (
state: ConversationState,
action: PayloadAction<Conversation>
): void => {
if (action.payload) {
state.currentConversation = action.payload;
}
},
addNewConversation: (
state: ConversationState,
action: PayloadAction<Conversation>
): void => {
if (action.payload) {
const conversationAlreadyExists = state.conversations.find(
(conv) => conv.id === action.payload.id
);
if (!conversationAlreadyExists) {
state.conversations = sortConversations([
...state.conversations,
action.payload,
]);
}
}
},
markConversationAsRead: (
state: ConversationState,
action: PayloadAction<Conversation>
): void => {
if (action.payload) {
const conversationToUpdate = state.conversations.find(
(conversation) => conversation.id === action.payload.id
);
if (conversationToUpdate) {
conversationToUpdate.messages.forEach((message) => {
message.isRead = true;
});
}
}
},
sendMessage: (
state: ConversationState,
action: PayloadAction<Message>
): void => {
const message = action.payload;
const conversationToUpdate = state.conversations.find(
(conversation) => conversation.id === message.conversationID
);
if (conversationToUpdate) {
conversationToUpdate.messages.push(message);
}
if (
state.currentConversation &&
message.conversationID === state.currentConversation.id
) {
state.currentConversation.messages.push(message);
}
state.conversations = JSON.parse(
JSON.stringify(sortConversations(state.conversations))
);
},
},
extraReducers: (builder) => {
builder.addCase(PURGE, (state) => {
storage.removeItem("persist:root");
});
},
});
// Action creators are generated for each case reducer function
export const {
addAllConversations,
sendMessage,
setCurrentConversation,
addNewConversation,
markConversationAsRead,
} = conversationsSlice.actions;
export default conversationsSlice.reducer;
We use the createSlice function and pass in the current state and a name, as well as the reducers. createSlice “automatically generates action creators and action types that correspond to the reducers and state”. More here
Each of our reducers is modifying our state in a specific type of way.
For example, addAllConversations:
addAllConversations: (
state: ConversationState,
action: PayloadAction<Conversation[]>
): void => {
state.conversations = sortConversations(action.payload);
},
addAllConversations receives an array of Conversations (via our database) and then it sorts them and sets the conversations state to be these conversations.
And here’s redux/usersReducer.ts
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import { PURGE } from "redux-persist";
import storage from "@react-native-async-storage/async-storage";
import { User } from "../types";
export interface UserState {
currentUser: User | null;
users: User[];
}
const initialState: UserState = {
users: [],
currentUser: null,
};
export const usersSlice = createSlice({
name: "users",
initialState,
reducers: {
addAllUsers: (state: UserState, action: PayloadAction<User[]>): void => {
state.users = action.payload;
},
setCurrentUser: (state: UserState, action: PayloadAction<User>): void => {
state.currentUser = action.payload;
},
},
extraReducers: (builder) => {
builder.addCase(PURGE, (state) => {
storage.removeItem("persist:root");
});
},
});
// Action creators are generated for each case reducer function
export const { addAllUsers, setCurrentUser } = usersSlice.actions;
export default usersSlice.reducer;
Once we’ve setup these three files, we can add them to our app in App.tsx
import { SafeAreaProvider } from "react-native-safe-area-context";
import { store } from "./redux/store";
import { Provider as ReduxProvider, useSelector } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { persistStore } from "redux-persist";
import Navigation from "./navigation";
const persistor = persistStore(store);
// use this to clear out all the data
// persistor.purge();
export default function App() {
return (
<ReduxProvider store={store}>
<PersistGate persistor={persistor}>
<SafeAreaProvider>
<Navigation />
</SafeAreaProvider>
</PersistGate>
</ReduxProvider>
);
}
The main parts here are that we wrap everything inside our ReduxProvider that has access to our store, enabling everything inside to access this store.
And then inside ReduxProvider we wrap everything inside a PersistGate which tells redux-persist to save our store (our state) in a persistent location.
We are then able to access data from our screens and trigger actions from our screens using useSelector and useDispatch and when the user closes their app they can maintain the state.
Here is how we get all the conversations data from inside a screen. Notice we use RootState from earlier. This saves a lot of silly mistakes.
import { useSelector } from "react-redux";
...
const conversations = useSelector(
(state: RootState) => state.conversations.conversations
);
And here is how we trigger a new conversation to be added in redux.
import { useDispatch } from "react-redux";
...
const dispatch = useDispatch();
dispatch(sendMessage(message));
Communicating between React Native & Supabase
Localhost for my machine and localhost for my simulator are two different things so I like to use ngrok. This gives me a live url that effectively puts my localhost online.
You’ll need to setup an account and download ngrok.
brew install ngrok/ngrok/ngrok
Yes it is actually ngrok/ngrok/ngrok! Explainer here
So when you have Supabase server running on http://localhost:3000/, you can run
ngrok http 3000
Then you get a url like the one highlighted below that you can copy paste and use as the URL for your API
In my code I save this in constants/ngrokURL.ts and we should now be able to communicate with Supabase.
Getting conversation and user data in React Native
Here is how we get conversations from Supabase
import { MyResponse } from "../types";
import formatConversations from "../helpers/formatConversations";
import ngrokURL from "../constants/ngrokURL";
const requestOptions: RequestInit = {
method: "GET",
redirect: "follow",
};
const baseURL = ngrokURL;
export default async function getAllConversations(
userID: string
): Promise<MyResponse> {
try {
const getconversationsURL: string = `${baseURL}/conversations?user_id=${userID}`;
const response = await fetch(getconversationsURL, requestOptions);
const result_1 = await response.json();
const formattedConversations = formatConversations(result_1);
return {
data: formattedConversations,
status: response.status,
message: response.statusText,
};
} catch (error) {
let message;
if (error instanceof Error) message = error.message;
else message = String(error);
return {
data: null,
status: 400,
message,
};
}
}
We use this to update our conversations state in redux from App.tsx
useEffect(() => {
if (!currentUser) {
setShowUserDialog(true);
} else if (currentUser)
getAllConversations(currentUser.id).then((res) => {
const conversations = res.data;
if (conversations) {
dispatch(addAllConversations(conversations));
}
});
}, []);
Creating conversations and users in React Native
We create conversations when users click on a person. Here’s what _onPress looks like
const _onPress = () => {
if (currentUser) {
const newGroupName = `${user.username} & ${currentUser.username}`;
createConversation([user.id, currentUser.id], newGroupName, user.id)
.then((conversation) => {
const formattedConversation = formatConversation(conversation);
dispatch(setCurrentConversation(formattedConversation));
dispatch(addNewConversation(formattedConversation));
navigation.dispatch((state) => {
const routes = state.routes.filter(
(r) => r.name !== "CreateNewChat"
);
const chatRoute = {
name: "Chat",
params: { conversation: formattedConversation },
path: undefined,
};
const newRoutes = [...routes, chatRoute];
return CommonActions.reset({
...state,
routes: newRoutes,
index: newRoutes.length - 1,
});
});
// navigation.navigate("Chat", { conversation: formattedConversation });
})
.catch((err) => console.log(err));
}
};
We create a conversation, then we:
- set the current conversation
- add the conversation to our conversations list
- navigate to the conversation
Navigating to “Chat” was interesting. At first I straight up navigated with navigation.navigate(”Chat”)
but that meant when you press back you came back to the create conversation screen instead of the home screen. It looked a bit weird.
So we modified the route state so that when you press back from the conversation you go straight back to Chats.
And here’s the API call to create a conversation:
import ngrokURL from "../constants/ngrokURL";
const myHeaders = new Headers();
myHeaders.append("Content-Type", "application/json");
export default async function createConversation(
participantIDs: string[],
groupName: string,
ownerID: string
) {
const uniqueparticipantIDs = [...new Set([...participantIDs, ownerID])];
const raw = JSON.stringify({
owner_id: ownerID,
group_name: groupName,
participant_ids: uniqueparticipantIDs,
});
const baseURL = ngrokURL;
const createConversationURL: string = `${baseURL}/conversations/create`;
const requestOptions: RequestInit = {
method: "POST",
headers: myHeaders,
body: raw,
redirect: "follow",
};
try {
const res = await fetch(createConversationURL, requestOptions);
const data = await res.json();
return data;
} catch (err) {
console.log(err);
}
}
Sending a message
Here is the code that executes when a message is sent.
onPress={() => {
if (!userID) {
Alert.alert("user id is null");
} else {
const message = _prepMessage(
newMsg,
thisConversation.id,
userID,
setNewMsg,
isTyping,
setIsTyping
);
if (message) {
addNewMessage(message).then((res) => {
dispatch(sendMessage(message));
});
}
}
}}
- We format our message in the correct format.
- Then we make the API call to add a meessage.
- Then we add our message to the conversation
Here is the code for our API call to create the message:
import { Message } from "../types";
import ngrokURL from "../constants/ngrokURL";
const baseURL = ngrokURL;
const myHeaders = new Headers();
myHeaders.append("Content-Type", "application/json");
export default async function addNewMessage(message: Message) {
const addNewMessageURL: string = `${baseURL}/conversations/${message.conversationID}/messages/create`;
const raw = JSON.stringify({
user_id: message.userID,
message: message.message,
});
const requestOptions: RequestInit = {
method: "POST",
headers: myHeaders,
body: raw,
redirect: "follow",
};
const response = await fetch(addNewMessageURL, requestOptions);
const res1 = response.json();
return res1;
}
Creating a new user
In WhatsApp, users are linked to phone numbers. But in our example, we’re going to have a simple concept of a user.
When there is no user stored in redux state, we will send a popup modal that asks for a username.
<CreateUserDialog
visible={showUserDialog}
setShowUserDialog={setShowUserDialog}
/>
It will then create a user and set it in redux sate.
import { View } from "react-native";
import Dialog from "react-native-dialog";
import React, { useState } from "react";
import { useDispatch } from "react-redux";
import { setCurrentUser } from "../../redux/usersReducer";
import { User } from "../../types";
import createUser from "../../api/createUser";
export default function CreateUserDialog({
visible,
setShowUserDialog,
}: {
visible: boolean;
setShowUserDialog: (vis: boolean) => void;
}) {
const dispatch = useDispatch();
const [username, setUsername] = useState<string>("");
const _createUser = async () => {
const user: User = await createUser(username);
dispatch(setCurrentUser(user));
setShowUserDialog(false);
};
return (
<View>
<Dialog.Container visible={visible}>
<Dialog.Title>Set name</Dialog.Title>
<Dialog.Input
label="username"
onChangeText={(_username: string) => setUsername(_username)}
></Dialog.Input>
<Dialog.Button label="Create new user" onPress={_createUser} />
</Dialog.Container>
</View>
);
}
And here’s the API call to Supabase to create a user:
import ngrokURL from "../constants/ngrokURL";
import { SupabaseUser, User } from "../types";
export default async function createUser(username: string): Promise<User> {
const baseURL = ngrokURL;
const createUserURL: string = `${baseURL}/users/create`;
var myHeaders = new Headers();
myHeaders.append("Content-Type", "application/json");
var raw = JSON.stringify({
username,
});
var requestOptions: RequestInit = {
method: "POST",
headers: myHeaders,
body: raw,
redirect: "follow",
};
try {
const res = await fetch(createUserURL, requestOptions);
const resjson: SupabaseUser = await res.json();
const newUser: User = {
id: resjson.id,
username: resjson.username,
createdAt: resjson.created_at,
};
if (newUser) {
return newUser;
} else throw new Error("user not created");
} catch (err) {
console.log(err);
throw err;
}
}
Real time
Socket.io is a technology that easily enables bidirectional communication between client and server.
For us this means:
- Our client can listen for updates from our server and adjust the state
- Our server can listen for updates from our client and adjust our data
Fun aside: Socket.io does not **only* use WebSockets for communication - it first establishes a connection using HTTP long-polling transport. Then once the connection is established it tries to upgrade to a WebSocket connection. The reason it does this is because its not always possible to establish a WebSocket connection because of corporate policies, personal firewall etc. An unsuccessful WebSocket connection attempt could lead to a 10+ second wait from the user’s perspective so it’s really worth avoiding.*
We need to add socket.io to our project:
yarn add socket.io-client
We initialize our socket inside navigation/index.tsx
import { io } from "socket.io-client";
import ngrokURL from "../constants/ngrokURL"
...
const socket = io(ngrokURL);
Then we can join the socket inside our useEffect in navigation/index.tsx
useEffect(() => {
if (currentUser) {
socket.emit("join", {
id: currentUser.id,
username: currentUser.username,
created_at: currentUser.createdAt,
});
return () => {};
}, [currentUser]);
Our server fires two different events:
- message - when a new message is created
- newConversation - when a new conversation is created
Listening for new conversations
In the same userEffect, we can setup our socket to listen for newConversation event
// navigation/index.tsx
socket.on("newConversation", (conv: SupabaseConversation) => {
const conversation = formatConversation(conv);
dispatch(addNewConversation(conversation));
});
When we are notified about a new conversation event we add it to our conversations state.
// redux/conversationsReducer.ts
addNewConversation: (
state: ConversationState,
action: PayloadAction<Conversation>
): void => {
if (action.payload) {
const conversationAlreadyExists = state.conversations.find(
(conv) => conv.id === action.payload.id
);
if (!conversationAlreadyExists) {
state.conversations = sortConversations([
...state.conversations,
action.payload,
]);
}
}
},
Listening for new messages
And this is how we listen to message inside useEffect
// navigation/index.tsx
socket.on("message", (message: SupabaseMessage) => {
const newMessage: Message = {
id: message.id,
message: message.message,
conversationID: message.conversation_id,
userID: message.users.id,
isRead: false,
time: message.created_at,
};
dispatch(sendMessage(newMessage));
});
And we add our message to conversations state as well as the currentConversation if it has been selected
sendMessage: (
state: ConversationState,
action: PayloadAction<Message>
): void => {
const message = action.payload;
const conversationToUpdate = state.conversations.find(
(conversation) => conversation.id === message.conversationID
);
if (conversationToUpdate) {
conversationToUpdate.messages.push(message);
}
if (
state.currentConversation &&
message.conversationID === state.currentConversation.id
) {
state.currentConversation.messages.push(message);
}
state.conversations = JSON.parse(
JSON.stringify(sortConversations(state.conversations))
);
},
And that’s it, it should all be working for you now. Please check out our previous tutorials and if you have questions let me know in the comments:
If you like this article, I also host a podcast on developer tools.
Top comments (1)