Build a lunch recommendation app with React Native tutorial

0:00

You know those moments when you want to grab some lunch, a coffee, or drinks with friends, but nothing you can think of feels right? It's gonna take an hour before you make moves and you're hungry.

We can build an app for that. A lunch spot app that opens as a full screen map, asks what you feel like doing, and suggests 10 places within walking distance.

maps in React Native

You might still bicker with your friends, but you’ll with objective data! There’s no way you’re getting dragged to that bar with soggy toast. Not today, friends, not today.

Wanna build a new app with me every 2 weeks? Subscribe by email and you’ll never miss a lesson.

Here’s what you’ll learn today:

We’re going to avoid state management libraries because I don’t think we need them. There’s some state we’ll have to manage, like the user’s current location and a list of restaurants, but we can do that in App.state.

That means all state lives in the main component’s state and is passed down through props. Callbacks go back up the chain and change state when necessary.

Architecture sketch
Architecture sketch

If you’re new to React Native, I suggest starting with React Native 101 where you build your first app.

You can see the full code for LunchSpotApp on Github.

Install dependencies

We need Shoutem’s UI Toolkit, AirBnB’s map library, and query-string, which will make it easier to build Foursquare requests. Yes, we’re using Foursquare and no it’s not dead. I like it.

$ react-native install @shoutem/ui
$ react-native install react-native-vector-icons
$ react-native install react-native-linear-gradient
$ react-native install react-native-maps@0.13.0
$ react-native install query-string

I like to use react-native install to install dependencies because it installs libraries from npm, links them into the project, and saves them to package.json. Many tutorials still suggest npm install and linking manually. This is easier.

We need react-native-vector-icons and react-native-linear-gradient to work around a Shoutem UI Toolkit bug with missing fonts. I don’t know how this fixes the bug, but it does. Fingers crossed the team releases a fixed version soon.

Similarly, we have to install a specific version of react-native-maps. As of March 6th, 2017 0.13.0 is the latest version, but npm thinks 0.12.4 is the latest. That one breaks with the latest React Native.

Welcome to the bleeding edge. Run react-native init every few weeks and you’ll have some fun problems to deal with.

That must be why people don’t start new apps so often

Run the app, update index.ios.js

You should now see the default app, if you run react-native run-ios. Go into index.ios.js and remove all of that. Replace run-ios with run-android and index.ios with index.android, if you prefer the Android simulator.

Your index file should look like this:

import React, { Component } from 'react';
import {
  AppRegistry,
} from 'react-native';

import App from './components/App';

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

AppRegistry.registerComponent('LunchSpotApp', () => LunchSpotApp);

iOS and Android can share the App component we’re building.

File structure

Through this tutorial, we’re going to build 4 files:

LunchSpotApp
  └─ components
      └─ App.js
      └─ Recommendation.js
      └─ RecommendationsMap.js
      └─ Topics.js
      └─ styles.js
  └─ index.ios.js
  └─ index.android.js

Actually, you should just grab styles.js from Github. Explaining it would just clutter up the code samples.

Render a map with user’s geolocation

React Native comes with a polyfill for HTML5’s navigator.geolocation API. You can get the current location and watch it for updates. Super useful when the user is moving.

The easiest place to do that is in App.componentWillMount. We start observing geolocation when App first renders, and stop when it unmounts.

./components/App.js

class App extends Component {
    state = {
        mapRegion: null,
        gpsAccuracy: null
    }
    watchID = null

    componentWillMount() {
        this.watchID = navigator.geolocation.watchPosition((position) => {
            let region = {
                latitude: position.coords.latitude,
                longitude: position.coords.longitude,
                latitudeDelta: 0.00922*1.5,
                longitudeDelta: 0.00421*1.5
            }

            this.onRegionChange(region, position.coords.accuracy);
        });
    }

    componentWillUnmount() {
        navigator.geolocation.clearWatch(this.watchID);
    }

    onRegionChange(region, gpsAccuracy) {
        this.fetchVenues(region);

        this.setState({
            mapRegion: region,
            gpsAccuracy: gpsAccuracy || this.state.gpsAccuracy
        });
    }
}

That’s a lot of code to spring on you all at once. Let me explain.

We set up default state: an empty mapRegion and gpsAccuracy. It’s called region because that’s what react-native-maps uses. Consistency makes code easier to read.

We start a geolocation watch in componentWillMount with navigation.geolocation.watchPosition. I don’t know what happens behind the scenes, but the environments triggers our callback every time new information becomes available. In the form of a position object with many properties.

The properties we care about are latitude, longitude, and accuracy. We use latitude and longitude to center the map and run the Foursquare query, and we use accuracy to give the user a familiar visual. Bigger or smaller circle based on GPS accuracy.

In componentWillUnmount we clean up after ourselves and stop listening to location changes.

onRegionChange doubles as a MapView callback when the user zooms and pans. We want the same effect for both physically moving and for changing the view. It makes sense to combine them into one callback.

Whenever the region changes, we save it to state, which triggers a re-render, and fetch a new set of Foursquare recommendations. You’ll see how that works in the next section.

Render the map

Now that we know where we are, we can render the map. That happens in App.render and the RecommendationsMap component.

./components/App.js

class App extends Component {
        // ...
    render() {
        const { mapRegion, lookingFor } = this.state;

        if (mapRegion) {
            return (
                <Screen>
                    <RecommendationsMap {...this.state} onRegionChange={this.onRegionChange.bind(this)} />
                </Screen>
            );
        }else{
            return (
                <Screen style={styles.centered}>
                    <Spinner styleName="large" />
                </Screen>
            );
        }
    }
}

Here’s how App.render works: If we have a location, we render the map, otherwise we render a Spinner.

Screen and Spinner are Shoutem UI Toolkit components. Screen is a lot like the default View, except it has a nice background that matches your overall theme. Themes are configurable, but I think the default looks great. Spinner is a spinner image. The kind you’ve seen many times 🙂

If we do know where we are – mapRegion is defined – then we render RecommendationsMap. We pass all of this.state as props and give it an onRegionChange callback. The map component is going to call this function every time the user drags our map.

RecommendationsMap component

We let react-native-maps deal with rendering the map.

// ./components/RecommendationsMap.js

import MapView from 'react-native-maps';

const RecommendationsMap = ({ mapRegion, gpsAccuracy, recommendations, lookingFor,
                              headerLocation, onRegionChange }) => (

    <MapView.Animated region={mapRegion}
                      style={styles.fullscreen}
                      onRegionChange={onRegionChange}>

        <Title styleName="h-center multiline" style={styles.mapHeader}>
            {lookingFor ? `${lookingFor} in` : ''} {headerLocation}
        </Title>

        <MapView.Circle center={mapRegion}
                        radius={gpsAccuracy*1.5}
                        strokeWidth={0.5}
                        strokeColor="rgba(66, 180, 230, 1)"
                        fillColor="rgba(66, 180, 230, 0.2)"
                        />

        <MapView.Circle center={mapRegion}
                        radius={5}
                        strokeWidth={0.5}
                        strokeColor="rgba(66, 180, 230, 1)"
                        fillColor="rgba(66, 180, 230, 1)"
                        />
    </MapView.Animated>
);

export default RecommendationsMap;

You can see this file on Github.

We use MapView to render the map. The .Animated version makes region changes smoother. Playing around, I needed this to make the app work, but I don’t know why. Couldn’t figure it out for the life of me

My understanding is that the only time you wouldn’t use .Animated is if you’re certain that you don’t need to detect or programmatically cause region changes. That is not the case for us.

Title is another component from the Shoutem Toolkit. It styles typography so text looks great as a title. I’ll explain the lookingFor and headerLocation props later. You can set this as a Hello Humans text for now, but don’t forget to change later.

Two MapView.Circles render a location indicator. The small circle is a faux dot, the big circle indicates accuracy. The bigger the circle, the less accurate we your location. Just like in Google Maps.

Your app should look like this:

LunchSpotApp with a blank map
LunchSpotApp with a blank map

Yes it does use Google Maps by default on Android and Apple Maps on iOS. You can change that, too.

Load Foursquare recommendations

Time to fill our map with recommendations. I like Foursquare more than Yelp, so we’re using that. If you know something even better, yell at me on twitter.

The bulk of our logic happens in App. If you want to use MobX, this goes in a Store, if you like Redux, it goes in a reducer of some sort. I think.

Anyway … you’ll need a Foursquare CLIENT_ID and CLIENT_SECRET. You can get them from Foursquare’s developer portal.

./components/App.js

const CLIENT_ID = 'your_id';
const CLIENT_SECRET = 'your_secret';
const FOURSQUARE_ENDPOINT = 'https://api.foursquare.com/v2/venues/explore';
const API_DEBOUNCE_TIME = 2000;

class App extends Component {
    state = {
        // ...
        recommendations: [],
        lookingFor: null,
        headerLocation: null,
        last4sqCall: null
    }
    
    // ...
    
    fetchVenues(region, lookingFor) {
        if (!this.shouldFetchVenues(lookingFor)) return;

        const query = this.venuesQuery(region, lookingFor);

        fetch(`${FOURSQUARE_ENDPOINT}?${query}`)
            .then(fetch.throwErrors)
            .then(res => res.json())
            .then(json => {
                if (json.response.groups) {
                    this.setState({
                        recommendations: json.response.groups.reduce(
                            (all, g) => all.concat(g ? g.items : []), []
                        ),
                        headerLocation: json.response.headerLocation,
                        last4sqCall: new Date()
                    });
                }
            })
            .catch(err => console.log(err));
    }

    shouldFetchVenues(lookingFor) {
        return lookingFor != this.state.lookingFor
             || this.state.last4sqCall === null
             || new Date() - this.state.last4sqCall > API_DEBOUNCE_TIME;
    }

    venuesQuery({ latitude, longitude }, lookingFor) {
        return queryString({
            client_id: CLIENT_ID,
            client_secret: CLIENT_SECRET,
            v: 20170305,
            ll: `${latitude}, ${longitude}`,
            llAcc: this.state.gpsAccuracy,
            section: lookingFor || this.state.lookingFor || 'food',
            limit: 10,
            openNow: 1,
            venuePhotos: 1
        });
    }
}

Oh my that was a lot of code again

Here’s how it works: fetchVenues is the brains of the operation. It uses fetch() to talk to Foursquare, and updates this.state when new recommendations fly in.

Because recommendations come in nested groups, we flatten them with json.response.groups.reduce. You can think of it as walking through an array of arrays and concatenating them into one big array.

We use shouldFetchVenues in combination with state.last4sqCall and API_DEBOUNCE_TIME to avoid calling the API too often. Every 2 seconds seemed like a good compromise.

In venuesQuery we use query-string to turn an object into a URL query. It’s one of those things that looks easy, but is much better left as somebody else’s problem

Great, we fetch recommendations, put them in state, and trigger a re-render. Time to draw them on our map.

Put markers on your map

To put our recommendations on the map, we add them to RecommendationsMap in a loop. Like this:

./components/RecommendationsMap.js

const RecommendationsMap = ({ mapRegion, gpsAccuracy, recommendations, lookingFor,
                              headerLocation, onRegionChange }) => (

    <MapView.Animated region={mapRegion}
                      style={styles.fullscreen}
                      onRegionChange={onRegionChange}>
        // ...

        {recommendations.map(r => <Recommendation {...r} key={r.venue.id} />)}

    </MapView.Animated>
);

Recommendations come from a prop and get rendered in a loop as Recommendation components. Each Recommendation is a custom marker that shows a picture and a tip when you tap on it.

Recommendation component

./components/Recommendation.js

class Recommendation extends Component {
    get photo() {
        const photo = this.props.venue.photos.groups[0].items[0];

        return `${photo.prefix}300x500${photo.suffix}`;
    }

    render() {
        const { venue, tips } = this.props;

        return (
            <MapView.Marker coordinate={{latitude: venue.location.lat,
                                         longitude: venue.location.lng}}>

                <MapView.Callout>
                    <Card>
                        <Image styleName="medium-wide"
                               source={{uri: this.photo}} />
                        <View styleName="content">
                            <Subtitle>{venue.name}</Subtitle>
                            <Caption>{tips ? tips[0].text : ''}</Caption>
                        </View>
                    </Card>
                </MapView.Callout>
            </MapView.Marker>
        )
    }
}

MapView.Marker and MapView.Callout come from the react-native-maps library. The first is a pin we show on the map, and the second is a tooltip that shows up when a user taps a marker.

Inside the tooltip we use a Shoutem Toolkit Card, with an image, a subtitle, and a caption. I took the markup from the Shoutem Toolkit docs because I like how it looks.

Shoutem UI Toolkit Card
Shoutem UI Toolkit Card

Each shows the venue’s photo and the top voted tip. Foursquare’s explore API conveniently gives us both. It gives us a bunch of other info as well, but when I played around, it got crowded really fast. I suggest console.log-ing the props and seeing what’s available.

Your app should now look like this:

Geolocation app with recommendations
App with recommendations

Now I don’t know why the Pinecrest Diner is open at 3am, but it’s making me hungry. You’ll see more stuff at a more reasonable hour. 🙂

If that didn’t work, consult the Github.

Render topic buttons

For the final touch, we give users some options. You won’t always feel like having drinks in the evening and coffee in the morning.

The logic is a single function in App and the rendering happens also in App. We’ll put the helper components in a Topics.js file.

./components/App.js

class App extends Component {
   // ...
   
   onTopicSelect(lookingFor) {
        this.fetchVenues(this.state.mapRegion, lookingFor);

        this.setState({
            lookingFor: lookingFor
        });
    }

    render() {
        const { mapRegion, lookingFor } = this.state;

        if (mapRegion) {
            return (
                <Screen>
                    // ...
                    {!lookingFor ? <OverlayTopics onTopicSelect={this.onTopicSelect.bind(this)} />
                                 : <BottomTopics onTopicSelect={this.onTopicSelect.bind(this)} />}
                </Screen>
            );
        }else{
            // ..
        }
    }
}

The onTopicSelect callback updates state with a new lookingFor value and triggers a re-render. We use it as the onPress callback on every button.

In the render method, we use two types of “list of buttons” views. One is a fullscreen overlay, another is a small toolbar under the map.

They look like this:

./components/Topics.js

const TOPICS = ['food', 'drinks', 'coffee', 'shops', 'sights', 'arts'];

const OverlayTopics = ({ onTopicSelect }) => (
    <Overlay styleName="fill-parent">
        <Heading style={{marginBottom: 15}}>What do you feel like?</Heading>
        {TOPICS.map(topic => (
            <Button onPress={() => onTopicSelect(topic)} key={topic} style={{marginBottom: 10}}>
                <Text>{topic}</Text>
            </Button>
        ))}
    </Overlay>
);

const BottomTopics = ({ onTopicSelect }) => (
    <View styleName="horizontal">
        {TOPICS.map(topic => (
            <Button onPress={() => onTopicSelect(topic)} key={topic} styleName="muted">
                <Text>{topic}</Text>
            </Button>
         ))}
    </View>
);

I took the list of topics from Foursquare’s documentation. They hint that there’s more, but these 6 should cover our bases 🙂

The components you see here are all from the Shoutem UI Toolkit. Overlay makes a fullscreen overlay with a darkened background, Button makes a nice button, and Heading looks like a big title.

With that our app is complete. I’d use it find good lunch, would you?

Geolocation React Native app with markers

What do you wanna build next? Email me, I read everything.