WhatsApp in React Native (part 3)
Tags: reactnative, javascript, node, tutorial
Published: 2023-01-31 13:20:57

Can we use JavaScript to build WhatsApp? 


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/z3m22pjujidd93gq9nrt.gif)



This is part three of our series on Chat apps.

[In part one, we set up a lot of the UI for Whatsapp](https://dev.to/jacksbridger/whatsapp-ui-in-react-native-part-1-4nbm).

[In part two, we setup the backend using Node.js, Supabase and Socket.io.](https://dev.to/nsmet/chat-backend-with-supabase-socketio-55nn) 

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](https://github.com/jackbridger/whatsapp-chat-ui-react-native/tree/supabase-node-backend) and the full code for [the backend here](https://github.com/nsmet/supabase-node-chat-backend)

## 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:

```jsx
yarn add redux @reduxjs/toolkit redux-persist @react-native-async-storage/async-storage
```

First we create our redux store in  **redux/store.ts**

```jsx
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](https://redux-toolkit.js.org/tutorials/quick-start) 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.

```jsx
export type RootState = ReturnType<typeof store.getState>;
```

We also combined multiple reducers.

```jsx
const reducer = combineReducers({
  conversations: conversationsReducer,
  users: usersReducer,
});
```

And then we make these reducers persisted 

```jsx
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:

- Android - [SQLite](https://github.com/react-native-async-storage/async-storage/discussions/567)
- iOS - [small values in serialized dictionary and larger values in separate files](https://github.com/react-native-async-storage/async-storage/blob/master/ios/RNCAsyncStorage.h)

## Reducers

Speaking of reducers, let’s take a look at our reducers.

Here’s **redux/conversationsReducer.ts**  

```jsx
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](https://redux-toolkit.js.org/api/createSlice)

Each of our reducers is modifying our state in a specific type of way.

For example, **addAllConversations**:

```jsx
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**

```tsx
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**

```jsx
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.

```jsx
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. 

```jsx
import { useDispatch } from "react-redux";
...
const dispatch = useDispatch();
dispatch(sendMessage(message));
```

## Communicating between React Native & Supabase

[Localhost](http://Localhost) for my machine and [localhost](http://localhost) for my simulator are [two different things](https://stackoverflow.com/questions/33704130/react-native-android-fetch-failing-on-connection-to-local-api) 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.

```jsx
brew install ngrok/ngrok/ngrok
```
Yes it is actually ngrok/ngrok/ngrok! [Explainer here](https://docs.brew.sh/Taps#repository-naming-conventions-and-assumptions)

So when you have Supabase server running on [http://localhost:3000/](http://localhost:3000/), you can run

```jsx
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


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sbn1fv52nto5tgfjla20.png)



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

```jsx
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**

```jsx
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

```jsx
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:

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/opzmj4cf1y7n1m8jjyp1.gif)

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dzyato2775h989orl91i.gif)




And here’s the API call to create a conversation:

```jsx
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.

```jsx
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));
                  });
                }
              }
            }}
```

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:

```jsx
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. 


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uqcp52n7gll01owdxljg.png)


```jsx
<CreateUserDialog
        visible={showUserDialog}
        setShowUserDialog={setShowUserDialog}
 />
```

It will then create a user and set it in redux sate.

```jsx
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:

```jsx
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](http://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](http://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](http://socket.io) to our project:

```jsx
yarn add socket.io-client
```

We initialize our socket inside **navigation/index.tsx**

```jsx
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**

```jsx
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

```jsx
// 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.

```jsx
// 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

```jsx
// 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

```jsx
    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:

- [Part one](https://dev.to/jacksbridger/whatsapp-ui-in-react-native-part-1-4nbm) - WhatsApp UI
- [Part two](https://dev.to/nsmet/chat-backend-with-supabase-socketio-55nn) - setting up Supabase backend

[Full source code](https://github.com/jackbridger/whatsapp-chat-ui-react-native)

If you like this article, I also host a podcast on [developer tools](https://scalingdevtools.com/).