Push Notifications in React Native with Expo

A step-by-step guide on adding push notifications to a React Native app using Expo's push notification service with Firebase cloud functions and Hasura.

Push Notifications in React Native with Expo

A step-by-step guide on adding push notifications to a React Native app using Expo's push notification service with Firebase cloud functions and Hasura.

Last week, we looked at creating a full-stack React Native app using Firebase and Hasura. This week, we're going to continue from that point and work on adding push notifications to our app. So if you haven't seen that post, you might want to check it out.

Push notifications are an important feature for any app. We can use them to notify our users about some activity, remind them about a certain thing or simply communicate some information to them. To add push notifications to our app, we're going to use Expo because they make it very simple to do so. We don't have to worry about any of the native stuff or even about communicating with the Apple or Google servers. This way, we can treat both sets of notifications in the same manner and only worry about our own frontend and backend code.

Before we get started, let's take a look at the things we're going to cover in this post:

  • Saving push tokens - In order to send a notification to a user, we need their Expo Push Token.
  • Calling Expo's push API - We need to do this in order to instruct Expo to send the push notification.
  • Saving tickets - After we send a notification, Expo sends us a ticket which we later have to use to check if our notification was sent successfully.
  • Reading receipts - Using our ticket, we can ask Expo for a receipt and check if our users received our notification.
  • Handling errors - If our receipts return errors, we should probably handle them, that's what this section will be about.
  • Receiving push notifications - This refers to the actions we take when a user clicks our notification. Perhaps we want to open a certain screen when they do so.

Initial Setup

If you're using the managed workflow, you can skip this section as the Notifications API is pre-installed. If you're using the bare workflow or have recently ejected to it, continue reading.

expo install expo-notifications

Make sure you follow the specific configuration instructions for iOS and/or Android. Once you're done with the configuration, add your credentials to Expo's server as that's what we will be using to send the notifications.

Saving Push Tokens

To be able to send notifications to our users, we need to be able to identify them (their device) and we do that through an Expo push token. The push token can change over time (re-install, device change, app update) so we want to fetch and update it every time a user opens our app. A good place to do this is our root component as it renders whenever the app is opened.

I want to cover how to do this for both the bare and the managed workflow so we're going to break this process into smaller chunks and then in the end talk about how to plug those into our app.

First, let's see how we'd check and ask for permission to send push notifications to our users.

import { Alert, Platform } from 'react-native'
import * as Permissions from 'expo-permissions';
import { Linking } from 'expo';

const hasNotificationPermission = async () => {
  try {
    const { status: existingStatus } = await Permissions.getAsync(Permissions.NOTIFICATIONS);
    let finalStatus = existingStatus;
    // If we don't already have permission, ask for it
    if (existingStatus !== 'granted') {
      const { status } = await Permissions.askAsync(Permissions.NOTIFICATIONS);
      finalStatus = status;
    }
    if (finalStatus === 'granted') return true;
    if (finalStatus !== 'granted') {
      Alert.alert(
        'Warning',
        'You will not receive reminders if you do not enable push notifications. If you would like to receive reminders, please enable push notifications for Fin in your settings.',
        [
          { text: 'Cancel' },
          // If they said no initially and want to change their mind,
          // we can automatically open our app in their settings
          // so there's less friction in turning notifications on
          { text: 'Enable Notifications', onPress: () => Platform.OS === 'ios' ? Linking.openURL('app-settings:') : Linking.openSettings() }
        ]
      )
      return false;
    }
  } catch (error) {
    Alert.alert(
      'Error',
      'Something went wrong while check your notification permissions, please try again later.'
    );
    return false;
  }
}
Asking for Permission

In the above snippet, we first check if we already have the permission to send notifications (this is true by default on Android) and if we don't, we ask for it. Also, if the user has previously denied permission, we give them an option to change their mind and make it easier for them to grant us permission.

Ideally, you shouldn't be bothering your users by asking for permission to notify them whenever they open your app. The code above is just an example of how you'd ask for and get permission. In a real app, I'd probably only check if we already have permission and ask for permission when they do an action that they might require a notification for.

A small caveat here, if you test this on an emulator/simulator, it will always return undetermined. So make sure you use a real device when doing this whole process.

Next, we want to get the push token for our user.

// Bare
import Constants from 'expo-constants';
import * as Notifications from 'expo-notifications';

// I like extracting all my constants in a separate file
// The EXPERIENCE_ID is a simple string which is basically
// your username/app-name. So in this case, it'd be
// @farazpatankar/full-stack-react-native
import { EXPERIENCE_ID } from './constants';

const getPushToken = async () => {
  let experienceId;
  if (!Constants.manifest) experienceId = EXPERIENCE_ID;
  const { data: token } = await Notifications.getExpoPushTokenAsync({ experienceId });
  return token;
}
-----

// Managed
import { Notifications } from 'expo';

const getPushToken = async () => {
  const token = await Notifications.getExpoPushTokenAsync();
  return token;
}
Getting the push token

In the snippet above, I've gone over how we'd get the push token in both the Expo workflows. We don't need to pass the experience ID in the managed workflow as Expo can automatically pick it up from the constants.

Once we have the token, we should save it to our Hasura database.

import { GRAPHQL_API_URL } from 'react-native-dotenv';

const updatePushToken = async (user, token) => {
  const query = JSON.stringify({
    query: `
      mutation {
        update_users_by_pk(pk_columns: {id: "${user.uid}"}, _set: {expoPushToken: "${token}"}) {
          expoPushToken
        }
      }
    `,
  });

  try {
    const response = await fetch(GRAPHQL_API_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${await user.getIdToken()}`,
      },
      body: query,
    });
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('err', error);
  }
};
Saving the token to our database

Here, we're basically writing a mutation that updates the push token for our user in our database. The reason we're using fetch and not apollo is that we're doing this in our App.js component before we even use the ApolloProvider. Also, make sure you don't forget to add the expoPushToken to your user model in Hasura and update the permissions so you can modify its value.

Now that we have individual methods for checking and acquiring permission, fetching the push token and saving it to our database, let's put it all together inside our App.js component.

...
import { hasNotificationPermission, getPushToken, updatePushToken } from './lib/helpers';

const App = () => {
  ...
  
  useEffect(() => {
    Firebase.auth().onAuthStateChanged(async (firebaseUser) => {
      if (firebaseUser) {
        let user;
        // Check for permission
        const hasPermission = await hasNotificationPermission();
        if (hasPermission) {
          // If we have the permission, get the token
          const token = await getPushToken();
          // Save the token to the DB
          const user = await updatePushToken(firebaseUser, token); 
        }
      }
    });
  }, []);
  ...
}

export default App;
App.js

The code above is pretty straightforward. We check if we have permission, ask for it if we don't and once we get it, we get the push token from Expo and update our user object in that database.

Calling Expo's push API

Now that we've saved our user's push token, we should work on sending them notifications. I like using Firebase cloud functions for this as we can call them both programatically or through a cron job using a URL. Within our cloud function, we are going to use Expo's node SDK to actually trigger the notification so let's go ahead and install it.

npm i expo-server-sdk

Now, let's write some code to make use of the SDK we just installed.

const { Expo } =  require('expo-server-sdk');

const sendNotifications = async (expo, messages) => {
  const chunks = expo.chunkPushNotifications(messages);
  const tickets = [];
  for (const chunk of chunks) {
    try {
      const ticketChunk = await expo.sendPushNotificationsAsync(chunk);
      tickets.push(...ticketChunk);
    } catch (error) {
      console.error("error sending notifications", error);
    }
  }

  return tickets;
}

exports.sendReminders = functions.https.onRequest(async (req, res) => {
  if (req.method !== 'POST') {
    res.status(400).send('What are you even doing?');
    return;
  }

  const expo = new Expo();
  
  // You can either get these tokens from the database
  // or pass them in your request
  const tokens = [];
  
  const messages = [];
  for(token of tokens) {
    if (!Expo.isExpoPushToken(token)) {
      console.error(`Push token ${token} is not a valid Expo push token`);
      continue;
    }
    messages.push({
      to: token,
      title: 'Test Title',
      body: 'Test body'
    });
  }

  try {
    await sendNotifications(expo, messages);
  } catch (error) {
    console.log('error', error);
  }

  res.send(200).send('Notifications sent successfully');
})
Cloud function to notify users

Above is a very basic implementation of a cloud function that you can use to send notifications using Expo's SDK. The only thing you need to add/change in it is how you get the push tokens for your users.  You can either pass them to the request as params or fetch them from your database within the cloud function itself.

Once you deploy the cloud function, you should see a URL that you can call to trigger this function. Make sure you make a POST request when you use URL as that is one of the things we check within our cloud function.

Saving tickets

The next thing we will work on is saving the tickets the Expo servers send us when we ask them to send our notifications for us. We can then use these tickets to get receipts for our notifications.

Before we start saving these tickets, we need to create a new tickets model in our Hasura database with the following fields.

Tickets model

Now that our model is ready, let's write the query we will use to populate this model.

const INSERT_TICKETS = `
mutation($tickets: [tickets_insert_input!]!) {
  insert_tickets(objects: $tickets) {
    affected_rows
  }
}
`;

Let's look at this query for a bit, it's nothing like the queries we've written before. The reason being we are saving all the tickets at once. We do this because saving them one by one would be incredibly slow and our cloud function would timeout. You can read more about bulk mutations with Hasura here.

We've created the model and written our query. Now let's put these to use and save the tickets Expo gives us.

exports.sendReminders = functions.https.onRequest(async (req, res) => {
  ...
  
  try {
    const tickets = await sendNotifications(expo, messages);
    // Convert the tickets we get from Expo into the data structure
    // that our model supports
    const formattedTickets = tickets.map((ticket, idx) => {
      const formattedTicket = {};
      formattedTicket.status = ticket.status;
      formattedTicket.expoPushToken = tokens[idx];
      if (ticket.id) formattedTicket.receiptId = ticket.id;
      if (ticket.message) formattedTicket.message = ticket.message;
      if (ticket.details) formattedTicket.details = ticket.details;
      return formattedTicket;
    });
    const response = await client.request(INSERT_TICKETS, { tickets: formattedTickets });
    console.log('response', response);
  } catch (error) {
    console.log('error', error);
  }
  ...
})
Saving expo tickets to our database

In the above snippet, we're extending our sendReminders function to also save the tickets once we send our notifications. We will later use the receiptId from our tickets to check whether our notifications were sent successfully or not.

Reading receipts

Now that we have saved our tickets and the corresponding receipt IDs, let's use them to get the receipts from Expo. The docs say that it might take up to 30 minutes when under load for the receipts to be available so let's be a little more generous and check the tickets that are more than an hour old.

Let's start by installing dayjs as it will be very useful to work with date and time objects, especially when it comes to sending things like reminders.

npm i dayjs

Now, let's write our cloud function to read receipts.

const dayjs = require('dayjs');
const utc = require('dayjs/plugin/utc');
dayjs.extend(utc)

const FETCH_TICKETS = `
query($time: timestamptz!) {
  tickets(where: {created_at: {_lte: $time}}) {
    expoPushToken
    receiptId
  }
}
`;

exports.readReceipts = functions.https.onRequest(async (req, res) => {
  if (req.method !== 'POST') {
    res.status(400).send('What are you even doing?');
    return;
  }

  // This line simply gets the current time in UTC
  // and formats it into a timestamp
  const time = dayjs.utc().subtract(1, 'hour').format();
  const ticketsResponse = client.request(FETCH_TICKETS, { time });
  const { tickets } = ticketsResponse;

  const receiptIds = [];
  for (ticket of tickets) {
    if (ticket.receiptId) receiptIds.push(ticket.receiptId);
  }

  const receiptIdChunks = expo.chunkPushNotificationReceiptIds(receiptIds);
  for (let chunk of receiptIdChunks) {
    try {
      let receipts = await expo.getPushNotificationReceiptsAsync(chunk);
      for (let receiptId in receipts) {
        let { status, message, details } = receipts[receiptId];
        if (status === 'ok') {
          continue;
        } else if (status === 'error') {
          console.error(
            `There was an error sending a notification: ${message}`
          );
          if (details && details.error) {
            console.error(`The error code is ${details.error}`);
            if (details.error === 'DeviceNotRegistered') {
              // Read Handling Errors section for this part
            } else {
              // Handle this separately. Maybe send them to an
              // error tracking tool like Sentry
            }
          }
        }
      }
    } catch (error) {
      console.error(error);
    }
  }
});
Reading Push Receipts

Okay, so let's go over this snippet now. First, we fetch all the tickets that are more than an hour old. Next, we fetch the receipts for all the tickets that have a receiptId. Then, for each receipt, we check if there was an error and if we do see an error, we check to see what type of error it was. You can read more about all the error types that Expo sends here and for most of them, I choose to log them in Sentry but one of them is very easy to handle and we'll talk about it in the next section.

Handling errors

So when we receive the DeviceNotRegistered error, Expo advises that we stop sending notifications to that specific push token. If we take a look at our tickets model, we can see that we map each expoPushToken to its corresponding ticket.receiptId and in turn to the ID of the receipt itself.

This way, once we run into an error, we can simply set the expoPushToken value to null for our user and because of the way we've set fetching push tokens up, their expoPushToken will automatically be updated whenever they reopen our app.

const NULLIFY_PUSH_TOKEN = `
mutation($token: String!) {
  update_users(where: {expoPushToken: {_eq: $token}}, _set: {expoPushToken: null}) {
    affected_rows
  }
}
`;

exports.readReceipts = functions.https.onRequest(async (req, res) => {
  ...
  
  if (details.error === 'DeviceNotRegistered') {
    const token = tickets.find(ticket => ticket.receiptId === receiptId).expoPushToken;
    const response = await client.request(NULLIFY_PUSH_TOKEN, { token });
    console.log('response', response);
  }
  ...
})
Nullify push token for users who cannot receive notifications

The last thing we need to do here is delete our stale tickets. If we don't delete them, our cloud function will keep finding them and run unnecessarily. So let's add a query to delete all the tickets that we've already read.

const DELETE_TICKETS = `
mutation($time: timestamptz!) {
  delete_ticketsOld(where: {created_at: {_lte: ""}}) {
    affected_rows
  }
}
`;

exports.readReceipts = functions.https.onRequest(async (req, res) => {
  ...
  // This is the same time variable we set up earlier
  // We will reuse this as we only want to delete the tickets
  // we've read the receipts for
  const time = dayjs.utc().subtract(1, 'hour').format();
  ...
  try {
    const response = await client.request(DELETE_TICKETS, { time });
    console.log('Delete tickets -> ', response);
  } catch (error) {
    console.log('Error deleting tickets', error);
  }
  ...
})
Deleting stale tickets

Here, we simply reuse the time variable we used for our first query and delete all the tickets that are more than an hour old. This way, we aren't checking any receipts that we've already read and dealt with and making sure we aren't adding any unnecessary load to our cloud function or our database.

Receiving push notifications

You can also handle how you want to receive notifications within your app and take custom actions based on the notification. For example, you can automatically navigate the user to a different screen based on what the notification is about. You can read more about it here and also see an example below.

...
import { Notifications } from 'expo';

const App = () => {
  ...
  const handleNotification = (notification) => {
    const { data } = notification;
    // A simple example of passing data as the value
    // of the screen you want the user to be navigated to
    // when they click on a notification
    if (data.screen) navigation.navigate(data.screen);
  };

  useEffect(() => {
    Notifications.addListener(handleNotification);
  }, []);
  
  ...
}

export default App;
Handling an incoming notification

In the above snippet, we set a listener in our App.js and check if an incoming notification wants us to take the user to a specific screen. If it does, we simply navigate the user.


I think that does it for this article. We went over the entire process of identifying our users in order to send them a push notification, to sending them the notification, handling the notification within their device and also handling any errors we might run into during the whole process.

As always, feel free to hit me up on Twitter with any questions you may have regarding this post. And, again, I am open to suggestions/ideas for other posts!

Tags:

Faraz Patankar

Probably obsessing over fantasy football, figuring out what to eat for my next meal or working on my next great (soon to be abandoned) side project but hello to you too! 🤗

More posts from this author

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.