DEV Community

Cover image for WhatsApp in React Native (part 3)
Jack Bridger
Jack Bridger

Posted on • Updated on

WhatsApp in React Native (part 3)

Can we use JavaScript to build WhatsApp?

Image description

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

We also combined multiple reducers.

const reducer = combineReducers({
  conversations: conversationsReducer,
  users: usersReducer,
});
Enter fullscreen mode Exit fullscreen mode

And then we make these reducers persisted

const persistedReducer = persistReducer(persistConfig, reducer);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
    },
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
  );
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then you get a url like the one highlighted below that you can copy paste and use as the URL for your API

Image description

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,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
        }
      });
  }, []);
Enter fullscreen mode Exit fullscreen mode

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));
    }
  };
Enter fullscreen mode Exit fullscreen mode

We create a conversation, then we:

  1. set the current conversation
  2. add the conversation to our conversations list
  3. 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.

Image description

Image description

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
                  });
                }
              }
            }}
Enter fullscreen mode Exit fullscreen mode
  1. We format our message in the correct format.
  2. Then we make the API call to add a meessage.
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Image description

<CreateUserDialog
        visible={showUserDialog}
        setShowUserDialog={setShowUserDialog}
 />
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

We initialize our socket inside navigation/index.tsx

import { io } from "socket.io-client";
import ngrokURL from "../constants/ngrokURL"
...
const socket = io(ngrokURL);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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));
      });
Enter fullscreen mode Exit fullscreen mode

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,
          ]);
        }
      }
    },
Enter fullscreen mode Exit fullscreen mode

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));
      });
Enter fullscreen mode Exit fullscreen mode

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))
      );
    },
Enter fullscreen mode Exit fullscreen mode

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:

Full source code

If you like this article, I also host a podcast on developer tools.

Top comments (1)

Collapse
 
Sloan, the sloth mascot
Comment deleted