Creating a photo sharing app with React Native

Creating a photo sharing app with React Native

In this tutorial, we'll be creating a realtime photo-sharing app with React Native

In this tutorial, we'll be creating a realtime photo-sharing app with React Native

Prerequisites

Basic knowledge of React Native is required is in order to follow along. We’ll also be using Redux in some parts of the app so basic knowledge of it will be helpful as well.

We’ll be using Expo in order to easily test the app on multiple devices. Download the Expo client app for your iOS or Android device.

These are the package versions used in creating the app:

  • Node 8.3.0
  • Yarn 1.7.0
  • Expo CLI 2.0.0
  • Expo SDK 30.0.0
  • Pusher 4.3.1
  • React Navigation 2.14.0

You don’t necessarily have to use the versions above, but if you encounter problems when using other versions, I recommend you to use the ones above instead. For other packages used in the app, be sure to check out the package.json file found in the GitHub repo.

We’ll be using Pusher and Imgur in this tutorial so you need to have an account on both of those services:

  • Node 8.3.0
  • Yarn 1.7.0
  • Expo CLI 2.0.0
  • Expo SDK 30.0.0
  • Pusher 4.3.1
  • React Navigation 2.14.0
App overview

When the user first opens the app, they’ll be greeted by the following screen. From here, they can either choose to share photos or view them by subscribing to another user who chose to share their photo:

When a user chooses Share, they’ll be assigned a unique username, which they can share with anyone. This sharing mechanism will be entirely outside the app, so it can be anything (For example, email or SMS):

Here’s what it looks like when someone chooses View. On this screen, they have to enter the username assigned to the user they want to follow:

Going back to the user who selected Share, here’s what their screen will look like when they click on the camera icon from earlier. This will allow the user to take a photo, flip the camera, or close it:

Once they take a snap, the camera UI will close and the photo will be previewed. At this point, the photo should have already started uploading in the background using the Imgur API:

Switching back to the follower (the user who clicked on View), once the upload is finished, the Imgur API should return the image URL and its unique ID. Those data are then sent to the Pusher channel which the follower has subscribed to. This allows them to also see the shared photo:

It’s not shown in the screenshot above, but everytime a new photo is received, it will automatically be appended to the top of the list.

You can find the app’s source code in this GitHub repo.

Create Pusher and Imgur apps

On your Pusher dashboard, create a new app and name it RNPhotoShare. Once it’s created, go to app settings and enable client events. This will allow us to directly trigger events from the app:

Next, after logging in to your Imgur account, go to this page and register an app. The most important setting here is the Authorization type. Select Anonymous usage without user authorization as we will only be uploading images anonymously. Authorization callback URL can be any value because we won’t really be using it. Other than that, you can enter any value for the other fields:

Click Submit to create the app. This will show you the app ID and app secret. We’re only going to need the app ID so take note of that. In case you lose the app ID, you can view all the Imgur apps you created here.

Building the app

Start by cloning the project repo and switch to the starter branch:

    git clone https://github.com/anchetaWern/RNPhotoShare.git
    cd RNPhotoShare
    git checkout starter


The starter branch contains the bare-bones app template, navigation, components, and all of the relevant styles which we will be using later on. Having all of those in the starter allows us to focus on the main meat of the app.

Install the packages using Yarn:

    yarn install


Here’s a quick overview of what each package does:

  • Node 8.3.0
  • Yarn 1.7.0
  • Expo CLI 2.0.0
  • Expo SDK 30.0.0
  • Pusher 4.3.1
  • React Navigation 2.14.0

Home screen

Let’s first start with the Home screen by importing all the necessary packages:

    // src/screens/HomeScreen.js
    import React, { Component } from "react";
    import { View, Text, Button } from "react-native";

    import Pusher from "pusher-js/react-native";

By default, React Navigation will display a header on every page, we don’t want that in this page so we disable it. In the constructor, we initialize the value of the Pusher client. We will be using this to connect to Pusher and trigger and subscribe to events:

    export default class HomeScreen extends Component {
      static navigationOptions = {
        header: null // don't display header
      };

      constructor(props) {
        super(props);
        this.pusher = null;
      }

      // next: add componentDidMount
    }

Once the component is mounted, we initialize the Pusher client using the app key and app cluster from your app settings. As for the authEndpoint, retain the value below for now, we will be updating it later before we run the app:

    componentDidMount() {
      this.pusher = new Pusher("YOUR PUSHER APP KEY", {
        authEndpoint: "YOUR_NGROK_URL/pusher/auth",
        cluster: "YOUR PUSHER APP CLUSTER",
        encrypted: true // false doesn't work, you need to always use https for the authEndpoint
      });
    }

    // next: add render method

Next, we render the UI for the Home screen. This contains two buttons that allow the user to navigate to either the Share screen or the View screen. In both cases, we pass in the reference to the Pusher client as a navigation param. This allows us to use Pusher on both pages:

    render() {
      return (
        <View style={styles.container}>
          <Text style={styles.mainText}>What to do?</Text>

          <View style={styles.buttonContainer}>
            <Button
              title="Share"
              color="#1083bb"
              onPress={() => {
                this.props.navigation.navigate("Share", {
                  pusher: this.pusher
                });
              }}
            />
          </View>

          <View style={styles.buttonContainer}>
            <Button
              title="View"
              color="#2f9c0a"
              onPress={() => {
                this.props.navigation.navigate("View", {
                  pusher: this.pusher
                });
              }}
            />
          </View>
        </View>
      );
    }

Share screen

Next is the Share screen. This is where the user can take pictures with the in-app camera and share it on realtime to people who have followed their username.

Start by importing all the packages we’ll need. Most of these should look familiar, except for Clipboard. We’ll be using it to copy the user’s username to the clipboard so they can easily share it on another app:

    // src/screens/ShareScreen.js
    import React, { Component } from "react";
    import {
      View,
      Text,
      TouchableOpacity,
      Clipboard,
      Alert,
      Image,
      Dimensions,
      Button,
      ScrollView
    } from "react-native";

Next are the Expo packages and the random animal name generator. For Expo, we need the Camera for rendering a bare-bones camera UI and the Permissions to ask the user to access the camera:

    import { MaterialIcons } from "@expo/vector-icons";
    import { Camera, Permissions } from "expo";
    import generateRandomAnimalName from "random-animal-name-generator"; // for generating unique usernames

Next, add a button in the header. This will allow the user to stop sharing their photos. When this button is clicked, all users who are currently subscribed to this user will stop receiving updates:

    export default class ShareScreen extends Component {
      static navigationOptions = ({ navigation }) => {
        const { params } = navigation.state;
        return {
          title: "Share Photos",
          headerTransparent: true,
          headerRight: (
            <Button
              title="Finish"
              color="#333"
              onPress={() => params.finishSharing()}
            />
          ),
          headerTintColor: "#333"
        };
      };

      // next: initialize state
    }

Next, initialize the state:

    state = {
      hasCameraPermission: null, // whether the user has allowed the app to access the device's camera
      cameraType: Camera.Constants.Type.front, // which camera to use? front or back?
      isCameraVisible: false, // whether the camera UI is currently visible or not
      latestImage: null // the last photo taken by the user
    };

    // next: add constructor

In the constructor, we generate a unique username for the user. This is composed of the funny animal name from the random-animal-name-generator library and a random number. Here, we also initialize the value for the Pusher client (we’ll get it from the navigation params shortly) and the user_channel where we will emit the event for sharing photos. Since this screen is where the Camera UI will be rendered, we also want the user to be able to change the screen orientation. That way, they can capture both portrait and landscape photos:

    constructor(props) {
      super(props);
      // generate unique username
      const animalName = generateRandomAnimalName()
        .replace(" ", "_")
        .toLowerCase();
      const min = 10;
      const max = 99;
      const number = Math.floor(Math.random() * (max - min + 1)) + min;
      const username = animalName + number;
      this.username = username;

      // initialize pusher
      this.pusher = null;
      this.user_channel = null;

      // allow changing of screen orientation
      Expo.ScreenOrientation.allow(
        Expo.ScreenOrientation.Orientation.ALL_BUT_UPSIDE_DOWN // enable all screen orientations except upside-down/reverse portrait
      );
    }

    // next: add componentDidMount

Once the component is mounted, we set the finishSharing method as a navigation param. We’ll define this method later, but for now, know that this is used for unsubscribing the user from their own channel. We’re subscribing to that channel right below that code. This allows us to listen to or trigger messages from this channel. Lastly, we ask for permission from the user to access the camera:

    async componentDidMount() {
      const { navigation } = this.props;

      navigation.setParams({
        finishSharing: this.finishSharing
      });

      // subscribe to channel
      this.pusher = navigation.getParam("pusher");
      this.user_channel = this.pusher.subscribe(`private-user-${this.username}`);

      // ask user to access device camera
      const { status } = await Permissions.askAsync(Permissions.CAMERA);
      this.setState({ hasCameraPermission: status === "granted" });
    }
    // next: add render method

For those who are working with Pusher for the first time, the way it works is that you first have to subscribe the users to a channel. Anyone who is subscribed to this channel will be able to trigger and listen for messages sent through that channel by means of “events”. Not all users who are subscribed to the channel need to know all about the events being sent through that channel, that’s why users can selectively bind to specific events only.

Next, we render the contents of the Share screen. In this case, there are only two possible contents: one where only the camera UI is visible, and the other where only the box containing the username and a button (for opening the camera) is visible:

    render() {
      return (
        <View style={styles.container}>
          {!this.state.isCameraVisible && (
            <ScrollView contentContainerStyle={styles.scroll}>
              <View style={styles.mainContent}>
                <TouchableOpacity onPress={this.copyUsernameToClipboard}>
                  <View style={styles.textBox}>
                    <Text style={styles.textBoxText}>{this.username}</Text>
                  </View>
                </TouchableOpacity>
                <View style={styles.buttonContainer}>
                  <TouchableOpacity onPress={this.openCamera}>
                    <MaterialIcons name="camera-alt" size={40} color="#1083bb" />
                  </TouchableOpacity>
                </View>

                {this.state.latestImage && (
                  <Image
                    style={styles.latestImage}
                    resizeMode={"cover"}
                    source={{ uri: this.state.latestImage }}
                  />
                )}
              </View>
            </ScrollView>
          )}

          {this.state.isCameraVisible && (
            <Camera
              style={styles.camera}
              type={this.state.cameraType}
              ref={ref => {
                this.camera = ref;
              }}
            >
              <View style={styles.cameraFiller} />
              <View style={styles.cameraContent}>
                <TouchableOpacity
                  style={styles.buttonFlipCamera}
                  onPress={this.flipCamera}
                >
                  <MaterialIcons name="flip" size={25} color="#e8e827" />
                </TouchableOpacity>

                <TouchableOpacity
                  style={styles.buttonCamera}
                  onPress={this.takePicture}
                >
                  <MaterialIcons name="camera" size={50} color="#e8e827" />
                </TouchableOpacity>

                <TouchableOpacity
                  style={styles.buttonCloseCamera}
                  onPress={this.closeCamera}
                >
                  <MaterialIcons name="close" size={25} color="#e8e827" />
                </TouchableOpacity>
              </View>
            </Camera>
          )}
        </View>
      );
    }

    // next: add copyUsernameToClipboard

If you’ve read the app overview earlier, you should already have a general idea on what’s going on in the code above so I’ll no longer elaborate. Take note of the ref prop we’ve passed to the Camera component though. This allows us to get a reference to that instance of the Camera component and assign it to a local variable called this.camera. We will be using it later to take a picture using that camera instance.

When the user clicks on the box containing the user’s username, this method is called and it sets the username to the clipboard:

    copyUsernameToClipboard = () => {
      Clipboard.setString(this.username);
      Alert.alert("Copied!", "Username was copied clipboard");
    };

    // next: add openCamera

Next, are the methods for opening the camera UI, flipping it (use either back or front camera), and closing it:

    openCamera = () => {
      const { hasCameraPermission } = this.state;
      if (!hasCameraPermission) {
        Alert.alert("Error", "No access to camera");
      } else {
        this.setState({ isCameraVisible: true });
      }
    };

    flipCamera = () => {
      this.setState({
        cameraType:
          this.state.cameraType === Camera.Constants.Type.back
            ? Camera.Constants.Type.front
            : Camera.Constants.Type.back
      });
    };

    closeCamera = () => {
      this.setState({
        isCameraVisible: false
      });
    };

    // next: add takePicture

Next is the method for taking pictures. This is where we use the camera reference from earlier (this.camera) to call the takePictureAsync method from the Camera API. By default, the takePictureAsync method only returns an object containing the width, height and uri of the photo that was taken. That’s why we’re passing in an object containing the options we want to use. In this case, base64 allows us to return the base64 representation of the image. This is what we set in the request body of the request we send to the Imgur API. Once we receive a response from the Imgur API, we extract the data that we need from the response body and trigger the client-posted-photo event so any subscriber who is currently listening to that event will receive the image data:

    takePicture = async () => {
      if (this.camera) {
        let photo = await this.camera.takePictureAsync({ base64: true }); // take a snap, and return base64 representation

        // construct
        let formData = new FormData();
        formData.append("image", photo.base64); 
        formData.append("type", "base64");

        this.setState({
          latestImage: photo.uri, // preview the photo that was taken
          isCameraVisible: false // close the camera UI after taking the photo
        });

        const response = await fetch("https://api.imgur.com/3/image", {
          method: "POST",
          headers: {
            Authorization: "Client-ID YOUR_IMGUR_APP_ID" // add your Imgur App ID here
          },
          body: formData
        });

        let response_body = await response.json(); // get the response body

        // send data to all subscribers who are listening to the client-posted-photo event
        this.user_channel.trigger("client-posted-photo", {
          id: response_body.data.id, // unique ID assigned to the image
          url: response_body.data.link // Imgur link pointing to the actual image
        });
      }
    };

    // next: add finishSharing

Note that the name of the event has to have client- as its prefix, just like what we did above. This is because we’re triggering this event from the client side. It’s a naming convention used by Pusher so your event won’t work if you don’t follow it. Check out the docs for more information about this.

Once the user clicks on the Finish button, we unsubscribe them from their own channel. This effectively cuts off all communication between this user and all their followers:

    finishSharing = () => {
      this.pusher.unsubscribe(`private-user-${this.username}`);
      this.props.navigation.goBack(); // go back to home screen
    };

For production apps, it’s a good practice to first trigger an “ending” event right before the main user (the one who mainly triggers events) unsubscribes from their own channel. This way, all the other users will get notified and they’ll be able to clean up their connection before their source gets completely shut off.

View screen

The View screen is where users who want to follow another user go. Again, start by importing all the packages we need:

    // src/screens/ViewScreen.js
    import React, { Component } from "react";
    import {
      View,
      Text,
      TextInput,
      ScrollView,
      Dimensions,
      Button,
      Alert
    } from "react-native";

    import CardList from "../components/CardList";

Nothing really new in the code above, except for the CardList component. This component is already included in the starter project so we don’t have to create it separately. What it does is render all the images that were sent by the user followed by the current user.

Next, import all the Redux-related packages:

    // src/screens/ViewScreen.js
    import { Provider } from "react-redux";
    import { createStore } from "redux";
    import reducers from "../reducers";

    import { addedCard } from "../actions";

    const store = createStore(reducers);

Next, we also add a button in the header. This time, to unfollow the user. We’re also passing in the function used here (params.unfollow) as a navigation param later inside the componentDidMount method:

    export default class ViewScreen extends Component {

      static navigationOptions = ({ navigation }) => {
        const { params } = navigation.state;
        return {
          title: "View Photos",
          headerTransparent: true,
          headerTintColor: "#333",
          headerRight: (
            <Button
              title="Unfollow"
              color="#333"
              onPress={() => params.unFollow()}
            />
          )
        };
      };

      // next: initialize state
    }

Next, initialize the state:

    state = {
      subscribedToUsername: "", // the username of the user the current user is subscribed to
      isSubscribed: false // is the user currently subscribed to another user?
    };

In the constructor, we also set the default value for the Pusher client and the user channel. In this case, the user channel will be whoever the current user is subscribed to. The current user doesn’t really need to trigger any events in the user channel, so we don’t have to generate a unique username and subscribe them to their own channel as we did in the Share screen earlier:

    constructor(props) {
      super(props);
      this.pusher = null;
      this.user_channel = null;
    }
    // next: add componentDidMount

Once the component is mounted, we set the unFollow function as a navigation param and initialize the Pusher client:

    componentDidMount() {
      const { navigation } = this.props;
      navigation.setParams({ unFollow: this.unFollow }); // set the unFollow function as a navigation param

      this.pusher = navigation.getParam("pusher");
    }

    // next: add render

Next, we render the UI of the of the View screen. Here, we wrap everything in the Provider component provided by react-redux. This allows us to pass down the store so we could use it inside the followUser to dispatch the action for adding a new Card to the CardList:

    render() {
      return (
        <Provider store={store}>
          <View style={styles.container}>
            {!this.state.isSubscribed && (
              <View style={styles.initialContent}>
                <Text style={styles.mainText}>User to follow</Text>
                <TextInput
                  style={styles.textInput}
                  onChangeText={subscribedToUsername =>
                    this.setState({ subscribedToUsername })
                  }
                >
                  <Text style={styles.textInputText}>
                    {this.state.subscribedToUsername}
                  </Text>
                </TextInput>

                <View style={styles.buttonContainer}>
                  <Button
                    title="Follow"
                    color="#1083bb"
                    onPress={this.followUser}
                  />
                </View>
              </View>
            )}

            {this.state.isSubscribed && (
              <ScrollView>
                <View style={styles.mainContent}>
                  <CardList />
                </View>
              </ScrollView>
            )}
          </View>
        </Provider>
      );
    }
    // next: add followUser

The followUser method is where we add the code for subscribing to the username entered by the user in the text field. Once the subscription succeeds, only then can we listen for the client-posted-photo event. When we receive this event, we expect the id and url of the image to be present. We then use those to dispatch the action for adding a new Card on top of the CardList:

    followUser = () => {
      this.setState({
        isSubscribed: true
      });

      // subscribe to the username entered in the text field
      this.user_channel = this.pusher.subscribe(
        `private-user-${this.state.subscribedToUsername}`
      );

      // alert the user if there's an error in subscribing
      this.user_channel.bind("pusher:subscription_error", status => {
        Alert.alert(
          "Error occured",
          "Cannot connect to Pusher. Please restart the app."
        );
      });

      this.user_channel.bind("pusher:subscription_succeeded", () => { // subscription successful
        this.user_channel.bind("client-posted-photo", data => { // listen for the client-posted-photo event to be triggered from the channel
          store.dispatch(addedCard(data.id, data.url)); // dispatch the action for adding a new card to the list
        });
      });
    };

    // next: add unFollow

Lastly, add the unFollow method. This gets called when the user clicks on the Unfollow button in the header. This allows us to unsubscribe from the user we subscribed to earlier inside the followUser method:

    unFollow = () => {
      this.pusher.unsubscribe(`private-user-${this.state.subscribedToUsername}`);
      this.props.navigation.goBack(); // go back to the home page
    };

Unsubscribing from a channel automatically unbinds the user from all the events they’ve previously bound to. This means they’ll no longer receive any new photos.

Adding the action and reducer

Earlier in the followUser method of the src/screens/ViewScreen.js file, we dispatched the addedCard action. We haven’t really defined it yet so let’s go ahead and do so. Create an actions and reducers folder inside the src directory to house the files we’re going to create.

To have a single place where we define all the action types in this app, create a src/actions/types.js file and add the following:

    export const ADDED_CARD = "added_card";

In the code above, all we do is export a constant which describes the action type. Nothing really mind-blowing, but this allows us to import and use this constant every time we need to use this specific action. This prevents us from making any typo when using this action.

Next, create a src/actions/index.js file, this is where we define and export the action. We pass in the ADDED_CARD constant as a type along with the id and url. These are the unique ID and URL of the image which is received by the reducer everytime this action is dispatched:

    // src/actions/index.js
    import { ADDED_CARD } from "./types";

    export const addedCard = (id, url) => {
      return {
        type: ADDED_CARD,
        id: id,
        url: url
      };
    };

Next, create a src/``reducers/CardsReducer.js file, this is where we add the reducer responsible for modifying the value of the cards array in the state. This gets executed every time we dispatch the addedCard action. When that happens, we simply return a new array containing the existing card objects and the new card object:

    // src/reducers/CardsReducer.js
    import { ADDED_CARD } from "../actions/types";

    const INITIAL_STATE = {
      cards: []
    };

    export default (state = INITIAL_STATE, action) => {
      switch (action.type) {
        case ADDED_CARD:
          const cards = [...state.cards, { id: action.id, url: action.url }]; // return a new array containing the existing card objects and the new card object
          return { ...state, cards };

        default:
          return state;
      }
    };

Note that we’re adding it to the end of the new array instead of in the beginning. This is because the FlatList component which is responsible for rendering this data is inverted. This means that the items are rendered from bottom to top.

Lastly, combine all the reducers in a single file:

    // src/reducers/index.js
    import { combineReducers } from "redux";
    import CardsReducer from "./CardsReducer";

    export default combineReducers({
      cards: CardsReducer
    });

The code above enabled us to import only a single file to include the reducers and use it for creating the store. Don’t add this, as it was already added earlier:

    // src/screens/ViewScreen.js (don't add as it was already added earlier)
    import reducers from "../reducers"; 
    const store = createStore(reducers);

Update the CardList component

If you saw the CardList component from the codes of the View screen earlier, you might have noticed that we haven’t really passed any props to it. So how will it have any data to render?

    // src/screens/ViewScreen.js
    {this.state.isSubscribed && (
      <ScrollView>
        <View style={styles.mainContent}>
          <CardList />
        </View>
      </ScrollView>
    )}

The answer is it doesn’t. Currently, the CardList component doesn’t really have the ability to render cards, so we have to update it. Start by importing the connect method from the react-redux library. This will allow us to create a “connected” component:

    // src/components/CardList.js
    import { connect } from "react-redux";

After the CardList prop types, add a mapStateToProps method. This allows us to map out any value in the store as a prop for this component. In this case, we only want the cards array:

    CardList.propTypes = {
      // previous CardList propTypes code here...
    };

    // add this:
    const mapStateToProps = ({ cards }) => { // extract the cards array from the store
      return cards; // make it available as props
    };

    // replace export default CardList with this:
    export default connect(mapStateToProps)(CardList);

Now, every time the addedCard action is dispatch, the value of this.props.cards inside this component will always be in sync with the value of the cards array in the store.

Creating the server

The server is mainly used for authenticating a user who tries to connect to Pusher. If you open the file for the Home screen, we’ve added this code earlier:

    // src/screens/HomeScreen.js
    componentDidMount() {
      this.pusher = new Pusher("YOUR PUSHER APP KEY", {
        authEndpoint: "YOUR_NGROK_URL/pusher/auth",
        cluster: "YOUR PUSHER APP CLUSTER",
        encrypted: true
      });
    }

This is where we establish the connection to Pusher’s servers. The authEndpoint is responsible for authenticating the user to verify that they’re really a user of your app. So the app hits the server every time the code above is executed.

Now that you know what the server is used for, we’re ready to add its code. Start by navigating inside the server directory and install all the packages:

    cd server
    npm install


Import all the libraries we need and intialize them. This includes Express and a couple of middlewares (JSON and URL encoded body parser), and dotenv which allows us to load values from the .env file:

    var express = require("express");
    var bodyParser = require("body-parser");
    var Pusher = require("pusher");

    var app = express(); // Express server
    app.use(bodyParser.json()); // for parsing the request body into JSON object
    app.use(bodyParser.urlencoded({ extended: false })); // for parsing URL encoded request body

    require("dotenv").config(); // initialize dotenv

Next, initialize the Pusher server component using the values from the .env file inside your server directory:

    var pusher = new Pusher({
      // connect to pusher
      appId: process.env.APP_ID,
      key: process.env.APP_KEY,
      secret: process.env.APP_SECRET,
      cluster: process.env.APP_CLUSTER
    });

Next, add the route for testing if the server is working correctly:

    app.get("/", function(req, res) {
      res.send("all green...");
    });

Next, add the route for authenticating user requests:

    app.post("/pusher/auth", function(req, res) {
      var socketId = req.body.socket_id;
      var channel = req.body.channel_name;
      var auth = pusher.authenticate(socketId, channel);
      res.send(auth);
    });

Note that in the code above, we haven’t really added any form of authentication. All we’re really doing is authenticating the user as they hit this route. This is not what you want to do for production apps. For production apps, you will most likely have some sort of user authentication before a user can use your app. That’s what you need to integrate into this code so you can ensure that the users who are making requests to your Pusher app are real users of your app.

Next, make the server listen to the port indicated in the .env file:

    var port = process.env.PORT || 5000;
    app.listen(port);

Lastly, update the .env file and update it with your Pusher app details:

    APP_ID=YOUR_PUSHER_APP_ID
    APP_KEY=YOUR_PUSHER_APP_KEY
    APP_SECRET=YOUR_PUSHER_APP_SECRET
    APP_CLUSTER=YOUR_PUSHER_APP_CLUSTER
    PORT=3000

Running the app

To run the app, you need to create an account on ngrok.com. Once you have an account, go to your account dashboard and download the ngrok binary for your operating system. Extract the zip file and you’ll see an ngrok file. Execute that file from the terminal (Note: you’ll probably need to add execution permissions to it if you’re on Linux) to add your auth token:

    ./ngrok authToken YOUR_NGROK_AUTH_TOKEN


Once that’s done, run the server and expose port 3000 using ngrok:

    node server.js
    ./ngrok http 3000


Ngrok will provide you with an https URL. Use that as the value for the authEndpoint in the src/screens/HomeScreen.js file:

    componentDidMount() {
      this.pusher = new Pusher("YOUR PUSHER APP KEY", {
        authEndpoint: "YOUR_NGROK_HTTPS_URL/pusher/auth",
      });
    }

Lastly, navigate inside the root directory of the app and start it:

    expo start


You can test the app on your machine using the emulator if you have a powerful machine. Personally, I tested it on my iOS and Android device so you might have better luck when running it on your device also.

Conclusion

That’s it! In this tutorial, you learned how to create a realtime photo-sharing app with React Native and Pusher. Along the way, you learned how to use Expo’s Camera API, Imgur API to anonymously upload images, and Pusher to send and receive data in realtime.

You can find the app’s source code in this GitHub repo.

Thanks for reading ❤

React Native Map components for iOS + Android

React Native Map components for iOS + Android

React Native Map components for iOS + Android.Contribute to react-native-community/react-native-maps development by creating an account on GitHub

React Native Map components for iOS + Android

Installation

See Installation Instructions.

See Setup Instructions for the Included Example Project.

Compatibility

Due to the rapid changes being made in the React Native ecosystem, we are not officially going to support this module on anything but the latest version of React Native. With that said, we will do our best to stay compatible with older versions as much that is practical, and the peer dependency of this requirement is set to "react-native": "*" explicitly for this reason. If you are using an older version of React Native with this module though, some features may be buggy.

Note about React requires

Since react-native 0.25.0, React should be required from node_modules. React Native versions from 0.18 should be working out of the box, for lower versions you should add react as a dependency in your package.json.

General Usage
source-js
import MapView from 'react-native-maps';

or

source-js
var MapView = require('react-native-maps');

This MapView component is built so that features on the map (such as Markers, Polygons, etc.) are specified as children of the MapView itself. This provides an intuitive and react-like API for declaratively controlling features on the map.

Rendering a Map with an initial region

MapView
source-js-jsx
  <MapView
    initialRegion={{
      latitude: 37.78825,
      longitude: -122.4324,
      latitudeDelta: 0.0922,
      longitudeDelta: 0.0421,
    }}
  />

Using a MapView while controlling the region as state

source-js-jsx
getInitialState() {
  return {
    region: {
      latitude: 37.78825,
      longitude: -122.4324,
      latitudeDelta: 0.0922,
      longitudeDelta: 0.0421,
    },
  };
}

onRegionChange(region) {
  this.setState({ region });
}

render() {
  return (
    <MapView
      region={this.state.region}
      onRegionChange={this.onRegionChange}
    />
  );
}

Rendering a list of markers on a map

source-js-jsx
import { Marker } from 'react-native-maps';

<MapView
  region={this.state.region}
  onRegionChange={this.onRegionChange}
>
  {this.state.markers.map(marker => (
    <Marker
      coordinate={marker.latlng}
      title={marker.title}
      description={marker.description}
    />
  ))}
</MapView>

Rendering a Marker with a custom view

source-js-jsx
<Marker coordinate={marker.latlng}>
  <MyCustomMarkerView {...marker} />
</Marker>

Rendering a Marker with a custom image

source-js-jsx
<Marker
  coordinate={marker.latlng}
  image={require('../assets/pin.png')}
/>

Rendering a custom Marker with a custom Callout

source-js-jsx
import { Callout } from 'react-native-maps';

<Marker coordinate={marker.latlng}>
  <MyCustomMarkerView {...marker} />
  <Callout>
    <MyCustomCalloutView {...marker} />
  </Callout>
</Marker>

Draggable Markers

source-js-jsx
<MapView initialRegion={...}>
  <Marker draggable
    coordinate={this.state.x}
    onDragEnd={(e) => this.setState({ x: e.nativeEvent.coordinate })}
  />
</MapView>

Using a custom Tile Overlay

Tile Overlay using tile server

source-js-jsx
import { UrlTile } from 'react-native-maps';

<MapView
  region={this.state.region}
  onRegionChange={this.onRegionChange}
>
  <UrlTile
    /**
     * The url template of the tile server. The patterns {x} {y} {z} will be replaced at runtime
     * For example, http://c.tile.openstreetmap.org/{z}/{x}/{y}.png
     */
    urlTemplate={this.state.urlTemplate}
    /**
     * The maximum zoom level for this tile overlay. Corresponds to the maximumZ setting in
     * MKTileOverlay. iOS only.
     */
    maximumZ={19}
    /**
     * flipY allows tiles with inverted y coordinates (origin at bottom left of map)
     * to be used. Its default value is false.
     */
    flipY={false}
  />
</MapView>

For Android: add the following line in your AndroidManifest.xml

text-xml
<uses-permission android:name="android.permission.INTERNET" />

For IOS: configure App Transport Security in your app

Tile Overlay using local tiles

Tiles can be stored locally within device using xyz tiling scheme and displayed as tile overlay as well. This is usefull especially for offline map usage when tiles are available for selected map region within device storage.

source-js-jsx
import { LocalTile } from 'react-native-maps';

<MapView
  region={this.state.region}
  onRegionChange={this.onRegionChange}
>
  <LocalTile
   /**
    * The path template of the locally stored tiles. The patterns {x} {y} {z} will be replaced at runtime
    * For example, /storage/emulated/0/mytiles/{z}/{x}/{y}.png
    */
   pathTemplate={this.state.pathTemplate}
   /**
    * The size of provided local tiles (usually 256 or 512).
    */
   tileSize={256}
  />
</MapView>

For Android: LocalTile is still just overlay over original map tiles. It means that if device is online, underlying tiles will be still downloaded. If original tiles download/display is not desirable set mapType to 'none'. For example:

<MapView
  mapType={Platform.OS == "android" ? "none" : "standard"}
>

See OSM Wiki for how to download tiles for offline usage.

Overlaying other components on the map

Place components you that wish to overlay MapView underneath the MapView closing tag. Absolutely position these elements.

source-js-jsx
render() {
  return (
    <MapView
      region={this.state.region}
    />
    <OverlayComponent
      style={{position: “absolute”, bottom: 50}}
    />
  );
}

Customizing the map style

Create the json object, or download a generated one from the google style generator.

source-js-jsx
// The generated json object
mapStyle = [ ... ]

render() {
  return (
    <MapView
      region={this.state.region}
      onRegionChange={this.onRegionChange}
      customMapStyle={mapStyle}
    />
  );
}

For iOS, in addition to providing the mapStyle you will need to do the following

source-js-jsx
import MapView, { PROVIDER_GOOGLE } from 'react-native-maps'

// ...

<MapView
  provider={PROVIDER_GOOGLE}
  customMapStyle={MapStyle}
>

Then add the AirGoogleMaps directory:

https://github.com/react-native-community/react-native-maps/blob/1e71a21f39e7b88554852951f773c731c94680c9/docs/installation.md#ios

An unofficial step-by-step guide is also available at https://gist.github.com/heron2014/e60fa003e9b117ce80d56bb1d5bfe9e0

Examples

To run examples:

source-shell
npm i
npm start

#Android
npm run run:android

#iOS
npm run build:ios
npm run run:ios

MapView Events

The <MapView /> component and its child components have several events that you can subscribe to. This example displays some of them in a log as a demonstration.

Tracking Region / Location

Programmatically Changing Region

One can change the mapview's position using refs and component methods, or by passing in an updated region prop. The component methods will allow one to animate to a given position like the native API could.

Changing the style of the map

Arbitrary React Views as Markers

Using the MapView with the Animated API

The <MapView /> component can be made to work with the Animated API, having the entire region prop be declared as an animated value. This allows one to animate the zoom and position of the MapView along with other gestures, giving a nice feel.

Further, Marker views can use the animated API to enhance the effect.

Issue: Since android needs to render its marker views as a bitmap, the animations APIs may not be compatible with the Marker views. Not sure if this can be worked around yet or not.

Markers' coordinates can also be animated, as shown in this example:

Polygon Creator

Other Overlays

So far, <Circle />, <Polygon />, and <Polyline /> are available to pass in as children to the <MapView /> component.

Gradient Polylines (iOS MapKit only)

Gradient polylines can be created using the strokeColors prop of the <Polyline> component.

Default Markers

Default markers will be rendered unless a custom marker is specified. One can optionally adjust the color of the default marker by using the pinColor prop.

Custom Callouts

Callouts to markers can be completely arbitrary react views, similar to markers. As a result, they can be interacted with like any other view.

Additionally, you can fall back to the standard behavior of just having a title/description through the <Marker />'s title and description props.

Custom callout views can be the entire tooltip bubble, or just the content inside of the system default bubble.

To handle press on specific subview of callout use <CalloutSubview /> with onPress. See Callouts.js example.

Image-based Markers

Markers can be customized by just using images, and specified using the image prop.

Draggable Markers

Markers are draggable, and emit continuous drag events to update other UI during drags.

Lite Mode ( Android )

Enable lite mode on Android with liteMode prop. Ideal when having multiple maps in a View or ScrollView.

On Poi Click (Google Maps Only)

Poi are clickable, you can catch the event to get its information (usually to get the full detail from Google Place using the placeId).

Animated Region

The MapView can accept an AnimatedRegion value as its region prop. This allows you to utilize the Animated API to control the map's center and zoom.

source-js-jsx
import MapView, { AnimatedRegion, Animated } from 'react-native-maps';

getInitialState() {
  return {
    region: new AnimatedRegion({
      latitude: LATITUDE,
      longitude: LONGITUDE,
      latitudeDelta: LATITUDE_DELTA,
      longitudeDelta: LONGITUDE_DELTA,
    }),
  };
}

onRegionChange(region) {
  this.state.region.setValue(region);
}

render() {
  return (
    <Animated
      region={this.state.region}
      onRegionChange={this.onRegionChange}
    />
  );
}

Animated Marker Position

Markers can also accept an AnimatedRegion value as a coordinate.

source-js-jsx
import Mapview, { AnimatedRegion, Marker } from 'react-native-maps';

getInitialState() {
  return {
    coordinate: new AnimatedRegion({
      latitude: LATITUDE,
      longitude: LONGITUDE,
    }),
  };
}

componentWillReceiveProps(nextProps) {
  const duration = 500

  if (this.props.coordinate !== nextProps.coordinate) {
    if (Platform.OS === 'android') {
      if (this.marker) {
        this.marker._component.animateMarkerToCoordinate(
          nextProps.coordinate,
          duration
        );
      }
    } else {
      this.state.coordinate.timing({
        ...nextProps.coordinate,
        duration
      }).start();
    }
  }
}

render() {
  return (
    <MapView initialRegion={...}>
      <MapView.Marker.Animated
        ref={marker => { this.marker = marker }}
        coordinate={this.state.coordinate}
      />
    </MapView>
  );
}

If you need a smoother animation to move the marker on Android, you can modify the previous example:

source-js-jsx
// ...

componentWillReceiveProps(nextProps) {
  const duration = 500

  if (this.props.coordinate !== nextProps.coordinate) {
    if (Platform.OS === 'android') {
      if (this.marker) {
        this.marker._component.animateMarkerToCoordinate(
          nextProps.coordinate,
          duration
        );
      }
    } else {
      this.state.coordinate.timing({
        ...nextProps.coordinate,
        duration
      }).start();
    }
  }
}

render() {
  return (
    <MapView initialRegion={...}>
      <Marker.Animated
        ref={marker => { this.marker = marker }}
        coordinate={this.state.coordinate}
      />
    </MapView>
  );
}

Take Snapshot of map

source-js-jsx
import MapView, { Marker } from 'react-native-maps';

getInitialState() {
  return {
    coordinate: {
      latitude: LATITUDE,
      longitude: LONGITUDE,
    },
  };
}

takeSnapshot () {
  // 'takeSnapshot' takes a config object with the
  // following options
  const snapshot = this.map.takeSnapshot({
    width: 300,      // optional, when omitted the view-width is used
    height: 300,     // optional, when omitted the view-height is used
    region: {..},    // iOS only, optional region to render
    format: 'png',   // image formats: 'png', 'jpg' (default: 'png')
    quality: 0.8,    // image quality: 0..1 (only relevant for jpg, default: 1)
    result: 'file'   // result types: 'file', 'base64' (default: 'file')
  });
  snapshot.then((uri) => {
    this.setState({ mapSnapshot: uri });
  });
}

render() {
  return (
    <View>
      <MapView initialRegion={...} ref={map => { this.map = map }}>
        <Marker coordinate={this.state.coordinate} />
      </MapView>
      <Image source={{ uri: this.state.mapSnapshot.uri }} />
      <TouchableOpacity onPress={this.takeSnapshot}>
        Take Snapshot
      </TouchableOpacity>
    </View>
  );
}

Zoom to Specified Markers

Pass an array of marker identifiers to have the map re-focus.

Zoom to Specified Coordinates

Pass an array of coordinates to focus a map region on said coordinates.

Troubleshooting

My map is blank

  • Make sure that you have properly installed react-native-maps.
  • Check in the logs if there is more informations about the issue.
  • Try setting the style of the MapView to an absolute position with top, left, right and bottom values set.
  • Make sure you have enabled Google Maps API in Google developer console
source-js
const styles = StyleSheet.create({
  map: {
    ...StyleSheet.absoluteFillObject,
  },
});
source-js-jsx
<MapView
  style={styles.map}
  // other props
/>

Inputs don't focus

  • When inputs don't focus or elements don't respond to tap, look at the order of the view hierarchy, sometimes the issue could be due to ordering of rendered components, prefer putting MapView as the first component.

Bad:

source-js-jsx
<View>
  <TextInput/>
  <MapView/>
</View>

Good:

source-js-jsx
<View>
  <MapView/>
  <TextInput/>
</View>

React Native Tutorial - Build Android and iOS App from Scratch

React Native Tutorial - Build Android and iOS App from Scratch

React Native Tutorial - Build Android and iOS App from Scratch. Getting started tutorial on building an Android and iOS app from scratch using React Native. React Native is a modern Javascript framework backed by Facebook that use to make Mobile Apps (Android/iOS) development easier for Javascript developer. In other words, React Native is React.js mobile apps development version.

A comprehensive step by step tutorial on building Android and iOS app from scratch using React Native

A comprehensive getting started tutorial on building an Android and iOS app from scratch using React Native. React Native is a modern Javascript framework backed by Facebook that use to make Mobile Apps (Android/iOS) development easier for Javascript developer. In other words, React Native is React.js mobile apps development version. For more information about React Native definition, features, etc, you can find in their official documentation.

Shortcut to the steps:

  • Install React App Creator and Create A React Native App
  • Add Navigation Header and Home Screen
  • Load World Cup 2018 Data from API
  • Display List of World Cup Matchdays
  • Display World Cup 2018 Matchdays Details
  • Run the React Native World Cup 2018 App

In this React Native tutorial, we are creating an app that listing the World Cup 2018 matches using the free open public domain football data for the world cups in the JSON format.

The following tools, frameworks, and modules are required for this tutorial:

  • React Native
  • Node.js (NPM or Yarn)
  • Worldcup JSON data
  • Android Studio or SDK for Android
  • XCode for iOS
  • Terminal (OSX/Linux) or Command Line (Windows)
  • Text Editor or IDE (We are using Atom)

Before start to the main steps, make sure that you have installed Node.js and can run npm in the terminal or command line. To check existing or installed Node.js environment open the terminal/command line then type this command.

node -v
v8.11.1
npm -v
6.1.0
yarn -v
1.7.0

That's the Node and NPM or YARN version that we are using. In this tutorial, we will use Yarn commonly.

Install React App Creator and Create A React Native App

The Create React Native App is a tool for creating a React Native App. To install it, type this command in your App projects folder.

sudo npm install -g create-react-native-app

Then create a React Native App using this command.

create-react-native-app reactWorldCupApp

That command will create a React Native app then install all required modules. The app or project folder will contain these folders and files.

Next, go to the newly created React App folder.

cd reactWorldCupApp

This React Native App is running via Expo app, before running on your Android or iOS device, make sure you have to install the Expo App to Android or Expo Client to iOS. Of course, that app is available in the App Store. So, we assume that you have installed the Expo App in your device then type this command to run the app.

npm start

or

yarn start

You will see this barcode and instruction in the terminal or command line.

To open the app in the Android device, open the Expo App first then tap on Scan QR Code button. Scan the barcode in the terminal or command line then you will see the React Native Android App like this after waiting for minutes the build process.

For iOS Device, press s from the keyboard to send React Native App URL to your Email or SMS. Enter your phone number or Email address (We use an email address) then press Enter. You will get this email to your mailbox.

Choose open in Expo URL then open in your browser, that will be redirected to Expo App. In Expo App welcome screen, shake your phone to open the React Native App. Now, you will see this screen in your iOS device.

This step for development purpose, we will show you how to build for production at the end of this article.

Add Navigation Header and Home Screen

Above generated React Native App just show blank app with plain text. Now, we will show you how to add the Navigation Header and Home Screen for your app. So, it will look like the Native App. In the terminal or command line type this command to install React Navigation (react-navigation) module.

yarn add react-navigation

Next, create a folder for components and React Native components files in the root of the app folder.

mkdir components
touch components/HomeScreen.js
touch components/DetailsScreen.js

Open and edit components/HomeScreen.js then add this React Native codes.

import React, { Component } from 'react';
import { Button, View, Text } from 'react-native';

class HomeScreen extends Component {
  static navigationOptions = {
    title: 'Home',
  };
  render() {
    return (
      <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
        <Text>Home Screen</Text>
        <Button
          title="Go to Details"
          onPress={() => this.props.navigation.navigate('Details')}
        />
      </View>
    );
  }
}

export default HomeScreen;

Open and edit components/DetailsScreen.js then add this React Native codes.

import React, { Component } from 'react';
import { Button, View, Text } from 'react-native';

class DetailsScreen extends Component {
  static navigationOptions = {
    title: 'Details',
  };
  render() {
    return (
      <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
        <Text>Details Screen</Text>
        <Button
          title="Go to Details... again"
          onPress={() => this.props.navigation.push('Details')}
        />
        <Button
          title="Go to Home"
          onPress={() => this.props.navigation.navigate('Home')}
        />
        <Button
          title="Go back"
          onPress={() => this.props.navigation.goBack()}
        />
      </View>
    );
  }
}

export default DetailsScreen;

Next, open and edit App.js then add replace all codes with this.

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { createStackNavigator } from 'react-navigation';
import HomeScreen from './components/HomeScreen';
import DetailsScreen from './components/DetailsScreen';

const RootStack = createStackNavigator(
  {
    Home: HomeScreen,
    Details: DetailsScreen,
  },
  {
    initialRouteName: 'Home',
    navigationOptions: {
      headerStyle: {
        backgroundColor: '#f4511e',
      },
      headerTintColor: '#fff',
      headerTitleStyle: {
        fontWeight: 'bold',
      },
    },
  },
);

export default class App extends React.Component {
  render() {
    return <RootStack />;
  }
}

When the files are saved, the Expo app will refresh the React Native App automatically. Now, the app will look like this.

Load World Cup 2018 Data from API

To load World Cup 2018 data from API, open and edit components/HomeScreen.js then add constructor function before rendering function.

constructor(props){
  super(props);
  this.state ={ isLoading: true}
}

Add function for load API JSON data which the JSON response set to the dataSource.

componentDidMount(){
  return fetch('https://raw.githubusercontent.com/openfootball/world-cup.json/master/2018/worldcup.json')
    .then((response) => response.json())
    .then((responseJson) => {
      // console.log(responseJson);
      this.setState({
        isLoading: false,
        dataSource: responseJson.rounds,
      }, function(){

      });

    })
    .catch((error) =>{
      console.error(error);
    });
}

The response from API holds by dataSource variable that will use in the view in the next step.

Display List of World Cup Matchdays

We will use the List and ListItem component of React Native Elements (react-native-elements) module. First, install react-native-elements using this command.

yarn add react-native-elements

Open and edit again components/HomeScreen.js then replace all imports with these imports of React, Component (react), StyleSheet, ScrollView, ActivityIndicator, Text, View (react-native), List, and ListItem (react-native-elements).

import React, { Component } from 'react';
import { StyleSheet, ScrollView, ActivityIndicator, Text, View  } from 'react-native';
import { List, ListItem } from 'react-native-elements';

Next, replace all render function with this React Native rendered template which the ActivityIndicator load only if the state status is loading. The list that gets from the data source displaying to the ListItem after mapping the array of the data source.

render() {
  if(this.state.isLoading){
    return(
      <View style={styles.activity}>
        <ActivityIndicator/>
      </View>
    )
  }

  return(
    <ScrollView style={styles.container}>
      <List>
        {
          this.state.dataSource.map((item, i) => (
            <ListItem
              key={i}
              title={item.name}
              leftIcon={{name: 'soccer-ball-o', type: 'font-awesome'}}
              onPress={() => {
                this.props.navigation.navigate('Details', {
                  matches: `${JSON.stringify(item.matches)}`,
                });
              }}
            />
          ))
        }
      </List>
    </ScrollView>
  );
}

Add style const before the export code.

const styles = StyleSheet.create({
  container: {
   flex: 1,
   paddingBottom: 22
  },
  item: {
    padding: 10,
    fontSize: 18,
    height: 44,
  },
  activity: {
    flex: 1,
    padding: 20,
  }
})
Display World Cup 2018 Matchdays Details

We will display World Cup 2018 Matchdays Details in the DetailsScreen. Open and edit components/DetailsScreen.js then replace all imports with this.

import React, { Component } from 'react';
import { StyleSheet, ScrollView, View } from 'react-native';
import { List, ListItem, Text, Card } from 'react-native-elements';

Next, replace all render function with these lines codes.

render() {
  const { navigation } = this.props;
  const matches = JSON.parse(navigation.getParam('matches', 'No matches found'));
  console.log(matches);

  return (
    <ScrollView>
      <Card style={styles.container}>
        {
          matches.map((item, key) => (
            <View key={key} style={styles.subContainer}>
              if(item.group) {
                <View>
                  <Text h3>{item.group}</Text>
                </View>
              }
              <View>
                <Text h3>{item.team1.name} vs {item.team2.name}</Text>
              </View>
              <View>
                <Text h5>{item.date}</Text>
              </View>
              <View>
                <Text h4>{item.score1} - {item.score2}</Text>
              </View>
              if(item.goals1.length > 0) {
                item.goals1.map((item2, key2) => (
                  <View key={key2}>
                    <Text h4>{item2.name} ({item2.minute})</Text>
                  </View>
                ))
              }
              if(item.goals2.length > 0) {
                item.goals2.map((item3, key3) => (
                  <View key={key3}>
                    <Text h5>{item3.name} ({item3.minute})</Text>
                  </View>
                ))
              }
            </View>
          ))
        }
      </Card>
    </ScrollView>
  );
}

Add StyleSheet const before the export code.

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20
  },
  subContainer: {
    flex: 1,
    paddingBottom: 20,
    borderBottomWidth: 2,
    borderBottomColor: '#CCCCCC',
  }
})
Run the React Native World Cup 2018 App

To run the application in Expo App, you can find in step 1 of this article. If there's no error found, you will see the result like this.


Next, to run or build as the Native App, type this command to install React Native CLI first.

sudo npm install -g react-native-cli

Next, eject the project from the Create React Native App by type this command.

npm run eject

You will get questions like below, make it the same with the one that we choose.

? How would you like to eject from create-react-native-app? React Native: I'd like a regular React Native project.
We have a couple of questions to ask you about how you'd like to name your app:
? What should your app appear as on a user's home screen? React World Cup
? What should your Android Studio and Xcode projects be called? ReactWorldCupApp

Next, to run the Native iOS or Android App in the Simulator, type this command.

react-native run-ios
react-native run-android

That it's for now. Any suggestions are open for next React Native tutorial. You can find the full working source code in our GitHub.

Create a todo app React Native for iOS, Android and web

Create a todo app React Native for iOS, Android and web

In this tutorial, I will be describing how to create a todo app with React Native for iOS, Android and web.

You will need Node and Yarn installed on your machine.

The application will be a Todo app but will also make use of Pusher Channels for realtime functionality. You can find a demo of the application below:

In the results of Stack Overflow’s 2019 developer survey, JavaScript happens to be the most popular technology. This is not by mere luck as it has proven we can write applications that can run almost anywhere - from web apps, desktop apps, android apps and iOS apps.

Prerequisites Directory setup

You will need to create a new directory called realtime-todo. In this directory, you will also need to create another one called server. You can make use of the command below to achieve the above:

    $ mkdir realtime-todo
    $ mkdir realtime-todo/server
Building the server

As you already know, we created a server directory, you will need to cd into that directory as that is where the bulk of the work for this section is going to be in. The first thing you need to do is to create a package.json file, you can make use of the following command:

$ touch package.json

In the newly created file, paste the following content:

    // realtime-todo/server/package.json
    {
      "name": "server",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "dependencies": {
        "body-parser": "^1.18.3",
        "cors": "^2.8.5",
        "dotenv": "^7.0.0",
        "express": "^4.16.4",
        "pusher": "^2.2.0"
      },
      "devDependencies": {},
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
      }
    }

After which you will need to actually install the dependencies, that can be done with:

$ yarn

Once the above command succeeds, you will need to create an index.js file that will house the actual todo API. You can create the file by running the command below:

$ touch index.js

In the index.js, paste the following contents:

    // realtime-todo/server/index.js

    require('dotenv').config({ path: 'variable.env' });

    const express = require('express');
    const bodyParser = require('body-parser');
    const cors = require('cors');
    const Pusher = require('pusher');

    const pusher = new Pusher({
      appId: process.env.PUSHER_APP_ID,
      key: process.env.PUSHER_APP_KEY,
      secret: process.env.PUSHER_APP_SECRET,
      cluster: process.env.PUSHER_APP_CLUSTER,
      useTLS: true,
    });

    const app = express();

    app.use(cors());
    app.use(bodyParser.json());
    app.use(bodyParser.urlencoded({ extended: true }));

    app.post('/pusher/auth', function(req, res) {
      var socketId = req.body.socket_id;
      var channel = req.body.channel_name;
      var auth = pusher.authenticate(socketId, channel);
      res.send(auth);
    });

    const todos = [];

    app.get('/items', (req, res) => {
      res.status(200).send({ tasks: todos });
    });

    app.post('/items', (req, res) => {
      const title = req.body.title;

      if (title === undefined) {
        res
          .status(400)
          .send({ message: 'Please provide your todo item', status: false });
        return;
      }

      if (title.length <= 5) {
        res.status(400).send({
          message: 'Todo item should be more than 5 characters',
          status: false,
        });
        return;
      }

      const index = todos.findIndex(element => {
        return element.text === title.trim();
      });

      if (index >= 0) {
        res
          .status(400)
          .send({ message: 'TODO item already exists', status: false });
        return;
      }

      const item = {
        text: title.trim(),
        completed: false,
      };

      todos.push(item);

      pusher.trigger('todo', 'items', item);

      res
        .status(200)
        .send({ message: 'TODO item was successfully created', status: true });
    });

    app.post('/items/complete', (req, res) => {
      const idx = req.body.index;

      todos[idx].completed = true;

      pusher.trigger('todo', 'complete', { index: idx });

      res.status(200).send({
        status: true,
      });
    });

    app.set('port', process.env.PORT || 5200);
    const server = app.listen(app.get('port'), () => {
      console.log(`Express running on port ${server.address().port}`);
    });

In the above, we create an API server that has three endpoints:

  • /items : an HTTP GET request to list all available todo items.
  • /items : an HTTP POST request to create a new todo item.
  • /items/complete: used to mark a todo item as done.

Another thing you might have noticed in on Line 3 where we make mention of a file called variable.env. That file does not exists yet, so now is the time to create it. You can do that with the following command:

$ touch variable.env

In the newly created file, paste the following content:

    // realtime-todo/server/variable.env

    PUSHER_APP_ID="PUSHER_APP_ID"
    PUSHER_APP_KEY="PUSHER_APP_KEY"
    PUSHER_APP_SECRET="PUSHER_APP_SECRET"
    PUSHER_APP_CLUSTER="PUSHER_APP_CLUSTER"
    PUSHER_APP_SECURE="1"

Please make sure to replace the placeholders with your original credentials

You can go ahead to run the server to make sure everything is fine. You can do that by running the command:

$ node index.js
Building the client

The client we will build in this section will run on the web. With the help of Expo and React Native, it will also run on Android and iOS. This is made possible via a library called [react-native-web]([https://github.com/necolas/react-native-web)](https://github.com/necolas/react-native-web)).

To get up to speed, we will make use of a starter pack available on GitHub. You will need to navigate to the project root i.e realtime-todo and clone the starter pack project. That can be done with the following command:

    # Clone into the `client` directory
    $ git clone [email protected]:joefazz/react-native-web-starter.git client

You will need to cd into the client directory as all changes to be made will be done there. You will also need to install the dependencies, that can be done by running yarn. As we will be making use of Pusher Channels and at the same time communicate with the server, you will need to run the following command:

$ yarn add axios pusher-js

The next step is to open the file located at src/App.js. You will need to delete all the existing content and replace with the following:

    // realtime-todo/client/src/App.js

    import React, { Component } from 'react';
    import {
      StyleSheet,
      Text,
      View,
      FlatList,
      Button,
      TextInput,
      SafeAreaView,
    } from 'react-native';
    import axios from 'axios';
    import Alert from './Alert';
    import Pusher from 'pusher-js/react-native';

    const APP_KEY = 'PUSHER_APP_KEY';
    const APP_CLUSTER = 'PUSHER_APP_CLUSTER';

    export default class App extends Component {
      state = {
        tasks: [],
        text: '',
        initiator: false,
      };

      changeTextHandler = text => {
        this.setState({ text: text });
      };

      addTask = () => {
        if (this.state.text.length <= 5) {
          Alert('Todo item cannot be less than 5 characters');
          return;
        }

        // The server is the actual source of truth. Notify it of a new entry so it can
        // add it to a database and publish to other available channels.
        axios
          .post('http://localhost:5200/items', { title: this.state.text })
          .then(res => {
            if (res.data.status) {
              this.setState(prevState => {
                const item = {
                  text: prevState.text,
                  completed: false,
                };

                return {
                  tasks: [...prevState.tasks, item],
                  text: '',
                  initiator: true,
                };
              });

              return;
            }

            Alert('Could not add TODO item');
          })
          .catch(err => {
            let msg = err;

            if (err.response) {
              msg = err.response.data.message;
            }

            Alert(msg);
          });
      };

      markComplete = i => {
        // As other devices need to know once an item is marked as done.
        // The server needs to be informed so other available devices can be kept in sync
        axios
          .post('http://localhost:5200/items/complete', { index: i })
          .then(res => {
            if (res.data.status) {
              this.setState(prevState => {
                prevState.tasks[i].completed = true;
                return { tasks: [...prevState.tasks] };
              });
            }
          });
      };

      componentDidMount() {
        // Fetch a list of todo items once the app starts up.
        axios.get('http://localhost:5200/items', {}).then(res => {
          this.setState({
            tasks: res.data.tasks || [],
            text: '',
          });
        });

        const socket = new Pusher(APP_KEY, {
          cluster: APP_CLUSTER,
        });

        const channel = socket.subscribe('todo');

        // Listen to the items channel for new todo entries.
        // The server publishes to this channel whenever a new entry is created.
        channel.bind('items', data => {
          // Since the app is going to be realtime, we don't want the same item to
          // be shown twice. Device A publishes an entry, all other devices including itself
          // receives the entry, so act like a basic filter
          if (!this.state.initiator) {
            this.setState(prevState => {
              return { tasks: [...prevState.tasks, data] };
            });
          } else {
            this.setState({
              initiator: false,
            });
          }
        });

        // This "complete" channel here is for items that were recently marked as done.
        channel.bind('complete', data => {
          if (!this.state.initiator) {
            this.setState(prevState => {
              prevState.tasks[data.index].completed = true;
              return { tasks: [...prevState.tasks] };
            });
          } else {
            this.setState({
              initiator: false,
            });
          }
        });
      }

      render() {
        return (
          // SafeAreaView is meant for the X family of iPhones.
          <SafeAreaView style={{ flex: 1, backgroundColor: '#F5FCFF' }}>
            <View style={[styles.container]}>
              <FlatList
                style={styles.list}
                data={this.state.tasks}
                keyExtractor={(item, index) => index.toString()}
                renderItem={({ item, index }) => (
                  <View>
                    <View style={styles.listItemCont}>
                      <Text
                        style={[
                          styles.listItem,
                          item.completed && { textDecorationLine: 'line-through' },
                        ]}
                      >
                        {item.text}
                      </Text>
                      {!item.completed && (
                        <Button
                          title="✔"
                          onPress={() => this.markComplete(index)}
                        />
                      )}
                    </View>
                    <View style={styles.hr} />
                  </View>
                )}
              />

              <TextInput
                style={styles.textInput}
                onChangeText={this.changeTextHandler}
                onSubmitEditing={this.addTask}
                value={this.state.text}
                placeholder="Add Tasks"
                returnKeyType="done"
                returnKeyLabel="done"
              />
            </View>
          </SafeAreaView>
        );
      }
    }

    const styles = StyleSheet.create({
      container: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
        backgroundColor: '#F5FCFF',
        paddingTop: 20,
        height: '100%',
      },
      list: {
        width: '100%',
      },
      listItem: {
        paddingTop: 2,
        paddingBottom: 2,
        fontSize: 18,
      },
      hr: {
        height: 1,
        backgroundColor: 'gray',
      },
      listItemCont: {
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'space-between',
      },
      textInput: {
        height: 40,
        paddingRight: 10,
        borderColor: 'gray',
        width: '100%',
      },
    });

Please update Line 17 and 18 to contain your actual credentials.

While the above is pretty straight forward, perhaps the most interesting is the line that reads Alert('Could not add TODO item');. It is easy to think Alert.alert() should be used, while that is true, react-native-web doesn’t include support for the Alert component so we will have to roll out our own. Here is a list of all components react-native-web supports. Building functionality for making alerts on the web isn’t a herculean task. You will need to create a new file called Alert.js in the src directory.

$ touch src/Alert.js

In the newly created file Alert.js, paste the following contents:

    // realtime-todo/client/src/Alert.js

    import { Platform, Alert as NativeAlert } from 'react-native';

    const Alert = msg => {
      if (Platform.OS === 'web') {
        alert(msg);
        return;
      }

      NativeAlert.alert(msg);
    };

    export default Alert;

Simple right ? We just check what platform the code is being executed on and take relevant action.

With that done, you will need to go back to the client directory. This is where you get to run the client. Depending on the platform you want to run the app in, the command to run will be different:

  • Web : yarn web. You will need to visit [http://localhost:3000](http://localhost:3000).
  • Android/iOS : yarn start-expo

If you go with the second option, you will be shown a web page that looks like the following:

You can then click on the links on the left based on your choice.

Remember to leave the server running

If you open the project on the web and on iOS/Android, you will be able to reproduce the demo below:

Conclusion

In this tutorial, I have described how to build an application that runs on Android, iOS and the web with just one codebase. We also integrated Pusher Channels so as to make communication realtime.

As always, you can find the code on GitHub.

Thanks for reading

If you liked this post, share it with all of your programming buddies!

Follow us on Facebook | Twitter

Learn More

The Complete React Native and Redux Course

React Native - The Practical Guide

The complete React Native course ( 2nd edition )

React Native Web Full App Tutorial - Build a Workout App for iOS, Android, and Web

How to build a Chat App with React Native

React Native on Windows