I’ve been learning React Native for about a week now. In the process, I’ve developed a Subreddit Image Search App. In this tutorial, I’ll try to share what I’ve learned so far to help beginners build their first app. Beware, it’s not one of those tutorials you find on Web from experienced react native folks with full of best practices and shiny fluid UIs. I just tried to make it work, and might not have used the best practices or patterns. And I’m in no position to infer what best practices are, after just a week of learning. I’ve set up a Github repository if you want to directly dive into code without going further.

Getting Started

There are two ways to create a react native app, create-react-native-app and react-native-cli. Apps created with create-react-native-app does not need any build tools (Xcode and Android Studio). Simply create an app and run it in Expo Client on your Android or IOS phone. On the other hand, react-native-cli require Android SKD and Xcode to build and run your apps. We’ll use CRNA to create our app. Later on, I’ll show you how to eject your CRNA app to generate a custom build and explain the need for it.

Creating and Running React Native App

I’m assuming that you’ve already installed Nodejs.

Run npm install -g create-react-native-app to globally install CRNA. Now create a new app and name it reimg.

create-react-native-app reimg cd reimg yarn run start 1 2 3 create - react - native - app reimg cd reimg yarn run start

React Native packager will package your app and show you a few options.

Scan QR code option is no longer available on IOS Expo Client. Press s and enter your email address. You’ll receive an email with expo link. You’ll be redirected to your browser after clicking on Expo link. It will ask you to open it in your Expo app. After accepting, your app will run in Expo client.

Installing Dependencies

{ [...] "devDependencies": { "jest-expo": "~27.0.0", "react-native-scripts": "1.14.0", "react-test-renderer": "16.3.1" }, "scripts": {...}, "jest": {...}, "dependencies": { "expo": "^27.0.1", "react": "16.3.1", "react-native": "~0.55.2", "react-native-axios": "^0.17.1", "react-native-elements": "^1.0.0-beta5", "react-native-flex-image": "^1.2.0", "react-native-vector-icons": "latest", "react-navigation": "^2.0.1", "url": "^0.11.0" } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 { [ . . . ] "devDependencies" : { "jest-expo" : "~27.0.0" , "react-native-scripts" : "1.14.0" , "react-test-renderer" : "16.3.1" } , "scripts" : { . . . } , "jest" : { . . . } , "dependencies" : { "expo" : "^27.0.1" , "react" : "16.3.1" , "react-native" : "~0.55.2" , "react-native-axios" : "^0.17.1" , "react-native-elements" : "^1.0.0-beta5" , "react-native-flex-image" : "^1.2.0" , "react-native-vector-icons" : "latest" , "react-navigation" : "^2.0.1" , "url" : "^0.11.0" } }

Let’s discuss dependencies. We’ll use react-native-axios to make network requests. react-native-elements is a cross-platform UI toolkit. We’ll use it to keep our components look exactly the same on both IOS and Android platform. We’ll use react-native-flex-image to render images in full width, I’ll later explain the need for it. react-navigation is a community developed solution for routing and navigation between components in react native apps. And at last, we’re using url, a small package for parsing URLs.

App Components

Let’s dive into the code and create these components.

App

Search

Results

NotFound

App components come pre-created with CRNA app. We’ll render stack navigator Components in the render method of App component.

import React from 'react'; import { createStackNavigator } from 'react-navigation'; import Search from './components/Search'; import Results from './components/Results'; const Navigator = createStackNavigator({ Home: { screen: Search, }, Results: { screen: Results, }, }, { initialRouteName: 'Home', }); export default class App extends React.Component { render() { return <Navigator />; } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import React from 'react' ; import { createStackNavigator } from 'react-navigation' ; import Search from './components/Search' ; import Results from './components/Results' ; const Navigator = createStackNavigator ( { Home : { screen : Search , } , Results : { screen : Results , } , } , { initialRouteName : 'Home' , } ) ; export default class App extends React . Component { render ( ) { return < Navigator / > ; } }

Stack Navigator provides a way to transition between components. With initialRouteName property, we can specify which component gets rendered on initial app load. We have two screens in our configuration, Search component will render a form where a user can enter a subreddit for image search. Result component will render images and NotFound will be rendered in case subreddit doesn’t exist.

import React from 'react'; import {KeyboardAvoidingView, Image, StyleSheet, Alert} from 'react-native'; import { Input, Button} from 'react-native-elements'; class Search extends React.Component { constructor(props){ super(props); this.state = { text: '' }; } static navigationOptions = { header : null }; searchReddit(e){ if(this.state.text.length){ this.props.navigation.navigate('Results', { subreddit: this.state.text, }); }else{ Alert.alert( 'Invalid Input', 'Please enter a valid Reddit', [ {text: 'OK'} ] ) } } render() { return ( <KeyboardAvoidingView style={styles.view} behavior="padding" > <Image source={require('../res/reddit.png')} style={{ justifyContent: 'center', alignItems: 'center', width: 100, height: 100, }} /> <Input inputContainerStyle={styles.search} containerStyle={styles.container} inputStyle={styles.input} onChangeText={(text) => this.setState({text})} placeholder='Search your favorite Subreddit' /> <Button title='Search' buttonStyle={styles.button} onPress={this.searchReddit.bind(this)} /> </KeyboardAvoidingView> ); } } const styles = StyleSheet.create({...}); export default Search; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 import React from 'react' ; import { KeyboardAvoidingView , Image , StyleSheet , Alert } from 'react-native' ; import { Input , Button } from 'react-native-elements' ; class Search extends React . Component { constructor ( props ) { super ( props ) ; this . state = { text : '' } ; } static navigationOptions = { header : null } ; searchReddit ( e ) { if ( this . state . text . length ) { this . props . navigation . navigate ( 'Results' , { subreddit : this . state . text , } ) ; } else { Alert . alert ( 'Invalid Input' , 'Please enter a valid Reddit' , [ { text : 'OK' } ] ) } } render ( ) { return ( < KeyboardAvoidingView style = { styles . view } behavior = "padding" > < Image source = { require ( '../res/reddit.png' ) } style = { { justifyContent : 'center' , alignItems : 'center' , width : 100 , height : 100 , } } / > < Input inputContainerStyle = { styles . search } containerStyle = { styles . container } inputStyle = { styles . input } onChangeText = { ( text ) = > this . setState ( { text } ) } placeholder = 'Search your favorite Subreddit' / > < Button title = 'Search' buttonStyle = { styles . button } onPress = { this . searchReddit . bind ( this ) } / > < / KeyboardAvoidingView > ) ; } } const styles = StyleSheet . create ( { . . . } ) ; export default Search ;

In Search components, we’re rendering a logo Image, an Input, and Button for search. We’re rendering KeyboardAvoidingView instead of a simple view to move out of the way of virtual keyboard. We’re assigning null to static navigationOptions property to hide default header. We are validating state on button press and navigating to Search component with subreddit prop. This is how our Search component looks like.

import React from "react"; import {ScrollView, View, Text, StyleSheet, ActivityIndicator} from 'react-native'; import {Button} from 'react-native-elements'; import axios from 'react-native-axios'; import FlexImage from 'react-native-flex-image'; import url from 'url'; import NotFound from "./NotFound"; class Results extends React.Component { constructor(props){ super(props); this.state = { subreddit : props.navigation.getParam('subreddit', ''), req_params : '', images : [], loading : false, not_found : false, nextPage : false, }; this._mounted = false; } static navigationOptions = ({ navigation }) => { const { params } = navigation.state; return { title: params ? '/r/'+params.subreddit : 'Results' } }; componentDidMount(){ this.getImages(); this._mounted = true; this.setState({ loading: true, }) } componentWillUnmount() { this._mounted = false; } getPathName(href) { let _url = url.parse(href); return _url.pathname; }; isImage(url) { return (url.match(/\.(jpeg|jpg|gif|png)$/) !== null); } stripAlbumID(url) { let path = this.getPathName(url), ids = path.split('/').filter(part => part !== ""); return ids[ids.length - 1]; } stripAlbumType(url) { if(url.indexOf('/a/') !== -1){ return 'album'; }else if(url.indexOf('/gallery/') !== -1){ return 'gallery'; }else{ return 'image'; } } getImages(){ let images = [], albums = []; axios.get('https://www.reddit.com/r/'+ this.state.subreddit + '.json'+ this.state.req_params) .then(function (response) { let after = response.data.data.after; this.setState({ req_params : after.length ? '?after=' + after : '', nextPage : !!after.length }); response.data.data.children.map(function(item, i){ if (item.kind === 't3') { if (this.isImage(item.data.url)) { images = images.concat({ url : item.data.url, id : item.data.id, title : item.data.title }) } else { if (item.data.domain === 'imgur.com') { let albumID = this.stripAlbumID(item.data.url), type = this.stripAlbumType(item.data.url), APIUrl = "https://api.imgur.com/3/"+type+"/" + albumID + "/images"; albums = albums.concat({ title : item.data.title, url : APIUrl, id : albumID }) } } } }.bind(this)) const url_promises = albums.map(album => { return axios.get(album.url , { headers: {"Authorization" : 'Client-ID YOUR_CLIENT_ID'} }) }); axios.all(url_promises).then(function(results) { results.map(r => { let _album = albums.filter(function( album ) { return album.url === r.config.url; })[0]; if(r.data.data.length){ r.data.data.map(function(res, i){ images = images.concat({ title : _album.title, url : res.link, id : _album.id }) }) }else{ images = images.concat({ url : r.data.data.link, id : _album.id, title : _album.title }); } }); if(this._mounted){ this.setState({ images : this.state.images.concat(images) }, () => { this.setState({ loading : false }) }) } }.bind(this)); }.bind(this)) .catch(function (error) { this.setState({ not_found : true, loading : false }) }.bind(this)); } loadMore(e){ if(!this.state.loading){ this.setState({ loading : true },() => { this.getImages(); }); } } render() { return ( <ScrollView> {this.state.images.map(function(image, index){ return ( <View key={index} style={{ margin : 10 }}> <Text style={{ marginBottom : 10, fontSize : 20, }}>{image.title}</Text> <FlexImage source={{ uri: image.url, cache: 'force-cache' }} loadingComponent={ <ActivityIndicator size='large' color="#ed3904" /> } /> </View> ) })} {this.state.loading && ( <ActivityIndicator size="large" color="#ed3904" style={styles.loading} /> )} {this.state.nextPage && ( <Button title='Load More' buttonStyle={styles.button} onPress={this.loadMore.bind(this)} /> )} {this.state.not_found && ( <NotFound subreddit={this.state.subreddit}/> )} </ScrollView> ); } } const styles = StyleSheet.create({...}); export default Results; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 import React from "react" ; import { ScrollView , View , Text , StyleSheet , ActivityIndicator } from 'react-native' ; import { Button } from 'react-native-elements' ; import axios from 'react-native-axios' ; import FlexImage from 'react-native-flex-image' ; import url from 'url' ; import NotFound from "./NotFound" ; class Results extends React . Component { constructor ( props ) { super ( props ) ; this . state = { subreddit : props . navigation . getParam ( 'subreddit' , '' ) , req_params : '' , images : [ ] , loading : false , not_found : false , nextPage : false , } ; this . _mounted = false ; } static navigationOptions = ( { navigation } ) = > { const { params } = navigation . state ; return { title : params ? '/r/' + params . subreddit : 'Results' } } ; componentDidMount ( ) { this . getImages ( ) ; this . _mounted = true ; this . setState ( { loading : true , } ) } componentWillUnmount ( ) { this . _mounted = false ; } getPathName ( href ) { let _url = url . parse ( href ) ; return _url . pathname ; } ; isImage ( url ) { return ( url . match ( / \ . ( jpeg | jpg | gif | png ) $ / ) !== null ) ; } stripAlbumID ( url ) { let path = this . getPathName ( url ) , ids = path . split ( '/' ) . filter ( part = > part !== "" ) ; return ids [ ids . length - 1 ] ; } stripAlbumType ( url ) { if ( url . indexOf ( '/a/' ) !== - 1 ) { return 'album' ; } else if ( url . indexOf ( '/gallery/' ) !== - 1 ) { return 'gallery' ; } else { return 'image' ; } } getImages ( ) { let images = [ ] , albums = [ ] ; axios . get ( 'https://www.reddit.com/r/' + this . state . subreddit + '.json' + this . state . req_params ) . then ( function ( response ) { let after = response . data . data . after ; this . setState ( { req_params : after . length ? '?after=' + after : '' , nextPage : ! ! after . length } ) ; response . data . data . children . map ( function ( item , i ) { if ( item . kind === 't3' ) { if ( this . isImage ( item . data . url ) ) { images = images . concat ( { url : item . data . url , id : item . data . id , title : item . data . title } ) } else { if ( item . data . domain === 'imgur.com' ) { let albumID = this . stripAlbumID ( item . data . url ) , type = this . stripAlbumType ( item . data . url ) , APIUrl = "https://api.imgur.com/3/" + type + "/" + albumID + "/images" ; albums = albums . concat ( { title : item . data . title , url : APIUrl , id : albumID } ) } } } } . bind ( this ) ) const url_promises = albums . map ( album = > { return axios . get ( album . url , { headers : { "Authorization" : 'Client-ID YOUR_CLIENT_ID' } } ) } ) ; axios . all ( url_promises ) . then ( function ( results ) { results . map ( r = > { let _album = albums . filter ( function ( album ) { return album . url === r . config . url ; } ) [ 0 ] ; if ( r . data . data . length ) { r . data . data . map ( function ( res , i ) { images = images . concat ( { title : _album . title , url : res . link , id : _album . id } ) } ) } else { images = images . concat ( { url : r . data . data . link , id : _album . id , title : _album . title } ) ; } } ) ; if ( this . _mounted ) { this . setState ( { images : this . state . images . concat ( images ) } , ( ) = > { this . setState ( { loading : false } ) } ) } } . bind ( this ) ) ; } . bind ( this ) ) . catch ( function ( error ) { this . setState ( { not_found : true , loading : false } ) } . bind ( this ) ) ; } loadMore ( e ) { if ( ! this . state . loading ) { this . setState ( { loading : true } , ( ) = > { this . getImages ( ) ; } ) ; } } render ( ) { return ( < ScrollView > { this . state . images . map ( function ( image , index ) { return ( < View key = { index } style = { { margin : 10 } } > < Text style = { { marginBottom : 10 , fontSize : 20 , } } > { image . title } < / Text > < FlexImage source = { { uri : image . url , cache : 'force-cache' } } loadingComponent = { < ActivityIndicator size = 'large' color = "#ed3904" / > } / > < / View > ) } ) } { this . state . loading && ( < ActivityIndicator size = "large" color = "#ed3904" style = { styles . loading } / > ) } { this . state . nextPage && ( < Button title = 'Load More' buttonStyle = { styles . button } onPress = { this . loadMore . bind ( this ) } / > ) } { this . state . not_found && ( < NotFound subreddit = { this . state . subreddit } /> )} </S crollView > ) ; } } const styles = StyleSheet . create ( { . . . } ) ; export default Results ;

When our Results component mount, we’re assigning header title with subreddit prop from navigator. In our componentDidMount() method, we’re calling getImages() method to retrieve posts from subreddit. After receiving posts, we’re filtering media posts. If it’s an image, we add it to our local images array. If it’s an imgur.com link, we generate an imgur API URL based on its type. We’re generating axios promises array from filtered imgur links.

After retrieving results from axios promises, we’re adding URLs to our local images[] array. We’ll then update our component state with received images. In render() method we’re rendering ScrollView to render Images with titles. FlexImage component is a wrapper for native Image component for rendering an image in full width and auto height.

ActivityIndicator component can be passed to FlexImage to show a spinning loader when an image is loading. We’re rendering a load more button. When pressed it calls getImages() method with new params to retrieve next page results. And in the end, we’re rendering a NotFound Component to handle a case where Subreddit doesn’t exist.

import React from 'react'; import {Text, StyleSheet, View, Image} from 'react-native'; class NotFound extends React.Component { constructor(props){ super(props); } render() { return ( <View> <Image source={require('../res/reddit.png')} style={styles.image} /> <Text style={styles.message}>Subreddit {this.props.subreddit} not found</Text> </View> ); } } const styles = StyleSheet.create({...}); export default NotFound; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import React from 'react' ; import { Text , StyleSheet , View , Image } from 'react-native' ; class NotFound extends React . Component { constructor ( props ) { super ( props ) ; } render ( ) { return ( < View > < Image source = { require ( '../res/reddit.png' ) } style = { styles . image } / > < Text style = { styles . message } > Subreddit { this . props . subreddit } not found < / Text > < / View > ) ; } } const styles = StyleSheet . create ( { . . . } ) ; export default NotFound ;

Ejecting from CRNA

Running yarn eject converts your CRNA app to react-native app. It would be the same as if you created an app with react-native-cli init command. CRNA apps help you get started quickly, but if you want to distribute your application or write a piece of native code at some point in development, you need to eject it. Read more about in docs.

Create React Native App makes it easy to start working on React Native apps by removing native code build tools from the equation. However, many apps want functionality that comes from interfacing directly with mobile platform APIs via Java, Objective-C, Swift, C, etc. As of right now, the only way to get direct access to these APIs from your app is by “ejecting” from CRNA and building the native code yourself.

To generate a signed apk for your ejected app, please refer to this guide. I’ve set up a repository. You can clone it right now and start hacking it. If you get an error or wants to ask something, please leave a comment and I’ll try to answer it.