Build an Imgur Client with React Native and MobX — React Native School
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.
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
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.
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.
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
, andrender
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.
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.
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.
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.
Originally published at school.shoutem.com.