Build an Imgur Client with React Native and MobX tutorial

0:00

Today, we’re building an Imgur app. A supreme fullscreen image browsing experience to make your 💩 even more fun.

What, you don’t Imgur while you poop? I do. And so does everyone I know… okay, some only browse Facebook and Twitter.

Imgur device rotation animation

Imgur React Native ImgurCarousel

A fullscreen view of Imgur’s frontpage. Tap on the right for next pic, left for the previous. Albums scroll up and down. It’s like Snapchat, but for the Imgur gallery.

OMG, please put that on the app store! I hate the current mobile experience.

~ Girlfriend when I showed her the app

Things that you can’t do: post or favorite or like. But how often do you really do that? 99% of the time, I just scroll through images and waste my time 😇


Here’s what you’ll learn in today’s lecture:

  • how to use MobX for state management
  • how to talk to RESTful APIs
  • how to render fullscreen images in a ListView
  • an elegant way to detect device orientation

New to React Native? Check out React Native 101 where you can build your first native app in 46 lines of code.

To keep this lecture shorter, the code samples might skip some details. You can see the full code at my Swizec/ImgurApp Github repository.

Commits roughly follow this lecture, except there’s some final cleanup to make the source easier for you to read 😁

You can also watch me build ImgurApp from scratch on my LiveEdu channel if you’re curious 👉 part1 | part2.

We start our app the usual way with

$ react-native init ImgurApp

Set up MobX for state management

ImgurApp is complex enough to use a state management library. Putting state in a central store makes our app easier to reason about, which makes development faster and our lives easier.

Having a central store that all components have access to also makes our app more flexible. We can move components around without having to rewire all the business logic as well.

Two popular approaches for state management exist: Redux and MobX. Both have their strengths and weaknesses. Redux is more obvious and closely follows React’s core principles, while MobX requires less code and looks more object-y.

I like to use MobX because of the “less code” aspect. If you use MobX the way I’m about to show you, it gives you all the same assurances about faux immutability as Redux and could, in theory, support time traveling debugging and all those cool things.

Install MobX

We need decorators to use MobX effectively. You can use it without decorators, but I think decorators improve your experience by a lot.

You can think of decorators as an easier way to write higher order functions and classes. Make a function and use @ to wrap it around anything. You can even use them to make higher order React components.

But decorators are still stage-0, so we have to enable them.

First, we install MobX and decorator support for react-native.

$ react-native install mobx
$ react-native install mobx-react
$ react-native install babel-preset-react-native-stage-0

I recommend installing libraries through react-native install. It works for all npm packages and ensures the libraries are correctly linked into your project. You never have to open XCode 🙂

With the libraries installed, we add decorators to our .babelrc presets. Like this:

// .babelrc
{
"presets": ["react-native", "react-native-stage-0/decorator-support"]
}

Your app now supports decorators. 🙌🏼

MobX 101

Explaining MobX in detail is beyond the scope of this article, so here’s a quick rundown of the concepts we’re going to use:

MobX implements the ideas of reactive programming. There are values that are observable and functions that react when those values change. MobX ensures only the minimal possible set of observers is triggered on every change.

So we have:

@observable – a property whose changes observers subscribe to

@observer – a component whose render() method observes values

@computed – a method whose value can be fully derived from observables

@action – a method that changes state, analogous to a Redux reducer

@inject – a decorator that injects global stores into a component’s props

That’s basically all you need to know. Once your component is an @observer, you never have to worry about what it’s observing. MobX ensures it reacts only to the values that it uses during rendering.

Fullscreen images with device orientation

Imgur device rotation animation

For a better viewing experience, we want images to be full screen. None of the social and branding cruft the default Imgur app comes with.

But image dimensions don’t always (ever) match your phone’s dimensions, so we have to make a choice. Either the image is full screen and cropped, or it has borders and you can see the full thing.

An approach many apps follow is to use borders in portrait mode and full screen in landscape. We’re going to do the same.

For that we need:

  • a MobX store to save orientation
  • a way to change this value when orientation changes
  • different image styles for the two orientations

Setup the global MobX Store

A MobX store can be any class with observable properties. Ours is called Store and sits in ./Store.js.

To store device orientation, it needs a property and an action. Because we’re reusing the touchable image component from our last lecture, we also stub out prevImage and nextImage actions. It avoids undefined callbacks 🙂

// ./Store.js
import { observable, action } from 'mobx';
import { PORTRAIT } from './Constants';

class Store {
    @observable orientation = PORTRAIT;

    @action changeOrientation(orientation) {
        this.orientation = orientation;
    }

    @action prevImage() {
        console.log('previous');
    }

    @action nextImage() {
        console.log('next');
    }
}

export default new Store();

We import observable and action decorators from mobx, get a relevant constant from Constants, and set up a class with an observable and some actions. The Constants file is a convenient way to store the constants we’re using all over our app. You can see it on Github here.

Notice that we’re exporting an instance, not a class. This ensures that no matter who imports our Store, they all share the same instance.

Detect device orientation

The most elegant way I’ve found to detect device orientation is to hook into the onLayout callback on your main <View>. It triggers every time your app changes layouts.

We can also take this opportunity to set up our Store so our components have access to it through @inject('store'). We’ll use MobX’s Provider component for that.

// ./index.ios.js

import { Provider as MobXProvider, observer } from 'mobx-react/native';

import ImgurCarousel from './components/ImgurCarousel';
import { LANDSCAPE, PORTRAIT } from './Constants';
import Store from './Store';

@observer
class ImgurApp extends Component {
    onLayout(event) {
        const { width, height } = event.nativeEvent.layout;
        const orientation = ( width > height ) ? LANDSCAPE : PORTRAIT;

        Store.changeOrientation(orientation);
    }

    render() {
        return (
            <MobXProvider store={Store}>
                <View style={styles.container}
                      onLayout={this.onLayout.bind(this)}>
                    <ImgurCarousel />
                </View>
            </MobXProvider>
        );
    }
}

We gut the ImgurApp.render method given by the default app init and replace it with <MobXProvider .... The provider puts all its props into a React context, which makes them globally available.

This is safe to do because MobX has a mechanism that ensures changes propagate to all observers as long as you don’t forget to mark them as observers. I’ve had many fun debugging hours that way 😅

In the onLayout callback, we compare width and height then call the changeOrientation action with the appropriate orientation. LANDSCAPE when the screen is wider than it is high, PORTRAIT otherwise.

Render a fullscreen image

Now let’s make the ImgurCarousel component. It’s the component that renders our entire interface.

Why isn’t it in index.ios.js then? Because we can share it between both iOS and Android. There’s nothing device specific in there.

At first, we make it render a static TouchableImage so you can see how the fullscreen-ness works. We’ll expand it later when we start loading images from Imgur.

ImgurCarousel

// ./components/ImgurCarousel.js

@inject('store') @observer
class ImgurCarousel extends Component {
    render() {
        const { image, store } = this.props;

        return (
            <TouchableImage image={{link: "https://i.imgur.com/6cFNnJp.jpg",
                                   title: "Its my cake day, why am i happier about this then my real birthday, i will never know. First fav. Post"}} />
        );
    }
}

ImgurCarousel renders a single TouchableImage and gives it an image with a title. Yes, it looks silly right now, but it’s going to decide between Album, TouchableImage, or Spinner later on.

TouchableImage

The TouchableImage component is pillaged from our previous lecture. It renders an image and calls different callbacks based on where you tap.

// ./components/TouchableImage.js

@inject('store') @observer
class TouchableImage extends Component {
    state = {
        width: null
    }

    onPress(event) {
        const { width } = this.state,
              { store } = this.props,
              X = event.nativeEvent.locationX;

        if (X < width/2) {
            store.prevImage();
        }else{
            store.nextImage();
        }
    }

    onImageLayout(event) {
        this.setState({
            width: event.nativeEvent.layout.width
        });
    }

    get caption() {
        let { caption, image } = this.props;
        return image.title || image.description || caption;
    }

    render() {
        const { image, orientation } = this.props,
                    uri = image.link.replace('http://', 'https://');

        return (
            <TouchableHighlight onPress={this.onPress.bind(this)}
                                style={styles.fullscreen}>
                <Image source={{uri: uri}}
                       style={[styles.backgroundImage, styles[orientation.toLowerCase()]]}
                       onLayout={this.onImageLayout.bind(this)}>
                    <Text style={styles.imageLabel}>{this.caption}</Text>
                </Image>
            </TouchableHighlight>
        );
    }
}

Very similar, right? We use onLayout to store width in local state (yes, it’s still fine even with MobX), then use it to call either prevImage or nextImage.

We use TouchableHighlight to detect taps and give feedback to the user, Image to render the image, and Text to render the caption. A caption can be either image.caption, image.title, or a caption forced through props.

This is forward-looking to match Imgur’s API.

A nice feature of using a store to manage state is that TouchableImage has direct access to it. If we want anything from the store, like the orientation, we get it with store.<property>. To trigger actions, we call them like normal functions.

An important thing to do is make sure we’re loading only https images. iOS blocks them otherwise, and Imgur’s API returns nonhttps links.

Styles for fullscreen

The key to making our image full screen lies in the styling. We move styles out of index.ios.js and put them in ./components/styles.js. That makes styles easier to share across files.

You can see the full styles.js file on Github. These are the relevant parts:

// ./components/styles.js

const styles = StyleSheet.create({
    fullscreen: {
        flex: 1,
        width: null,
        height: null
    },
    backgroundImage: {
        flex: 1,
        justifyContent: 'flex-end',
        width: null,
        height: null
    },
    portrait: {
        resizeMode: 'contain'
    },
    landscape: {
        resizeMode: 'cover'
    },

We propagate flex: 1 all the way down to the image itself, ensure width and height are null, then use different resize modes. For portrait we contain the image, and for landscape we resize it to cover the full screen.

This works great for single images. In the ListView step, we’ll have to add logic to the height so it doesn’t try to render them all on the same screen. You’ll see.

You should now have an app that shows a guy with soap bubbles on his face.

Imgur React Native fullscreen rotation

Reading Imgur’s REST API

The public parts of Imgur’s API are relatively easy to use. We care about two endpoints:

  • https://api.imgur.com/3/gallery/hot/viral/<N>
  • https://api.imgur.com/3/album/<albumID>

The first returns images from the front page. The N argument lets us page through them 60 images at a time.

The second returns images for a specific album. We need it because a lot of Imgur posts are collections of multiple images.

We’re going to talk to this API using my better-fetch wrapper of ES6’s built in fetch() function. It makes API calls with common Authorization headers easier.

If you don’t know about fetch(), I suggest reading David Walsh’s excellent article that explains how it works. While fetch() hasn’t yet reached wide adoption on the web, it’s the way to do REST in React Native.

Install helper libs

You’ll need better-fetch and the babel-latest-preset preset for this. I assume the preset is needed because of something I did in my library code, not because of fetch() itself.

$ react-native install better-fetch
$ react-native install babel-preset-latest

This gives you both libraries and links them. You have to enable the preset in .babelrc.

// ./.babelrc
{
"presets": ["react-native", "latest", "react-native-stage-0/decorator-support"]
}

Ordering is important. Your build will fail if latest shows up after react-native-stage-0. I don’t know why, but I’m happy that somebody told me when I livecoded this app.

Set up Imgur auth key

My better-fetch lets us set up default headers that are re-used on every API call. This is perfect for authorization headers.

// ./Store.js

import fetch from 'better-fetch';

fetch.setDefaultHeaders({
    Authorization: `Client-ID ${CLIENT_ID}`
});

Now every API call we make is going to have that Authorization header. This is good enough for reading Imgur’s API. We’d have to do the entire OAuth dance to allow writing. Which is why we don’t 🙂

Register your app on Imgur to get your CLIENT_ID. You’ll need an account and some URLs. I used the Github repository URLs because I don’t care about OAuth.

You can use my ID if you promise not to do anything shady.

Actions for reading Imgur

We need to expand our Store with some actions for fetching images. Another benefit of using state management: All our business logic is in one place.

// ./Store.js

class Store {
    @observable images = [];
    @observable index = 0;
    @observable galleryPage = 0;
    @observable albums = new observable.map();
    // ...

    @action fetchImages() {
        fetch(`${IMGUR_URL}gallery/hot/viral/${this.galleryPage}`)
          .then(fetch.throwErrors)
          .then(res => res.json())
          .then(json => {
              json.data.forEach(img => this.images.push(img));
          })
          .catch(err => console.log('ERROR', err.message));
    }

    @action fetchAlbum(id) {
        if (!this.albums.has(id)) {
            fetch(`${IMGUR_URL}album/${id}`)
              .then(fetch.throwErrors)
              .then(res => res.json())
              .then(json => {
                  this.albums.set(json.data.id, json.data);
              })
              .catch(err => console.log('ERROR', err.message, err));
        }
    }
}

Oh my, a lot of state showed up. We use images to keep a list of loaded images, copied straight from the API. index and galleryPage help us keep track of where in the images array we are, and albums is a hash map of albums. That is, images with multiple images.

We use a hash map for the albums so it’s easier to avoid unnecessary fetching.

fetchImages calls the Imgur API, gets the currentPage of gallery results and adds them to images. Looping through the result and adding them one-by-one ensures MobX observers keep working correctly.

fetchAlbum does something similar except it gets a specific album from the API and adds it to the hash map. A conditional check ensures that we never fetch the same album twice, which speeds up our app.

Make sure you call fetchImages

A quick intermezzo while we set up image loading: Call fetchImages when your app first loads.

// ./index.ios.js

class ImgurApp extends Component {
    // ...
    componentWillMount() {
        Store.fetchImages();
    }
}

MobX will call the correct observers when fetchImages is done loading images and updating the images array.

Actions that traverse images

Now that we have a local images array, we can implement the prevImage and nextImage stubs from before. They increase or decrease the index and fetch the next page of results as necessary.

We also add a @computed value that tells us the current image.

// ./Store.js

    @action prevImage() {
        this.index = this.index - 1;

        if (this.index < 1) { this.index = 0; } } @action nextImage() { this.index = this.index + 1; if (this.index > this.images.length) {
            this.galleryPage = this.galleryPage+1;
            this.fetchImages();
        }
    }

    @computed get currentImage() {
        return this.images.length ? this.images[this.index] : null;
    }

With these implemented, and TouchableImage already wired up, we automagically enable traversing the Imgur gallery. We just have to update ImgurCarousel to use store.currentImage.

Render Imgur pics

As promised, the only change we ned is in ImgurCarousel.

// ./components/ImgurCarousel.js

@inject('store') @observer
class ImgurCarousel extends Component {
    render() {
        const { store } = this.props;

        if (!store.currentImage) {
            return null;
        }

        return (
            <TouchableImage image={store.currentImage} />
        );
    }
}

An empty currentImage means we’re still waiting for an API response. This is where we’ll render a loading spinner later.

If the image is there, we render it with TouchableImage. Unfortunately, that component can’t handle albums, so many images show up as a blank screen.

Imgur React Native TouchableImage

Render albums in a ListView

Now comes the fun part: Rendering an album of images in a scrollable ListView. The <Album> component.

The Album component is made of 3 parts:

  • some logic to call store.fetchAlbum when necessary
  • some logic to build a ListView dataSource
  • 3 rendering functions: renderRow, renderHeader, and render

Fetch Album

Album fetching happens when our component is first render or its albumID property updates.

// ./components/Album.js

@inject('store') @observer
class Album extends Component {
    componentWillMount() {
        const { store, albumID } = this.props;

        store.fetchAlbum(albumID);
    }

    componentWillReceiveProps(newProps) {
        const { store, albumID } = newProps;

        store.fetchAlbum(albumID);
    }

In both cases, we defer to store.fetchAlbum and rely on its ability to avoid double-fetching. When the album is ready in store.albums, MobX’s observer wiring will take over and call our render method.

This is why I like MobX: A lot of sophistication with very little code to write 🙂

Build dataSource

ListView components render from a dataSource that’s more complex than a plain array. I don’t know the details of how it works, but I do know how to make one.

// ./components/Album.js

@inject('store') @observer
class Album extends Component {
    // ...
    get dataSource() {
        const { store, albumID } = this.props,
              ds = new ListView.DataSource({
                  rowHasChanged: (r1, r2) => r1.id !== r2.id
              });

        return ds.cloneWithRows(toJS(this.album.images));
    }

    get album() {
        const { store, albumID } = this.props;

        return store.albums.get(albumID);
    }

We instantiate a new ListView.DataSource with a row comparator function. We use cloneWithRows to fill it with data.

The toJS function we used is a MobX method that converts deep observable objects into plain JavaScript. This helps cloneWithRows read what we’re giving it.

The album() getter gets a specific album from the Store albums hash map. We use it as a helper method because similar code shows up in a few places.

Render

Rendering the ListView happens in 3 functions. The main render() method renders the ListView itself, and two helper methods render specific parts of the view.

    renderRow(img, caption) {
        const { store } = this.props;

        let height = store.screenSize.height;

        if (img.height < height) {
            height = img.height;
        }

        return (
            <TouchableImage image={img}
                            height={height}
                            caption={caption} />
        )
    }

    renderHeader() {
        return (
            <Text style={styles.header}>{this.album.title}</Text>
        );
    }

    render () {
        const { store, albumID } = this.props,
              album = this.album;

        if (album) {
            if (album.images.length > 1) {
                return (
                    <View style={styles.fullscreen}>
                        <ListView dataSource={this.dataSource}
                                  renderRow={img => this.renderRow(img)}
                                  renderHeader={this.renderHeader.bind(this)}
                                  style={styles.fullscreen} />
                    </View>
                );
            }else{
                return this.renderRow(album.images[0], album.title);
            }
        }else{
            return null;
        }
    }

renderRow renders a specific ListView row. In this case a single TouchableImage with a fixed height and [maybe] caption. The fixed height helps us stretch rows to full screen size. I’ll show you how in a bit.

renderHeader renders an album header, which is just a <Text> element with bigger font. It shows up at the top of the screen.

render renders a fullscreen View with a fullscreen ListView. We give the ListView our dataSource and the two render helpers.

The result is a whole album squeezed onto the screen.

Imgur React Native render

Fullscreen ListView rows

To stretch ListView rows to full height, we need to save screenSize information then use it in TouchableImage. We already set up renderRow above to set the height either based on screen size or on image size depending on what looks better.

First, we need to add an observable and an action to Store.

// ./Store.js
class Store {
    // ...

    @observable screenSize = {
        width: null,
        height: null
    };

    @action updateScreenSize(width, height) {
        this.screenSize.width = width;
        this.screenSize.height = height;
    }

That’s a screenSize object with a width and a height and an action that updates it. Make sure you don’t do something like this.screenSize = { .... That creates a new object and messes with MobX’s observables.

Second, we add a call to updateScreenSize in the main ImgurApp component. Right where we save orientation.

// ./index.ios.js

class ImgurApp extends Component {
    onLayout(event) {
        const { width, height } = event.nativeEvent.layout;
        const orientation = ( width > height ) ? LANDSCAPE : PORTRAIT;

        Store.changeOrientation(orientation);
        Store.updateScreenSize(width, height);
    }

// ...
}

Third, we have to use the height prop in our TouchableImage component. Find the render method and add to Image‘s style prop.

class TouchableImage extends Component {
    // ...
    render() {
        const { image, store, height } = this.props,
              uri = image.link.replace('http://', 'https://');

        return (
            // ...
                <Image source={{uri: uri}}
                       style={[styles.backgroundImage,
                               styles[store.orientation.toLowerCase()],
                               {height: height || null}]}
                       onLayout={this.onImageLayout.bind(this)}>
                    //...

If height is given, we use it, otherwise we keep the null from styles.

Add a loading spinner

Looking at a blank screen while images load is annoying. A pretty beating heart from loading.io makes it easier.

Heart shaped loading spinner

A Spinner component is a fullscreen view that centers its children and an Image from a local file. The only way to get those in React Native is to use require().

const Spinner = () => (
    <View style={[styles.fullscreen, styles.centered]}>
        <Image source={require('./img/heart.gif')}
               style={{width: 100, height: 100}} />
    </View>
);

The fullscreen and centered styles come from style.js and look like this:

const styles = StyleSheet.create({
    fullscreen: {
        flex: 1,
        width: null,
        height: null
    },
    centered: {
        justifyContent: 'center',
        alignItems: 'center'
    },

    // ...

Then you replace instances of return null in ImgurCarousel and Album, and that’s your final touch. Users see a green beating heart while they wait for Imgur to respond.

Imgur React Native ImgurCarousel

Subscribe with your email to get the next lecture in your inbox. We’re building an app to explore local restaurants on a map.

Got an app you want to see built? Email me.