DEV Community

Cover image for Meteor and React Native - Create a native mobile app
Jan Küster
Jan Küster

Posted on • Updated on

Meteor and React Native - Create a native mobile app

This is a full-scale workshop. Estimated time: 60min to 120min overall. We are going to create a working mobile app that includes authentication, a profile page and a managed ToDo-list. The app is developed with React-Native and uses a backend, implemented with Meteor.

Photo used by Rami Al-zayat on Unsplash

Quick link to the final workshop code:

GitHub logo jankapunkt / meteor-react-native-starter

The code repo for our workshop

Meteor React Native Starter

This is the final code repo for our workshop "Meteor and React Native" @ Meteor Impact 2022 After post-editing it resulted in a complete starter repo. 🤩

JavaScript Style Guide GitHub

Please note, that I can't cover all operating systems out there.

About

Meteor and React Native are not integrated with each other by default However, there are great packages out there, that help us to make them integrate The best is, it's actually not that difficult!

This starter brings the most basic integration for a Meteor project as a backend for your react native app. Just follow the instructions in this readme to get startet immediately.

preview

Installation

You need to have Meteor installed on your system. Follow the Meteor installation instructions on the Meteor website.

Clone the repo and checkout the workshop branch

$ git clone git@github.com:jankapunkt/meteor-react-native-workshop.git
# If you have no ssh access to GitHub, please use
Enter fullscreen mode Exit fullscreen mode

Background

This workshop is a result from my session at Meteor Impact 2022, which missed out the last part due to time constraints. Therefore I reviewed the workshop and the code and improved on everything. 👀

Get your Terminals and IDEs ready, grab a hot beverage and make yourself comfortable for a full hands-on session over the next two hours.

During the workshop we will cover these major topics:

  • installation of the dev environment
  • create the Meteor backend
  • create the React Native app
  • connect/reconnect the RN app to the backend
  • full authentication workflow using react navigation
  • CRUD a personal Tasks collection (using Meteor Methods and Publication/Subscription)

If you are new to Meteor or React Native, let me shortly introduce them to you (or skip to the next section if you already know).

Meteor icon

Meteor is a fullstack cross-platform framework to develop modern JavaScript applications. It can either be used with your favorite frontend engine/library to create SPAs (single page applications) with Websockets and Publish/Subscribe. Nevertheless, it also supports SSR, HTTP routes and integrates well with the vast majority of packages, installed from NPM, because under the hood it's all NodeJS.

react logo

React Native is also a cross-platform framework but for developing native mobile apps by using one of the world's most famous JavaScript libraries: React. But don't get it wrong! This is not just building some WebView-containered HTML5 app. It will render all your code using the mobile platform's native engine capabilities.

🤝 Together they provide a great developer experience for developing mobile apps. They both run on many platforms and deploy to different platforms at the same time. For example, we can run our development environment on Ubuntu Linux but run our development build on an iOS device.

You can also check out and contribute to their repositories on GitHub:

GitHub logo meteor / meteor

Meteor, the JavaScript App Platform


TravisCI Status CircleCI Status built with Meteor


Meteor is an ultra-simple environment for building modern web applications.



📚 Create your applications using modern JavaScript

Benefit from the latest technology updates to rapidly prototype and develop your applications.


Integrate technologies you already use

Use popular frameworks and tools right out-of-the-box. Focus on building features instead of configuring disparate components yourself.


💻 Build apps for any device

Use the same code whether you’re developing for web, iOS, Android, or desktop for a seamless update experience for your users.


🔥 Getting Started

How about trying a getting started tutorial in your favorite technology?

Next, read the documentation and get some examples.

🚀 Quick Start

On your platform, use this line:

> npm install -g meteor
Enter fullscreen mode Exit fullscreen mode

🚀 To create a project:

> meteor create my-app
Enter fullscreen mode Exit fullscreen mode

☄️ Run it:

cd my-app
meteor
Enter fullscreen mode Exit fullscreen mode

🧱 Developer Resources

Building an application with Meteor?

  • Deploy on…

GitHub logo facebook / react-native

A framework for building native applications using React

React Native

Learn once, write anywhere:
Build mobile apps with React

React Native is released under the MIT license. Current CircleCI build status. Current npm package version. PRs welcome! Follow @reactnative

Getting Started · Learn the Basics · Showcase · Contribute · Community · Support

React Native brings React's declarative UI framework to iOS and Android. With React Native, you use native UI controls and have full access to the native platform.

  • Declarative. React makes it painless to create interactive UIs. Declarative views make your code more predictable and easier to debug.
  • Component-Based. Build encapsulated components that manage their state, then compose them to make complex UIs.
  • Developer Velocity. See local changes in seconds. Changes to JavaScript code can be live reloaded without rebuilding the native app.
  • Portability. Reuse code across iOS, Android, and other platforms.

React Native is developed and supported by many companies and individual core contributors. Find out more in our ecosystem overview.

Contents


Architecture overview

One more thing, before we start with our hands-on workshop. Let me provide an overview for those of you who prefer a visual. Consider the Meteor app as server (backend) and the React Native app as one of it's clients:

architecture overview

As you can see the both will communicate using @meteorrn/core. The package implements most of the api as the Meteor client bundle on the web. However, it also provides an easy way to hook into some lower level API events that makes it in combination with useEffect very easy to act upon changes from the server.

The backend also manages the authentication via Accounts automatically, while we will use a Navigation with React contexts on the client. Notice, that authentication will also use features from @meteorrn/core that allow us to retrieve the login token to implement an "auto-login" functionality.

Now, let's finally start the actual workshop. ⌨


1. Prerequisites 🔧🪚🪛

In order to make the most out of the workshop you should gather at least one physical mobile device. It doesn't matter, whether it's Android or iOS or even both, React Native and Expo will take care of the bundling details.

1.1 Install Expo Go (or use emulators)

You should install the "Expo Go" app on these devices as it will be a huge help to us when we will preview our development builds. You can find the App in the stores by searching for "Expo Go" or directly under these links:

Expo Go for iOS

‎Expo Go on the App Store

‎Start building projects using web technologies with just your iOS device and your computer. Expo is a developer tool for creating experiences with interactive gestures and graphics using JavaScript and React. Note: some programming experience is recommended. Technical specs: this version of Expo us…

apps.apple.com

Expo Go for Android

Expo - Apps on Google Play

Expo is a free & open source platform to build apps using JavaScript and React.

favicon play.google.com

If one of the links won't work, you can also get them from the Expo website.

❗❗❗ Notice, that you will not necessarily have to signup to use the Expo Go app, even if it looks like that at a first glance.

Install emulators as alternative

If you really don't have access to a physical device or you face certain issues with your devices during this workshop you might consider installing the respective emulators.

The Expo devs provide a comprehensive guide on installing the Android emulator or iOS simulator for you.

1.2 Install Meteor

You can either install Meteor on various OS' and platforms via NPM (in case you have Node >= 14 and NPM already installed)

$ npm install -g meteor
Enter fullscreen mode Exit fullscreen mode

or using their provided shell script on Linux or MacOS:

$ curl https://install.meteor.com/ | sh
Enter fullscreen mode Exit fullscreen mode

If you want to install it on a Mac with M1, a docker container or Windows, please read the details from the official installation guide.

❗❗❗ Important
In this workshop you have to use meteor npm in your terminals from now on, instead of just npm. It is necessary to always use the correct linked binaries.
This is due to the Meteor tool ships with a bundled NPM that has it's own path on your system. It gets removed, including all installed dependencies, once you remove Meteor.

Verify Meteor and it's NPM via

$ meteor --version
$ meteor npm -v
Enter fullscreen mode Exit fullscreen mode

1.3 Install Expo CLI

React Native development is totally possible without Expo. However, with Expo it just starts to feel comfortable. No worries about building, no Android SDK or XCode to install. Hell, you can make a development build for iOS on a Linux machine. At that point they got me already.

📝 Notice

However, there are also a few downsides. First, you have to stick with the Expo Sdk and regularly update the Sdk.
Second, if you want to deploy your apps for the App Store / Play Store you will likely need to use their cloud services (EAS). Allthough they offer a free tier, it may introduce costs and finally, this is still a dependency to a third party when it comes to your deployment. The good side though is, that this services takes care of the really nasty sides of mobile app deployment, so it might not be that bad at all.

Enough talk, let's install the Expo CLI tool globally:

$ meteor npm install -g expo-cli
Enter fullscreen mode Exit fullscreen mode

❗❗❗ Important
This causes the expo cli to be available only in the Meteor namespace, so you will need to call meteor expo instead of just expo later on.

1.4 Create project repository or local folder

Once the tools are installed we can actually create the new projects. In order to keep track of everything you can either create a new empty GitHub repository and clone it locally or create a local folder that you later connect with an existing repository. In both ways, let's name the project mrntodos for this workshop.

1.4.a Clone from GitHub via SSH

$ git clone git@github.com:myusername/mrntodos.git
Enter fullscreen mode Exit fullscreen mode

1.4.b Clone from GitHub via HTTPS

$ git clone https://github.com/myusername/mrntodos.git
Enter fullscreen mode Exit fullscreen mode

1.4.c Create local folder

$ mkdir -p mrntodos
Enter fullscreen mode Exit fullscreen mode

Meteor icon

2. Create and set up the Meteor backend

Create a new Meteor project is straight forward using meteor create [options] [name]. However, with Meteor we have many choices for our frontend: React, Vue, Svelte, Solid (coming in 2.8), Blaze, Apollo, headless and some more (read the docs for all options). Meteor integrates them for you automatically on project creation.

📝 Notice
The term "backend" is a bit misleading here. The Meteor application is a fullstack app, providing a server and client build for you. However, for this workshop we will only use the server environment, making the Meteor app act as the "backend" for the mobile app.

2.1 Create a new Meteor app

For the workshop we use Meteor's default create command (which uses React as frontend):

$ cd mrntodos # if you are not already in the project folder
$ meteor create backend
$ cd backend
$ echo "{}" > settings.json
Enter fullscreen mode Exit fullscreen mode

We can configure global app settings in Meteor using a JSON file (backend/settings.json), that gets injected into the process environment by starting meteor with --settings. It comes in very handy when you have to manage multiple deployments that differ in their configuration.

In order to make the Meteor app automatically start on a custom port and to use our settings.json, let's change one of the scripts in backend/package.json:

...
"start": "meteor --port=8000 --settings=setting.json"
Enter fullscreen mode Exit fullscreen mode

Now start the Meteor app via

$ meteor npm start
Enter fullscreen mode Exit fullscreen mode

If you see the following message output, everything is fine and you can continue to the next section:

=> App running at: http://localhost:8000/
Enter fullscreen mode Exit fullscreen mode

2.2 Add authentication layer to the Meteor backend

Meteor provides core packages for zero- to minimal-config authentication, named Accounts. Using accounts-password you get an out-of-the-box OAuth2 authentication and the servers also uses bcrypt to encrypt the password on the server.

Accounts also integrate third-party authentication with Facebook, Twitter, Apple, GitHub or your custom OAuth2 provider. However, in this workshop we add and configure the most basic authentication method, using accounts-passwords:

$ meteor add accounts-password
Enter fullscreen mode Exit fullscreen mode

Accounts is also configurable (documentation) and we should change their defaults according to our needs. In order to do that, add a new file under backend/imports/startup/server/accounts.js (create these folders, if they don't exist yet). Then add the following to this file:

import { Accounts } from 'meteor/accounts-base'
import { Meteor } from 'meteor/meteor'

// Here we define the fields that are automatically 
// available to clients via Meteor.user().
// This extends the defaults (_id, username, emails) 
// by our profile fields.
// If you want your custom fields to be immediately 
// available then place them here.
const defaultFieldSelector = {
  _id: 1,
  username: 1,
  emails: 1,
  firstName: 1,
  lastName: 1
}


// merge our config from settings.json with fixed code
// and pass them to Accounts.config
Accounts.config({ 
  ...Meteor.settings.accounts.config,
  defaultFieldSelector 
})
Enter fullscreen mode Exit fullscreen mode

Now add the following parts to your settings.json file:

{
  "accounts": {
    "config": {
      "forbidClientAccountCreation": true,
      "ambiguousErrorMessages": true,
      "sendVerificationEmail": true,
      "loginExpirationInDays": null
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

All parameters of the configuration are documented, in case you want to further investigate on them.

📝 Notice
The accounts-base package is automatically added when accounts-password is added. When importing Accounts in your code you will use import { Accounts } from 'meteor/accounts-base'.

Finally, make sure our startup file is imported in backend/server/main.js. Open this file and replace it's default content with:

import '../imports/startup/server/accounts'
Enter fullscreen mode Exit fullscreen mode

2.3 Add registration Method endpoint

By default Meteor Accounts allow clients to register themselves via Accounts.createUser (docs). This is 1:1 supported in the @meteorrn/core package, too.

However, we want to have additional functionality during the registration process, like sending an enrollment email and adding default profile fields to the user document.

Therefore, we create a new registration endpoint as Meteor Method. We create a new file in backend/imports/accounts/methods.js and add the following code to it:

import { Accounts } from 'meteor/accounts-base'
import { check, Match } from 'meteor/check'

export const registerNewUser = function (options) {
  check(options, Match.ObjectIncluding({
    email: String,
    password: String,
    firstName: String,
    lastName: String,
    loginImmediately: Match.Maybe(Boolean)
  }))

  const { email, password, firstName, lastName, loginImmediately } = options

  if (Accounts.findUserByEmail(email)) {
    throw new Meteor.Error('permissionDenied', 'userExists', { email })
  }

  const userId = Accounts.createUser({ email, password })

  // we add the firstName and lastName as toplevel fields
  // which allows for better handling in publications
  Meteor.users.update(userId, { $set: { firstName, lastName } })

  // let them verify their new account, so
  // they can use the full app functionality
  Accounts.sendVerificationEmail(userId, email)

  if (loginImmediately) {
    // signature: { id, token, tokenExpires }
    return Accounts._loginUser(this, userId)
  }

  // keep the same return signature here to let clients
  // better handle the response
  return { id: userId, token: undefined, tokenExpires: undefined }
}
Enter fullscreen mode Exit fullscreen mode

Finally, make sure this file is imported at startup by creating a new Meteor Method with it in in our already created backend/imports/startup/server/accounts.js file:

import { Accounts } from 'meteor/accounts-base'
import { Meteor } from 'meteor/meteor'
import { registerNewUser } from '../../accounts/methods'

Accounts.config(Meteor.settings.accounts.config)
Meteor.methods({ registerNewUser })
Enter fullscreen mode Exit fullscreen mode

2.6 Add more endpoints to manage accounts

If you also want users to update their profile or delete their account then you need an endpoint Method for them, too.

Let's extend the backend/imports/accounts/methods.js file by these two methods:

// ... registerNewUser

export const updateUserProfile = function ({ firstName, lastName }) {
  check(firstName, Match.Maybe(String))
  check(lastName, Match.Maybe(String))

  // in a meteor Method we can access the current user
  // via this.userId which is only present when an
  // authenticated user calls a Method
  const { userId } = this

  if (!userId) {
    throw new Meteor.Error('permissionDenied', 'notAuthenticated', { userId })
  }

  const updateDoc = { $set: {} }

  if (firstName) {
    updateDoc.$set.firstName = firstName
  }

  if (lastName) {
    updateDoc.$set.lastName = lastName
  }

  return !!Meteor.users.update(userId, updateDoc)
}

export const deleteAccount = function () {
  const { userId } = this

  if (!userId) {
    throw new Meteor.Error('permissionDenied', 'notAuthenticated', { userId })
  }

  return !!Meteor.users.remove(userId)
}
Enter fullscreen mode Exit fullscreen mode

Finally, update the startup file at backend/imports/startup/server/accounts.js:

import { Accounts } from 'meteor/accounts-base'
import { Meteor } from 'meteor/meteor'
import { 
  registerNewUser,
  updateUserProfile,
  deleteAccount
 } from '../../accounts/methods'

// ... other code

Meteor.methods({
  registerNewUser,
  deleteAccount,
  updateUserProfile
})
Enter fullscreen mode Exit fullscreen mode

At this point you have the minimal api defined for your app to sign up, sign in, sign out (builtin, via Meteor.logout()), delete account and update profile. Let's continue by building the mobile app now.


react logo

3. Create and set up the React Native app

In this section we will create a new React Native app with an Expo-managed workflow. Make sure you have set up all required tools from section 1. If you have no physical device available, you can also install the Android emulator or iOS simulator (also covered in section 1).

3.1 Create the new React Native app

First of all, open a new terminal in order to install and run the app. The backend and the app will use separate node processes. Therefore, to manage them both effectively you should work on them in separate terminals.

If you haven't installed expo-cli yet or you have updated your Meteor version, then you need to install it via:

$ meteor npm install -g expo-cli
$ meteor expo --version
6.0.6 # at the time of this workshop
Enter fullscreen mode Exit fullscreen mode

Within the project root folder (mrntodos/), create a new expo project and answer the questions as following:

$ cd mrntodos # if not already there
$ meteor expo init
? What would you like to name your app? › app
? Choose a template: › - Use arrow-keys. Return to submit.
    ----- Managed workflow -----
❯   blank a minimal app as clean as an empty canvas
Enter fullscreen mode Exit fullscreen mode

📝 Notice, that you can also use different workflows, for example if you prefer to use TypeScript. Choose on your own or stick with this workshop's preference.

Then we also need to install some dependencies here that will be necessary for us during the workshop:

$ cd app
$ meteor expo install @meteorrn/core @react-navigation/native @react-navigation/native-stack @react-navigation/stack expo-secure-store expo-status-bar react-native-screens react-native-safe-area-context react-native-gesture-handler
Enter fullscreen mode Exit fullscreen mode

❗❗❗ Important
We need to use meteor expo install to install our dependencies. This is, because Expo resolves the correct dependency versions for the current Sdk for us. By doing so, we don't need to fiddle with package versions that may break our builds or are fundamentally incompatible.

After adding the package you may get some warnings from the npm audit. If you are like me and care about this, please help us to release the next version of @meteorrn/core with updated dependencies by testing the latest commits with your local build. Add your review or issues to: https://github.com/meteorrn/meteor-react-native

3.2 Set proper network settings

Connecting your app to the backend requires some further configuration. You need to obtain your local network ip in order to make the RN app connect. The Meteor-typical localhost will not work here, since this would not resolve to the same local ip when the code runs on the mobile device.

You can usually obtain your local ip via one of these commands:

os command
Linux ip addr show
MacOs ifconfig
Windows ipconfig

Once you obtained the ip (at home this is often a 192.168.178.XXX or similar) please create a config file with this value:

$ cd app # if not already there
$ echo "{}" > config.json
Enter fullscreen mode Exit fullscreen mode

Place the following content in there (where xxx.xxx.xxx.xxx is replaced by your obtained local ip):

{
  "backend": {
    "url": "ws://xxx.xxx.xxx.xxx:8000/websocket"
  }
}
Enter fullscreen mode Exit fullscreen mode

At this point, let's run the app the first time to let Expo generate a few files via:

$ meteor npm start
Enter fullscreen mode Exit fullscreen mode

Use your mobile device and scan the QR code with the Expo Go app. You should see now that bundling is in progress:

bundling in progress image

Once this is done your device should display the default content from app/App.js afterwards:

initial screen

After running, check the newly created app/.expo/settings.json file and make sure it looks like the following:

{
  "hostType": "lan",
  "lanType": "ip",
  "dev": true,
  "minify": false,
  "urlRandomness": "mc-y7b",
  "https": false,
  "scheme": null,
  "devClient": false
}
Enter fullscreen mode Exit fullscreen mode

3.3 Set Hermes as JavaScript engine

We can use a JavaScript engine, named Hermes, which is entirely optimized for React Native. In fact, we should. Consult the React Native docs page on Hermes to understand why.

In order to activate it with Expo, we need to edit the settings in app/app.json:

 "expo": {
    "name": "app",
    "slug": "app",
    "version": "1.0.0",
    "assetBundlePatterns": [
      "**/*"
    ],
    "jsEngine": "hermes"
  }
Enter fullscreen mode Exit fullscreen mode

That's all and Expo takes care of the rest for us.


4. Connect the app to the Meteor backend

Nearly all functionality of our app requires a connection to our backend. Meteor establishes connections and exchanges data via DDP, a custom protocol that builds on top of Websockets. The great thing is, that you don't have to worry about this whole transport layer as Meteor abstracts all of this logic for you so you can focus on the important things.

4.1 Write a connection hook

We want to remain flexible with the connection and the way we deal with it's state. This is why we will abstract it into a custom React hook, which we name useConnection.

Create a new file at app/src/hooks/useConnection.js and add the following code there:

import { useEffect, useState } from 'react'
import Meteor from '@meteorrn/core'
import * as SecureStore from 'expo-secure-store'
import config from '../../config.json'

// get detailed info about internals
Meteor.isVerbose = true

// connect with Meteor and use a secure store
// to persist our received login token, so it's encrypted
// and only readable for this very app
// read more at: https://docs.expo.dev/versions/latest/sdk/securestore/
Meteor.connect(config.backend.url, {
  AsyncStorage: {
    getItem: SecureStore.getItemAsync,
    setItem: SecureStore.setItemAsync,
    removeItem: SecureStore.deleteItemAsync
  }
})

export const useConnection = () => {
  const [connected, setConnected] = useState(null)
  const [connectionError, setConnectionError] = useState(null)

  // we use separate functions as the handlers, so they get removed
  // on unmount, which happens on auto-reload and would cause errors
  // if not handled
  useEffect(() => {
    const onError = (e) => setConnectionError(e)
    Meteor.ddp.on('error', onError)

    const onConnected = () => connected !== true && setConnected(true)
    Meteor.ddp.on('connected', onConnected)

    // if the connection is lost, we not only switch the state
    // but also force to reconnect to the server
    const onDisconnected = () => {
      Meteor.ddp.autoConnect = true
      if (connected !== false) {
        setConnected(false)
      }
      Meteor.reconnect()
    }
    Meteor.ddp.on('disconnected', onDisconnected)

    // remove all of these listeners on unmount
    return () => {
      Meteor.ddp.off('error', onError)
      Meteor.ddp.off('connected', onConnected)
      Meteor.ddp.off('disconnected', onDisconnected)
    }
  }, [])

  return { connected, connectionError }
}
Enter fullscreen mode Exit fullscreen mode

Classic Meteor + React provides a useTracker hook for Reactivity. In this case, we hook directly into the DDP events in order to update the connection state.

4.3 Integrate the useConnection hook into App.js

Let's move two levels up and open app/App.js, our main entry point for our React app. Here, we now integrate the useConnection hook and render messages, based on the connection state:

import React from 'react'
import { MainNavigator } from './src/screens/MainNavigator'
import { StyleSheet, View, Text, ActivityIndicator } from 'react-native'
import { useConnection } from './src/hooks/useConnection'

export default function App () {
  const { connected, connectionError } = useConnection()

  // use splashscreen here, if you like
  if (!connected) {
    return (
      <View style={styles.container}>
        <ActivityIndicator />
        <Text>Connecting to our servers...</Text>
      </View>
    )
  }

  // use alert or other things here, if you like
  if (connectionError) {
    return (
      <View style={styles.container}>
        <Text>Error, while connecting to our servers!</Text>
        <Text>{connectionError.message}</Text>
      </View>
    )
  }

  return (
      <View style={styles.container}>
        <Text>We are connected!</Text>
      </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#efefef',
    alignItems: 'center',
    justifyContent: 'center'
  }
})
Enter fullscreen mode Exit fullscreen mode

Start the app and see the We are connected! message on the screen. Now switch to the other terminal for our backend and hit ctrl+c to cancel the process. Take a look at your device - it will immediately show Connecting to our servers... as it tries to reconnect. Now start the backend server again and see the immediate change back to the connected state.

connecting to servers

You have a reactive connection status now 🥳 You could use this hook even further to build a offline-first app but that won't be covered by our workshop.

From here we can move on to the authentication workflow, but before that we need to do a short refactoring!

4.4 Tidy up code

There are a few elements that we will reuse a few more times in this app. It's a good practice to not repeat to write the same code all over again.

One potential saving is to create a default stylesheet, which you can compare to having a main.css file on the web. It helps to provide a consistent layout across our app and a single point to manage this layout.

Let's create a new file at app/src/styles/defaultStyles.js and place the following content there:

import { StyleSheet } from 'react-native'

export const defaultColors = {
  placeholder: '#8a8a8a',
  danger: '#981111',
  white: '#eee',
  black: '#1a1a1a',
  primary: '#0B52AF'
}

export const defaultStyles = StyleSheet.create({
  text: {
    height: 40,
    margin: 12,
    borderWidth: 1,
    padding: 10,
    alignSelf: 'stretch',
    color: defaultColors.black,
    backgroundColor: defaultColors.white
  },
  panel: {
    margin: 20
  },
  container: {
    margin: 20,
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center'
  },
  danger: {
    color: defaultColors.danger
  },
  dangerBorder: {
    borderWidth: 1,
    borderColor: defaultColors.danger
  },
  bold: {
    fontWeight: 'bold'
  },
  row: {
    flexDirection: 'row',
    alignItems: 'center'
  },
  flex1: {
    flex: 1
  }
})
Enter fullscreen mode Exit fullscreen mode

Second, let's create a new ErrorMessage component. It will also use some of the default styles already. Create the new file at app/src/components/ErrorMessage.js and add the following code to it:

import React from 'react'
import { Text, View } from 'react-native'
import { defaultStyles } from '../styles/defaultStyles'

export const ErrorMessage = ({ error, message }) => {
  if (!error && !message) { return null }

  return (
    <View style={defaultStyles.container}>
      <Text style={defaultStyles.danger}>{message || error.message}</Text>
    </View>
  )
}
Enter fullscreen mode Exit fullscreen mode

With both of these abstractions we can, again, update app/App.js and replace

// use alert or other things here, if you like
if (connectionError) {
  return (
    <View style={styles.container}>
      <Text>Error, while connecting to our servers!</Text>
      <Text>{connectionError.message}</Text>
    </View>
  )
}
Enter fullscreen mode Exit fullscreen mode

with

// use alert or other things here, if you like
if (connectionError) {
  return (<ErrorMessage error={connectionError} />)
}
Enter fullscreen mode Exit fullscreen mode

5. Implement the authentication workflow

One of the most important parts of our application is to provide a fluent and accessible authentication workflow. Users should not have to sign in each time they use the app. Rather a login token should be stored securely (which is why we installed expo-secure-store) and used until expiration (which we defined in section 2 using Accounts.config).

If users don't have an account, they should sign up by providing email, password, first name and last name. Once the account is created, they should receive the login token and become signed-in automatically. Finally, users should be able to sign out and delete their account.

The following graphics summarizes the auth workflow from a more abstract perspective, including the screens to navigate:

authentication workflow

This workflow is inspired by the React Nativation authentication workflow and we will use this library to implement the workflow within our own environmental boundaries.

5.1 Create the authentication context and api layer

As with the connection we want to decouple authentication from our rendering logic as much as possible in React. This also helps to keep the project not tightly coupled to any Meteor logic. At the same time we want to have a unified place of where the authentication state is managed.

5.1.1 Create a new authentication context

React contexts provides a way to access our auth layer without the need to passing it down through the whole component tree and helps to prevent tight coupling. Read more in the React docs on contexts, if you want to understand how it works in details.

Since there will be multiple screens, that make use of our authentication, we create and export our authentication context at app/src/contexts/AuthContext.js:

import { createContext } from 'react'

export const AuthContext = createContext()
Enter fullscreen mode Exit fullscreen mode

5.2.2 Create a useAuth hook

The whole "magic" will happen here. This is where calls from the components will be passed through as DDP requests to the backend and responses will update the state accordingly. In contrast, errors will be passed back to the components to make them render error messages.

Due to the all this resulting in a more complex state we should use a React reducer. Our authentication functions should also be only created once, which is why we wrap them in a useMemo hook.

Create the file app/src/hooks/useAuth.js and add the following content:

import { useReducer, useEffect, useMemo } from 'react'
import Meteor from '@meteorrn/core'

const initialState = {
  isLoading: true,
  isSignout: false,
  userToken: null
}

const reducer = (state, action) => {
  switch (action.type) {
    case 'RESTORE_TOKEN':
      return {
        ...state,
        userToken: action.token,
        isLoading: false
      }
    case 'SIGN_IN':
      return {
        ...state,
        isSignOut: false,
        userToken: action.token
      }
    case 'SIGN_OUT':
      return {
        ...state,
        isSignout: true,
        userToken: null
      }
  }
}

const Data = Meteor.getData()


export const useAuth = () => {
  const [state, dispatch] = useReducer(reducer, initialState, undefined)

  // Case 1: restore token already exists
  // MeteorRN loads the token on connection automatically,
  // in case it exists, but we need to "know" that for our auth workflow
  useEffect(() => {
    const handleOnLogin = () => dispatch({ type: 'RESTORE_TOKEN', token: Meteor.getAuthToken() })
    Data.on('onLogin', handleOnLogin)
    return () => Data.off('onLogin', handleOnLogin)
  }, [])


  const authContext = useMemo(() => ({
    signIn: ({ email, password, onError }) => {
      Meteor.loginWithPassword(email, password, async (err) => {
        if (err) {
          if (err.message === 'Match failed [400]') {
            err.message = 'Login failed, please check your credentials and retry.'
          }
          return onError(err)
        }
        const token = Meteor.getAuthToken()
        const type = 'SIGN_IN'
        dispatch({ type, token })
      })
    },
    signOut: ({ onError }) => {
      Meteor.logout(err => {
        if (err) {
          return onError(err)
        }
        dispatch({ type: 'SIGN_OUT' })
      })
    },
    signUp: ({ email, password, firstName, lastName, onError }) => {
      const signupArgs = { email, password, firstName, lastName, loginImmediately: true }

      Meteor.call('registerNewUser', signupArgs, (err, credentials) => {
        if (err) {
          return onError(err)
        }

        // this sets the { id, token } values internally to make sure
        // our calls to Meteor endpoints will be authenticated
        Meteor._handleLoginCallback(err, credentials)

        // from here this is the same routine as in signIn
        const token = Meteor.getAuthToken()
        const type = 'SIGN_IN'
        dispatch({ type, token })
      })
    },
    deleteAccount: ({ onError }) => {
      Meteor.call('deleteAccount', (err) => {
        if (err) {
          return onError(err)
        }

        // removes all auth-based data from client
        // as if we would call signOut
        Meteor.handleLogout()
        dispatch({ type: 'SIGN_OUT' })
      })
    }
  }), [])

  return { state, authContext }
}
Enter fullscreen mode Exit fullscreen mode

📝 Notice, that this file requires some explanation, of course.

  • the reducer handles the internal state, manipulated via dispatch calls
  • the @meteorrn/core packages will (once connected) automatically check for an existing token in the provided secure store; if found, it will try to login with token and emit a ' onLogin' event, if that succeeded; we can leverage this in the useEffect hook to implement our "auto-login" feature
  • the authContext is retrieved in screens with the previously created AuthContext and useContext as you will see in the upcoming sections
  • the several functions signIn, signOut, signUp and deleteAccounts leverage mostly what the @meteorrn/core package uses internally during Meteor.loginWithPassword or Meteor.logout
  • you could replace their code entirely if you need to migrate off Meteor (I hope you don't ❤️)

5.2 Create the screens

In the following step we will create all necessary screens that are involved in the workflow.

All screens should be created in app/src/screens, which you can create via

$ cd app # if not already in app folder
$ mkdir -p src/screens
Enter fullscreen mode Exit fullscreen mode

5.2.1 HomeScreen

The home screen at app/src/screens/HomeScreen.js will contain nothing but a message for now:

import React from 'react'
import { View, Text } from 'react-native'
import { defaultStyles } from '../styles/defaultStyles'

export const HomeScreen = () => {
  return (
    <View style={defaultStyles.container}>
      <Text>Welcome home!</Text>
    </View>
  )
}
Enter fullscreen mode Exit fullscreen mode

5.2.2 LoginScreen

The login screen provides a simple input for email and password, where the password field uses secureTextEntry to hide characters.

If the authentication fails, there should be an error message being displayed. If the user no account yet, the "Sign up" button should trigger the navigation to signup:

import React, { useState, useContext } from 'react'
import { View, Text, TextInput, Button } from 'react-native'
import { AuthContext } from '../contexts/AuthContext'
import { defaultColors, defaultStyles } from '../styles/defaultStyles'
import { ErrorMessage } from '../components/ErrorMessage'

export const LoginScreen = ({ navigation }) => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState(null)
  const { signIn } = useContext(AuthContext)

  // handlers
  const onError = err => setError(err)
  const onSignIn = () => signIn({ email, password, onError })
  const onSignUp = () => navigation.navigate('SignUp')

  // render login form
  return (
    <View style={defaultStyles.container}>
      <TextInput
        placeholder='Your Email'
        placeholderTextColor={defaultColors.placeholder}
        style={defaultStyles.text}
        value={email}
        onChangeText={setEmail}
      />
      <TextInput
        placeholder='Password'
        placeholderTextColor={defaultColors.placeholder}
        style={defaultStyles.text}
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      <ErrorMessage error={error} />
      <Button title='Sign in' color={defaultColors.primary} onPress={onSignIn} />
      <View style={defaultStyles.panel}>
        <Text>or</Text>
      </View>
      <Button title='Sign up' onPress={onSignUp} color={defaultColors.placeholder} />
    </View>
  )
}
Enter fullscreen mode Exit fullscreen mode

📝 Notice two things here.

First, the signIn method will be provided by useContext, where AuthContext acts as kind of a 'key' to make react return the correct value. Second, the component props will contain a navigation property, which is always injected when we use React Navigation.

5.2.3 RegistrationScreen

The register screen provides a similar form but will use a different auth method:

import React, { useContext, useState } from 'react'
import { TextInput, Button, View } from 'react-native'
import { defaultColors, defaultStyles } from '../styles/defaultStyles'
import { AuthContext } from '../contexts/AuthContext'
import { ErrorMessage } from '../components/ErrorMessage'

export const RegistrationScreen = () => {
  const [email, setEmail] = useState()
  const [firstName, setFirstName] = useState()
  const [lastName, setLastName] = useState()
  const [password, setPassword] = useState()
  const [error, setError] = useState()
  const { signUp } = useContext(AuthContext)

  const onError = err => setError(err)
  const onSignUp = () => signUp({ email, password, firstName, lastName, onError })

  return (
    <View style={defaultStyles.container}>
      <TextInput
        placeholder='Your Email'
        placeholderTextColor={defaultColors.placeholder}
        style={defaultStyles.text}
        value={email}
        onChangeText={setEmail}
      />
      <TextInput
        placeholder='Your password'
        placeholderTextColor={defaultColors.placeholder}
        style={defaultStyles.text}
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      <TextInput
        placeholder='Your first name (optional)'
        placeholderTextColor={defaultColors.placeholder}
        style={defaultStyles.text}
        value={firstName}
        onChangeText={setFirstName}
      />
      <TextInput
        placeholder='Your last name (optional)'
        placeholderTextColor={defaultColors.placeholder}
        style={defaultStyles.text}
        value={lastName}
        onChangeText={setLastName}
      />
      <ErrorMessage error={error} />
      <Button title='Create new account' onPress={onSignUp} />
    </View>
  )
}
Enter fullscreen mode Exit fullscreen mode

5.2.4 ProfileScreen

The profile screen will only be available, once authenticated. For now we create on this screen only the sign-out and delete-account features.

import { defaultColors, defaultStyles } from '../styles/defaultStyles'
import { Button, Text, View } from 'react-native'
import { useState } from 'react'
import { ErrorMessage } from '../components/ErrorMessage'

export const ProfileScreen = () => {
  const [error, setError] = useState(null)
  const handleSignOut = () => console.log('sign out')
  const handleDelete = () => console.log('delete account')


  return (
    <View style={defaultStyles.container}>
      <View style={{ ...defaultStyles.dangerBorder, padding: 10, marginTop: 10, alignSelf: 'stretch' }}>
        <Text style={defaultStyles.bold}>Danger Zone</Text>
        <Button title='Sign out' color={defaultColors.danger} onPress={handleSignOut} />
        <Button title='Delete account' color={defaultColors.danger} onPress={handleDelete} />
        <ErrorMessage error={error} />
      </View>
    </View>
  )
}
Enter fullscreen mode Exit fullscreen mode

5.3 Implement the navigation

As you might have realized already there are no "cancel" or "back" buttons on the screens and no "titles" rendered. This will all be handled by our React Navigation library. It allows us to remain flexible in the way screens are connected to each other.

On top of that, it helps us to render different screens, based on our authentication state and provides options on how the navigation bar is rendered.

5.3.1 Create the main navigation

Let's create the navigation at app/src/screens/MainNavigator.js and add the following code:

import React from 'react'
import { CardStyleInterpolators } from '@react-navigation/stack'
import { AuthContext } from '../contexts/AuthContext'
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { useAuth } from '../hooks/useAuth'
import { HomeScreen } from './HomeScreen'
import { LoginScreen } from './LoginScreen'
import { RegistrationScreen } from './RegistrationScreen'
import { ProfileScreen } from './ProfileScreen'
import { NavigateButton } from '../components/NavigateButton'

const Stack = createNativeStackNavigator()

export const MainNavigator = () => {
  const { state, authContext } = useAuth()
  const { userToken } = state

  const renderScreens = () => {
    if (userToken) {
      // only authenticated users can visit these screens
      const headerRight = () => (<NavigateButton title='My profile' route='Profile' />)
      return (
        <>
          <Stack.Screen name='Home' component={HomeScreen} options={{ title: 'Welcome home', headerRight }} />
          <Stack.Screen name='Profile' component={ProfileScreen} options={{ title: 'Your profile' }} />
        </>
      )
    }

    // non authenticated users need to sign in or register
    // and can only switch between the two screens below:
    return (
      <>
        <Stack.Screen
          name='SignIn'
          component={LoginScreen}
          options={{ title: 'Sign in to awesome-app' }}
        />
        <Stack.Screen
          name='SignUp'
          component={RegistrationScreen}
          options={{ title: 'Register to awesome-app' }}
        />
      </>
    )
  }

  return (
    <AuthContext.Provider value={authContext}>
      <NavigationContainer>
        <Stack.Navigator screenOptions={{ cardStyleInterpolator: CardStyleInterpolators.forVerticalIOS }}>
          {renderScreens()}
        </Stack.Navigator>
      </NavigationContainer>
    </AuthContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Again, this code needs some further explanation:

  • the Stack is a navigator that renders a native push/pop animation when navigation back and forth; it looks really good out-of-the-box
  • state is the current state from the reducer in useAuth, while the authContext is injected into our components tree via <AuthContext.Provider value={authContext}>; without this it would not be accessible within the screens using useContext
  • the userToken is only present, when @meteorrn/core received a token or loaded it from the secure store and sucessfully signed in with it

5.3.2 Create a navigation button component

After adding the navigation, please also add a new NavigateButton component at app/src/components/NavigateButton.js and add the following code to it:

import { Button } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { defaultColors } from '../styles/defaultStyles'

export const NavigateButton = ({ title, route }) => {
  const navigation = useNavigation()

  return (
    <Button
      title={title}
      color={defaultColors.primary}
      onPress={() => navigation.navigate(route)}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

It helps us to avoid passing down navigation through the whole components tree and handles routing internally.

5.3.3 Integrate the navigation into App.js

There will be nothing new, if we start the app at this stage. This is because we need to integrate the MainNavigation into our App. Therefore, update app/App.js to the following final code:

import React from 'react'
import { MainNavigator } from './src/screens/MainNavigator'
import { View, Text, ActivityIndicator } from 'react-native'
import { useConnection } from './src/hooks/useConnection'
import { ErrorMessage } from './src/components/ErrorMessage'
import { defaultStyles } from './src/styles/defaultStyles'

export default function App () {
  const { connected, connectionError } = useConnection()

  // use splashscreen here, if you like
  if (!connected) {
    return (
      <View style={defaultStyles.container}>
        <ActivityIndicator />
        <Text>Connecting to our servers...</Text>
      </View>
    )
  }

  // use alert or other things here, if you like
  if (connectionError) {
    return (<ErrorMessage error={connectionError} />)
  }

  return (<MainNavigator />)
}
Enter fullscreen mode Exit fullscreen mode

5.4 Test all workflow steps

At this point you should be able to run the app and test the whole authentication workflow. Here are some screenshots of what to expect:

5.4.1 LoginScreen

Plain on enter:

sign in screen

When sign in failed:

sign in failed

5.4.2 RegistrationScreen

register screen

5.4.3 HomeScreen

home screen

5.4.4 ProfileScreen

profile screen

5.5 Add a minimal user profile

This step provides an important insight on handling data reactivity from the Meteor backend in a way, that decouples from the rendering.

We want to update our user's profile (right now just the firstName and lastName fields) and reflect these changes immediately without the need to manually fetch the updated profile!

5.5.1 Create a useAccount hook

In order to do that we first create a new hook, named useAccount at app/src/hooks/useAccount.js and add the following code:

import Meteor from '@meteorrn/core'
import { useMemo, useState } from 'react'

const { useTracker } = Meteor

export const useAccount = () => {
  const [user, setUser] = useState(Meteor.user())

  useTracker(() => {
    const reactiveUser = Meteor.user()
    if (reactiveUser !== user) {
      setUser(reactiveUser)
    }
  })

  const api = useMemo(() => ({
    updateProfile: ({ options, onError, onSuccess }) => {
      Meteor.call('updateUserProfile', options, (err) => {
        return err
          ? onError(err)
          : onSuccess()
      })
    }
  }), [])

  return { user, ...api }
}
Enter fullscreen mode Exit fullscreen mode

With this hook we create a similar (but not same) approach as with our auth context. However, in this case we want to keep the updateProfile functionality close to the user profile and therefore won't use a context here.

Note the useTracker, which is one of the fundamental pillars of Meteor's reactivity model. In combination with publish/subscribe we can receive an updated user profile in the moment the server updated it on the db-level.

📝 Notice, that you have not subscribed to any publications, yet the user document automatically updates when changed on the server. This is due to Meteor always auto-publishes changes to a user's own document and why we set defaultPublishFields for Accounts.config in section 2.

5.5.2 Integrate the useAccount hook into the ProfileScreen

In order to update the profile fields we need to provide another simple form and handle some of it's state. I won't go too deep into details here as this is only apply the dry principle to have a form for multiple fields.

This is now the updated app/screens/ProfileScreen.js file:

import { AuthContext } from '../contexts/AuthContext'
import { defaultColors, defaultStyles } from '../styles/defaultStyles'
import { Button, Text, TextInput, View, StyleSheet } from 'react-native'
import { useContext, useState } from 'react'
import { ErrorMessage } from '../components/ErrorMessage'
import { useAccount } from '../hooks/useAccount'

export const ProfileScreen = () => {
  const [editMode, setEditMode] = useState('')
  const [editValue, setEditValue] = useState('')
  const [error, setError] = useState(null)
  const { signOut, deleteAccount } = useContext(AuthContext)
  const { user, updateProfile } = useAccount()
  const onError = err => setError(err)

  if (!user) {
    return null // if sign our or delete
  }

  /**
   * Updates a profile field from given text input state
   * by sending update data to the server and let hooks
   * reactively sync with the updated user document. *magic*
   * @param fieldName {string} name of the field to update
   */
  const updateField = ({ fieldName }) => {
    const options = {}
    options[fieldName] = editValue
    const onSuccess = () => {
      setError(null)
      setEditValue('')
      setEditMode('')
    }
    updateProfile({ options, onError, onSuccess })
  }

  const renderField = ({ title, fieldName }) => {
    const value = user[fieldName] || ''

    if (editMode === fieldName) {
      return (
        <>
          <Text style={styles.headline}>{title}</Text>
          <View style={defaultStyles.row}>
            <TextInput
              placeholder={title}
              autoFocus
              placeholderTextColor={defaultColors.placeholder}
              style={{ ...defaultStyles.text, ...defaultStyles.flex1 }}
              value={editValue}
              onChangeText={setEditValue}
            />
            <ErrorMessage error={error} />
            <Button title='Update' onPress={() => updateField({ fieldName })} />
            <Button title='Cancel' onPress={() => setEditMode('')} />
          </View>
        </>
      )
    }

    return (
      <>
        <Text style={styles.headline}>{title}</Text>
        <View style={{ ...defaultStyles.row, alignSelf: 'stretch' }}>
          <Text style={{ ...defaultStyles.text, flexGrow: 1 }}>{user[fieldName] || 'Not yet defined'}</Text>
          <Button
            title='Edit' onPress={() => {
              setEditValue(value)
              setEditMode(fieldName)
            }}
          />
        </View>
      </>
    )
  }

  return (
    <View style={defaultStyles.container}>
      <Text style={styles.headline}>Email</Text>
      <Text style={{ ...defaultStyles.text, alignSelf: 'stretch' }}>{user.emails[0].address}</Text>

      {renderField({ title: 'First Name', fieldName: 'firstName' })}
      {renderField({ title: 'Last Name', fieldName: 'lastName' })}

      <Text style={styles.headline}>Danger Zone</Text>
      <View style={{ ...defaultStyles.dangerBorder, padding: 10, marginTop: 10, alignSelf: 'stretch' }}>
        <Button title='Sign out' color={defaultColors.danger} onPress={() => signOut({ onError })} />
        <Button title='Delete account' color={defaultColors.danger} onPress={() => deleteAccount({ onError })} />
        <ErrorMessage error={error} />
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  headline: {
    ...defaultStyles.bold,
    alignSelf: 'flex-start'
  }
})
Enter fullscreen mode Exit fullscreen mode

6. Simple ToDos with CRUD functionality

This could now be the early end of the workshop, if you intend to go another path and continue from here with your own code. However, for those who want to stay and implement a simple ToDo app, hang tight. It's just another few steps.

6.1 Add Tasks functionality to the backend

The very heart of our app's functionality is always what the backend provides. Therefore we start by defining in the backend what Methods and Publications are actually available.

6.1.1 Add a new Tasks collection

We begin by creating a new Mongo collection, which is in Meteor just one line of code. Add the following code to backend/imports/tasks/TasksCollection.js:

import { Mongo } from 'meteor/mongo'

export const TasksCollection = new Mongo.Collection('tasks')
Enter fullscreen mode Exit fullscreen mode

6.1.2 Add Task Method endpoints and Publications

Client's should be able to create new tasks, update their checked state or remove them. Data fetching should be realized by publish/subscribe.

A simple implementation could look the the following backend/imports/tasks/methods.js file:

import { Meteor } from 'meteor/meteor'
import { TasksCollection } from './TasksCollection'

export const getMyTasks = function () {
  const userId = this.userId
  checkUser(userId)
  return TasksCollection.find({ userId })
}

export const insertTask = function ({ text }) {
  const userId = this.userId
  checkUser(userId)
  const checked = false
  const createdAt = new Date()
  return TasksCollection.insert({ text, userId, checked, createdAt })
}

export const checkTask = function ({ _id, checked }) {
  const userId = this.userId
  checkUser(userId)
  return TasksCollection.update({ _id, userId }, { $set: { checked } })
}

export const removeTask = function ({ _id }) {
  const userId = this.userId
  checkUser(userId)
  return TasksCollection.remove({ _id, userId })
}

const checkUser = userId => {
  if (!userId) {
    throw new Meteor.Error('permissionDenied', 'notSignedIn', { userId })
  }
}
Enter fullscreen mode Exit fullscreen mode

6.1.3 Add Tasks to the startup

First, register the Methods and Publication in a new file, backend/imports/startup/server/tasks.js:

import { Meteor } from 'meteor/meteor'
import { checkTask, insertTask, getMyTasks, removeTask } from '../../tasks/methods'

Meteor.methods({
  'tasks.insert': insertTask,
  'tasks.setIsChecked': checkTask,
  'tasks.remove': removeTask
})

Meteor.publish('tasks.my', getMyTasks)
Enter fullscreen mode Exit fullscreen mode

Finally, make sure this file is imported in backend/server/main.js:

import '../imports/startup/server/accounts'
import '../imports/startup/server/tasks'
Enter fullscreen mode Exit fullscreen mode

At this point the backend is ready to handle your personal tasklists aka "simple todos".

6.2 Add Tasks functionality to the app

This section involves the communication with the backend and rendering of the tasks. It contains a bit more code than the previous one. However, we will introduce only one new concept, the Meteor.subscribe mechanism. Apart from that, it's all things you already know by now.

6.2.1 Install Checkbox component

Prior to this workshop React Native contained an own Checkbox component, which is now deprecated. While there are multiple UI component systems out there that provide great checkboxes, let's stick with our current environment and use one, that Expo provides:

$ meteor expo install expo-checkbox
Enter fullscreen mode Exit fullscreen mode

6.2.2 Add a client Mongo Collection

One of the core aspects of Meteor is isomorphism - certain concepts implement the same API and behavior on the client as on the server. The same applies to the data handling, which is realized by "Minimongo" a lightweight counterpart to the server's Mongo Collections. It is a core part of Meteor's publish-subscribe system and a source for data reactivity.

In order to make use of these effects, the client collection needs to be named the same way as the collection in the server publication. Add therefore the following code to the new file app/src/tasks/TasksCollection.js:

import { Mongo } from '@meteorrn/core'

export const TasksCollection = new Mongo.Collection('tasks')
Enter fullscreen mode Exit fullscreen mode

6.2.3 Create a Task component

Since we are creating a Task List it is good to have each Task rendered using an own component. It should display the task's text and checked status and also provide a way to pass up some actions to the parents. For the checkbox we will use the previously installed expo-checkbox.

Create a new file app/src/tasks/Task.js with the following content:

import React from 'react'
import { View, Text, Button, StyleSheet } from 'react-native'
import Checkbox from 'expo-checkbox'
import { defaultColors } from '../styles/defaultStyles'

export const Task = ({ task, onCheckboxClick, onDeleteClick }) => {
  const handleCheck = (checked) => onCheckboxClick({ _id: task._id, checked })

  return (
    <View style={{ display: 'flex', flexDirection: 'row', width: '100%', padding: 5, justifyContent: 'space-between' }}>
      <View style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
        <Checkbox
          value={task.checked}
          onValueChange={handleCheck}
          style={{ padding: 12 }}
          color={task.checked && defaultColors.placeholder}
          readOnly
        />
        <Text style={task.checked ? styles.checked : styles.unchecked}>{task.text}</Text>
      </View>
      <Button title='X' onPress={() => onDeleteClick(task)} style={{ justifySelf: 'flex-end' }} />
    </View>
  )
}

const styles = StyleSheet.create({
  checked: {
    color: defaultColors.placeholder,
    marginLeft: 10
  },
  unchecked: {
    marginLeft: 10
  }
})
Enter fullscreen mode Exit fullscreen mode

In this case we will bubble-up the handlers to the parent, because of the way we will render the task items in the list.

6.2.4 Create a Task form

We also need a form to create new tasks. It is very similar to what we already did before. Create a new file at app/src/tasks/TaskForm.js and add the following content:

import Meteor from '@meteorrn/core'
import React, { useState } from 'react'
import { View, Button, TextInput } from 'react-native'
import { defaultColors, defaultStyles } from '../styles/defaultStyles'
import { ErrorMessage } from '../components/ErrorMessage'

export const TaskForm = () => {
  const [text, setText] = useState('')
  const [error, setError] = useState('')
  const handleSubmit = e => {
    e.preventDefault()
    if (!text) return
    Meteor.call('tasks.insert', { text }, (err) => {
      if (err) {
        return setError(err)
      }
      setError(null)
    })
    setText('')
  }

  return (
    <View>
      <View style={defaultStyles.row}>
        <TextInput
          placeholder='Type to add new tasks'
          value={text}
          place
          onChangeText={setText}
          placeholderTextColor={defaultColors.placeholder}
          style={defaultStyles.text}
        />
        <Button title='Add Task' onPress={handleSubmit} />
      </View>
      <ErrorMessage error={error} />
    </View>
  )
}
Enter fullscreen mode Exit fullscreen mode

6.2.5 Combine them in a Task list component

Now it's time to create a new component at app/src/tasks/TaskList.js and make all the prior created files work together, providing the complete CRUD functionality for our simple todos.

import Meteor from '@meteorrn/core'
import React, { useState } from 'react'
import { Text, View, SafeAreaView, FlatList, Button, ActivityIndicator } from 'react-native'
import { TasksCollection } from './TasksCollection'
import { Task } from './Task'
import { TaskForm } from './TaskForm'
import { useAccount } from '../hooks/useAccount'
import { defaultColors, defaultStyles } from '../styles/defaultStyles'

const { useTracker } = Meteor
const toggleChecked = ({ _id, checked }) => Meteor.call('tasks.setIsChecked', { _id, checked })
const deleteTask = ({ _id }) => Meteor.call('tasks.remove', { _id })

export const TaskList = () => {
  const { user } = useAccount()
  const [hideCompleted, setHideCompleted] = useState(false)

  // prevent errors when authentication is complete but user is not yet set
  if (!user) { return null }

  const hideCompletedFilter = { checked: { $ne: true } }
  const userFilter = { userId: user._id }
  const pendingOnlyFilter = { ...hideCompletedFilter, ...userFilter }

  const { tasks, pendingTasksCount, isLoading } = useTracker(() => {
    const tasksData = { tasks: [], pendingTasksCount: 0 }

    if (!user) {
      return tasksData
    }

    const handler = Meteor.subscribe('tasks.my')

    if (!handler.ready()) {
      return { ...tasksData, isLoading: true }
    }

    const filter = hideCompleted
      ? pendingOnlyFilter
      : userFilter
    const tasks = TasksCollection.find(filter, { sort: { createdAt: -1 } }).fetch()
    const pendingTasksCount = TasksCollection.find(pendingOnlyFilter).count()

    return { tasks, pendingTasksCount }
  }, [hideCompleted])

  if (isLoading) {
    return (
      <View style={defaultStyles.container}>
        <ActivityIndicator />
        <Text>Loading tasks...</Text>
      </View>
    )
  }

  const pendingTasksTitle = `${pendingTasksCount ? ` (${pendingTasksCount})` : ''}`

  return (
    <SafeAreaView style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      <View style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: 'scroll' }}>
        <View style={defaultStyles.row}>
          <Text>My Tasks {pendingTasksTitle}</Text>
          <TaskForm />
        </View>
        <Button
          title={hideCompleted ? 'Show All' : 'Hide Completed Tasks'}
          color={defaultColors.placeholder}
          onPress={() => setHideCompleted(!hideCompleted)}
        />
        <FlatList
          data={tasks}
          renderItem={({ item: task }) => (
            <Task
              task={task}
              onCheckboxClick={toggleChecked}
              onDeleteClick={deleteTask}
            />
          )}
          keyExtractor={task => task._id}
        />

      </View>
    </SafeAreaView>
  )
}
Enter fullscreen mode Exit fullscreen mode

The Meteor magic happens here within the useTracker hook. There we subscribe to the backend's publication tasks.my and apply optional post-filter to the retrieved data. We do all that by manipulation the live-synced Mongo.Collection. The useTracker will also trigger a new render cycle on data updates and this provides the "automagical" feeling of live updates.

One last step and we are finally done! Make sure, the TaskList is included in the HomeScreen:

import React from 'react'
import { View } from 'react-native'
import { defaultStyles } from '../styles/defaultStyles'
import { TaskList } from '../tasks/TaskList'

export const HomeScreen = () => {
  return (
    <View style={defaultStyles.container}>
      <TaskList />
    </View>
  )
}
Enter fullscreen mode Exit fullscreen mode

6.3 Finally celebrate your first app 🥳

final image


7. Summary and outlook

In this workshop we created a fully functional mobile app with React Native and Meteor as it's backend. It tackles the biggest challenges of making them both work together - connection, authentication and communication.

There are many topics to continue from here, since this is just a minimal app prototype. Many topics can't be covered in just one workshop, which is why I tried to shrink them down to the "essentials". If there are any uncovered topics that you think are essential, too, then please leave a comment.

If you liked the workshop, please like and subscribe to my channel.


Further Resources and links

Meteor

React Native


About me

I regularly publish articles here on dev.to about Meteor and JavaScript. If you like what you are reading and want to support me, you can sponsor me on GitHub or send me a tip via PayPal.

You can also find (and contact) me on GitHub, Twitter and LinkedIn.

Keep up with the latest development on Meteor by visiting their blog and if you are the same into Meteor like I am and want to show it to the world, you should check out the Meteor merch store.

Oldest comments (10)

Collapse
 
bratelefant profile image
bratelefant

Great to read some recent and well written code with meteor+rn 🥳

Any plans on a follow up covering offline first (maybe also with non reactive data fetching via methods)? Asking for a friend 😉

Collapse
 
jankapunkt profile image
Jan Küster

We (the research group I am working at) are still in the phase of reviewing options for offline and may soon begin our work. Once we have something stable I will write about it. Realm DB is currently my favorite but I have not reviewed all options. If you have suggestions please let us know, asking for my colleagues 😅

Collapse
 
bratelefant profile image
bratelefant • Edited

I'm not really even at the point of thinking about storage system; using Asyncstorage or even minimongo right now, just to do some testing, in combination with redux.

What is interesting to me is the ux of what happens when meteor reconnects and refreshes subscriptions. Without any further caching, on a reconnect (after an app resume for instance), all documents in a collection get removed and then added again, making them disappear for a few secs. For many of my use cases it would be nice to display stale data until the subscription is ready and then get back to live data. In some cases I even consider switching to methods instead of subscriptions.

Edit: a loading state is not a good option for most of my views, since the whole view is taken up by document views and the user would get to see any data while eg. a skeleton or activity indicator is displayed...

Collapse
 
bratelefant profile image
bratelefant

I'd really like to try out your implementation of the useConnection hook. However, the ddp events don't get fired. I have netinfo installed and I'm on expo sdk 47. Any idea where to look at?

Collapse
 
jankapunkt profile image
Jan Küster

Thanks for the info. There also seems to be an issue with the DDP connected event on GitHub. As a comparison you can try

Meteor.useTracker(() => Meteor.status())
Enter fullscreen mode Exit fullscreen mode

And see what it returns

Collapse
 
bratelefant profile image
bratelefant

Yes, I‘m doing it like that already and it’s working; still trying to accomplish displaying stale data from Asyncstorage until the subscriptions are ready, so I thought maybe more control over reconnection behavior could be nice ;)

Thread Thread
 
jankapunkt profile image
Jan Küster

Interesting. Do you also face the issue of no connection to the server when the app has been in the background for a while?

Thread Thread
 
bratelefant profile image
bratelefant

I'm avoiding this problem by calling Meteor.connect in a useEffect hook with no dependencies in my App.js when I prepare the app, hide splash screen etc.

Thread Thread
 
jankapunkt profile image
Jan Küster

That sounds great! I have a starter repo on GtiHub and it would really love to see, whether I can improve on this issue with your approach. Would you mind taking a look at it? Maybe we can collaborate on this?

Thread Thread
 
bratelefant profile image
bratelefant

I'll give it a shot, got a fork and will play around a little bit. Process of getting it setup up was flawless (which is, tbh, unfortunately non-standard ;) )