Building infinite scroll in React Native

Robert DeLuca

December 15, 2016

In high school, I spent a long time trying to learn how to build iOS apps. After many months of attempting what seemed impossible at the time, I decided to throw in the towel. So I gave up and to went to design, which ultimately led to web work.

That desire to build native mobile apps never went away for me. But learning an entirely new language and ecosystem wasn't something that I could do in my spare time. So when React Native busted onto the scene in 2015 it caught my attention. Since it's written in JavaScript, React Native allows you (the JS dev) to use technologies you're already familiar with to build truly native apps.

Around the same time at The Frontside we started to write most of our libraries in pure JavaScript. This allowed us to wrap those libraries in whatever framework we happened to be working in at the time. That means we can take problems we've solved in Ember apps and use them in a React Native project. Which is exactly what we're going to do today!

Introduction

The purpose of this post is to build an infinite scroll component for React Native based on Impagination.js. We'll talk more about Impagination a little later but for now, just know Impagination is a lazy data layer for your paged records.

We're also going to lean on NativeBase for nice prestyled native looking components. If you're coming from the web think of NativeBase like Bootstrap. The main difference is the styling will be tailored to the OS running the app. So in iOS, you're going to get iOS styled elements and in Android, you'll get Android styled elements.

The API we will be using is a Rails app that returns paginated data which is generated from the faker gem. Impagination will work with any paginated API, so if you would like you can sub in your own API here.

Here's an iOS screenshot of what we're going to build:

iOS Screenshot of the app we will be building

You can also check out the repo for what we're going to build here. If you haven't set up React Native on your machine yet, you should take a moment and do that. The getting started docs are a great resource.

To sum up our goals for this post into a list:

  • Introduce Impagination
  • Create the React Native app
  • Create a component that will be shared in iOS and Android
  • Use NativeBase for styling
  • Create an Impagination dataset
  • Render that dataset to the app screen
  • Listen for scroll events to request new data

Hello Impagination

Before we start actually building our app I want to take second to talk about Impagination. Like I said earlier, Impagination is a lazy data layer for your paged records. All you provide Impagination is the logic to fetch a single page, plus how many records you want it to pre-fetch ahead of you. Impagination will handle the rest for you.

Impagination is built using an event-driven immutable style and has zero dependencies. That means you can use it anywhere that JS can run! From the server to the client, it doesn't matter as long as it's JS.

There are two required attributes for creating an Impagination dataset: fetch and pageSize.

fetch is a function that will tell Impagination how to go get the data you are requesting as you scroll through the infinite list.

pageSize is an integer that tells Impagination how many records are in each page.

Okay, that's enough of an introduction to Impagination. As we build our app I will stop and explain more of Impagination as we build the app.

Creating the app

Let's start creating our React Native app! I'm going to call this app robotImpagination since the gem generating our fake data uses a bunch of different robots for images. Feel free to name your app whatever you like.

react-native init robotImpagination

This will create a hello world react native app. If you cd into that directory and run react-native run-ios (or react-native run-android) it should start the React Native packager, build the iOS app, and launch the simulator.

Now that we have confirmed that the app will build, shut it all down. We need to install a couple dependencies to build our infinite scroll. Let's install NativeBase first: yarn add native-base. Then Impagination: yarn add impagination. You should now see both of those libraries in your package.json file.

No place like Home

Make your way to the index.ios.js (or index.android.js if you're doing android dev). You should see a couple imports and then a class that's extending Component:

export default class robotImpagination extends Component {
  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.welcome}>
          Welcome to React Native!
        </Text>
        <Text style={styles.instructions}>
          To get started, edit index.android.js
        </Text>
        <Text style={styles.instructions}>
          Double tap R on your keyboard to reload,{'\n'}
          Shake or press menu button for dev menu
        </Text>
      </View>
    );
  }
}

Delete everything inside of the render method. We're going to replace that with a <Home /> component. This component will be used in both the index.ios.js & index.android.js files.

export default class robotImpagination extends Component {
  render() {
    return (
      <Home />
    );
  }
}

If you tried to run this right now, it wouldn't work. We still need to create that component and import it. For smaller projects, I like to put my components in a components folder. You can name it and place it where ever you would like. For me it's components in the root of the project: robotImpagination/components/Home.js

Inside of Home.js we should import React, the Text component from React Native, and create a component that returns "Hello!":

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

export default class Home extends Component {
  render() {
    return (
      <Text>Hello!</Text>
    );
  }
}

Now that we've actually created the <Home /> component we can import and use it in both of our index files (index.ios.js & index.android.js).

Your index files should look something like this now:

import React, { Component } from 'react';

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

import Home from './components/Home';

export default class robotImpagination extends Component {
  render() {
    return (
      <Home />
    );
  }
}

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

Awesome! If you refresh your simulator you should now see a poorly styled "Hello!".

<Home /> will be the main component we will be working out of. This is so our code will run on both iOS & Android. Write once, compile to both!

Furnishing our Home

We now have an app that's uglier than what we started with. Don't fret, we're going to call on NativeBase to make everything look nice and clean. Inside of Home.js lets import four components:

// Native base for nice prestyled components
import {
  Header,
  Container,
  Title,
  Content,
} from 'native-base';

These components will provide the structure & styling going forward. I recommend taking 10 minutes to review the NativeBase docs. Now we can make our lonely "Hello!" text look much better:

// imports here

export default class Home extends Component {
  render() {
    return (
      <Container>
        <Header>
          <Title>Robot Impagination</Title>
        </Header>
        <Content>
          <Text>Hello!</Text>
        </Content>
      </Container>
    );
  }
}

This should give you a styled header with bold text inside of it and our "Hello!" text isn't in the very top left of the device. Nice!

Next, we should create at least one "card" where we will be rendering the content of each record into. Later on we will iterate over an array returning many of these, but for now, we're only going to create one card.

Thankfully we don't have to style this ourselves since NativeBase has a card component. We need to import two more components from NativeBase:

// Native base for nice prestyled components
import {
  Header,
  Container,
  Title,
  Content,
  Card,
  CardItem
} from 'native-base';

And additionally the Image component from react-native:

import {
  Image,
  Text,
} from 'react-native';

Now that we have access to these components lets fill it in:

// imports here

export default class Home extends Component {
  render() {
    return (
      <Container>
        <Header>
          <Title>Robot Impagination</Title>
        </Header>
        <Content>
          <Card style={{margin: 10}}>
            <CardItem>
              <Text>Hello!</Text>
            </CardItem>
            <CardItem>
              <Image style={{resizeMode: 'cover'}} source={{uri: "https://placekitten.com/640/440"}} />
            </CardItem>
            <CardItem>
              <Text>Item description</Text>
            </CardItem>
          </Card>
        </Content>
      </Container>
    );
  }
}

Our app should look something like this:

iOS Screenshot of the new card component styling

We have a nicely styled app with styled cards, lets start scrolling through paginated data!

Creating an Impagination dataset

The first thing we have to do is import Impagination into Home.js:

import Dataset from 'impagination';

Now create a method called setupImpagination inside of our Home component. setupImpagination is where we're going to create a new instance of Impagination, set the pageSize, and set the dataset on the components local state. Earlier I mentioned that there are two required params from Impagination to work. Let's start by filling in those two params.

export default class Home extends Component {
  setupImpagination() {
    let dataset = new Dataset({
      pageSize: 15,

      // Where to fetch the data from.
      fetch(pageOffset, pageSize, stats) {
        return fetch(`https://serene-beach-38011.herokuapp.com/api/faker?page=${pageOffset + 1}&per_page=${pageSize}`)
          .then(response => response.json())
          .catch((error) => {
            console.error(error);
          });
      }
    });
  }

  render() {
    // ...
  }
}

The fetch function is where Impagination will hit your API to get more pages. There are three arguments passed to the fetch function: pageOffset which is the current page it's going to fetch, pageSize which is a number of records in a page, and stats which can hold totalPages if your API supports it.

Note: you may need to add one to pageOffset since it's zero based.

This looks great! But we still have some work to do in order for it to work. We currently have no way of accessing the data that is emitted by Impagination.

Impagination has two different objects you will work with. One is state which holds all of the current records and the current state regarding that data. The other is the dataset which allows you to call Impagination methods like setReadOffset.

A new state is emitted every single time the data has changed. We can listen for these changes using Impagination's observe method:

export default class Home extends Component {
  setupImpagination() {
    let dataset = new Dataset({
      pageSize: 15,

      // Anytime there's a new state emitted, we want to set that on
      // the componets local state.
      observe: (datasetState) => {
        this.setState({datasetState});
      },

      // Where to fetch the data from.
      fetch(pageOffset, pageSize, stats) {
        return fetch(`https://serene-beach-38011.herokuapp.com/api/faker?page=${pageOffset + 1}&per_page=${pageSize}`)
          .then(response => response.json())
          .catch((error) => {
            console.error(error);
          });
      }
    });
  }

  render() {
    // ...
  }
}

Now that we're calling setState we have to create our state object in the component constructor:

export default class Home extends Component {
  constructor(props) {
    super(props);

    this.state = {
      dataset: null,
      datasetState: null,
    };
  }

  setupImpagination() { //... }
  render() { //... }
}

While we're here we'll also add dataset into our state object.

To pull all of this together and start fetching data we need to set the dataset on the components local state. Then we need to set the readOffset to record 0. This is so Impagination knows exactly what record you are on when scrolling through the list. If we get close to the end it will automatically fetch new records. You can read more about how this all works here.

The final look of our setupImpagination method:

export default class Home extends Component {
  constructor(props) { //... }

  setupImpagination() {
    let dataset = new Dataset({
      pageSize: 15,

      // Anytime there's a new state emitted, we want to set that on
      // the componets local state.
      observe: (datasetState) => {
        this.setState({datasetState});
      },

      // Where to fetch the data from.
      fetch(pageOffset, pageSize, stats) {
        return fetch(`https://serene-beach-38011.herokuapp.com/api/faker?page=${pageOffset + 1}&per_page=${pageSize}`)
          .then(response => response.json())
          .catch((error) => {
            console.error(error);
          });
      }
    });

    // Set the readOffset to the first record in the state
    dataset.setReadOffset(0);
    this.setState({dataset});
  }

  render() { //... }
}

Finally, as soon as the component starts to mount we want to setup our data store. In componentWillMount lets call setupImpagination:

export default class Home extends Component {
  constructor(props) { //... }

  setupImpagination() { //... }

  componentWillMount() {
    this.setupImpagination();
  }

  render() { //... }
}

Bam! Now when the app loads it will go off and fetch the first 15 records from the API and set it on the local state of the component. You can now access these records by doing this.state.datasetState.

Looping over the datasetState

With the styling and data now in place lets render it to the screen. this.state.datasetState is an array-like object. This means we can iterate over the state while still being able to access getters like this.state.datasetState.readOffset (which returns the current readOffset).

We're going to map over the Impagination state and return a card:

export default class Home extends Component {
  constructor(props) { //... }

  setupImpagination() { //... }

  componentWillMount() { //... }

  render() {
    return (
      <Container>
        <Header>
          <Title>Robot Impagination</Title>
        </Header>
        <Content>
          {this.state.datasetState.map(record => {
            return (
              <Card style={{margin: 10}}>
                <CardItem>
                  <Text>Hello!</Text>
                </CardItem>
                <CardItem>
                  <Image style={{resizeMode: 'cover'}} source={{uri: "https://placekitten.com/640/440"}} />
                </CardItem>
                <CardItem>
                  <Text>Item description</Text>
                </CardItem>
              </Card>
            );
          })}
        </Content>
      </Container>
    );
  }
}

If you refresh your simulator you should now see 15 kittens. But it's 15 cards with same text over and over again. We can start to pull information from each record we're iterating over:

export default class Home extends Component {
  constructor(props) { //... }

  setupImpagination() { //... }

  componentWillMount() { //... }

  render() {
    return (
      <Container>
        <Header>
          <Title>Robot Impagination</Title>
        </Header>
        <Content>
          {this.state.datasetState.map(record => {
            return (
              <Card style={{margin: 10}}>
                <CardItem>
                  <Text>{record.content.title}</Text>
                </CardItem>
                <CardItem>
                  <Image style={{resizeMode: 'cover'}} source={{uri: record.content.image}} />
                </CardItem>
                <CardItem>
                  <Text>{record.content.description}</Text>
                </CardItem>
              </Card>
            );
          })}
        </Content>
      </Container>
    );
  }
}

Refreshing your simulator should bring up an error message that says Cannot read property 'title' of null. This is because Impagination emits an array with 15 items as soon as it's instantiated. Each record in the array has five state properties:

  • isRequested
  • isSettled
  • isPending
  • isResolved
  • isRejected

With these properties, we can tell exactly what state each record is in. This allows us to display different UI for each individual record if we please. In this case, we're going to show a loading spinner if the record hasn't settled yet.

Import the spinner component from NativeBase:

// Native base for nice prestyled components
import {
  Header,
  Container,
  Title,
  Content,
  Card,
  CardItem,
  Spinner,
} from 'native-base';

Then add a conditional inside the map function that returns different JSX:

export default class Home extends Component {
  constructor(props) { //... }

  setupImpagination() { //... }

  componentWillMount() { //... }

  render() {
    return (
      <Container>
        <Header>
          <Title>Robot Impagination</Title>
        </Header>
        <Content>
          {this.state.datasetState.map(record => {
            if (!record.isSettled) {
              return <Spinner key={Math.random()}/>;
            }

            return (
              <Card style={styles.cardContainer}>
                <CardItem>
                  <Text>{record.content.title}</Text>
                </CardItem>
                <CardItem>
                  <Image style={{resizeMode: 'cover'}} source={{uri: record.content.image}} />
                </CardItem>
                <CardItem>
                  <Text>{record.content.description}</Text>
                </CardItem>
              </Card>
            );
          })}
        </Content>
      </Container>
    );
  }
}

This is great! It now will render each record with their own content. But it's pretty hard to read that render method, we should refactor the card into its own presentation component. Create another component in the components folder called RobotItem.js. Then copy and paste the card component code from Home.js:

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

import {
  Card,
  CardItem,
} from 'native-base';

export default class RobotItem extends Component {
  constructor(props) {
    super(props);

    this.recordData = props.record.content;
  }

  render() {
    return (
      <Card style={{margin: 10}}>
        <CardItem>
          <Text>{this.recordData.title}</Text>
        </CardItem>
        <CardItem>
          <Image style={{resizeMode: 'cover'}} source={{uri: this.recordData.image}} />
        </CardItem>
        <CardItem>
          <Text>{this.recordData.description}</Text>
        </CardItem>
      </Card>
    );
  }
}

We also create a little shorthand in the constructor so we don't have to type this.props.record.content each time we have to access data.

There's still a little bit of refactoring left in Home.js. We're going to pull the map out of the render function and put it into its own method called renderItem.

export default class Home extends Component {
  constructor(props) { //... }

  setupImpagination() { //... }

  componentWillMount() { //... }

  renderItem() {
    return this.state.datasetState.map(record => {
      if (!record.isSettled) {
        return <Spinner key={Math.random()}/>;
      }

      return <RobotItem record={record} key={record.content.id} />;
    });
  }

  render() {
    return (
      <Container>
        <Header>
          <Title>Robot Impagination</Title>
        </Header>
        <Content>
          {this.renderItem()}
        </Content>
      </Container>
    );
  }
}

Don't forget to prune the imports we're no longer using at the top of Home.js.

Putting the "infinite" in scroll

We're so close! It's now rendering all 15 items in our first page but we're not able to scroll to the bottom and retrieve new records. Why? It's because as we scroll we have to set Impagination's readOffset. Basically, we have to tell Impagination which record is currently being viewed by the user.

As we progress through the list of records Impagination will fetch more pages if we're within the loadHorizon. By default, the loadHorizon is the same as the page size. This means Impagination is constantly keeping track of where we're at so it can smartly fetch new records as needed. If you up the loadHorizon to 30 it'll load even more pages ahead of the current scroll position.

In order to set the readOffset we're going to hook into the onScroll event on the <Content> component. The <Content> component descends from React Natives ScrollView component, so if you're not using NativeBase you can still use this same method.

Let's create a method called setCurrentReadOffset and then call that anytime the scroll event is called.

export default class Home extends Component {
  constructor(props) { //... }

  setupImpagination() { //... }

  componentWillMount() { //... }

  renderItem() { //... }

  setCurrentReadOffset = (event) => {
    // Log the current scroll position in the list in pixels
    console.log(event.nativeEvent.contentOffset.y);
  }

  render() {
    return (
      <Container>
        <Header>
          <Title>Robot Impagination</Title>
        </Header>
        <Content onScroll={this.setCurrentReadOffset}>
          {this.renderItem()}
        </Content>
      </Container>
    );
  }
}

If you refresh your simulator, enable remote JS debugging, scroll the list, and then look in the JS console you should see a bunch of logs with numbers. This is the current scroll position of the list in pixels. We're going to use contentOffset.y to help calculate what record we're seeing in the viewport.

Since this list is rendering items with the exact same height each time it's pretty easy for us to figure out what item is currently scrolled into view. You take the currentOffset.y and divide it by the items height. That's your current readOffset (aka the current record in view).

Finally, we should throttle the amount of times setCurrentReadOffset is called. As of right now it's called every single time there's a scroll event, which is extremely noisy. We'll cut this down by setting scrollEventThrottle to 300 (ms).

export default class Home extends Component {
  constructor(props) { //... }

  setupImpagination() { //... }

  componentWillMount() { //... }

  renderItem() { //... }

  setCurrentReadOffset = (event) => {
    let itemHeight = 402;
    let currentOffset = Math.floor(event.nativeEvent.contentOffset.y);
    let currentItemIndex = Math.ceil(currentOffset / itemHeight);

    this.state.dataset.setReadOffset(currentItemIndex);
  }

  render() {
    return (
      <Container>
        <Header>
          <Title>Robot Impagination</Title>
        </Header>
        <Content scrollEventThrottle={300} onScroll={this.setCurrentReadOffset}>
          {this.renderItem()}
        </Content>
      </Container>
    );
  }
}

Go ahead, refresh your simulator and scroll through that list! The API only has 100 records seeded to the DB so don't expect it to be truly infinite. Here's a GIF of what we've built together:

GIF demo of the app we just built together

It's so beautiful.

One last optimization

Using a ScrollView for something that is truly infinate might not be the best idea but you can add one property to the ScrollView significantly cut the memory usage: removeClippedSubviews. Since NativeBase's Content component is backed by a ScrollView we can use this same property. removeClippedSubviews will take the off screen cards and remove them from the native backing superview.

export default class Home extends Component {
  constructor(props) { //... }

  setupImpagination() { //... }

  componentWillMount() { //... }

  renderItem() { //... }

  setCurrentReadOffset = (event) => { //... }

  render() {
    return (
      <Container>
        <Header>
          <Title>Robot Impagination</Title>
        </Header>
        <Content scrollEventThrottle={300} onScroll={this.setCurrentReadOffset} removeClippedSubviews={true}>
          {this.renderItem()}
        </Content>
      </Container>
    );
  }
}

Conclusion

First off, great job! You have just built infinite scroll in React Native from scratch, which isn't an easy task. In about an hour we were able to take a library that was originally written to solve a problem in Ember.js and apply it to a React Native Project.

I think this speaks volumes about the power of writing libraries in plain old JavaScript. Not only can this library be used in any type JavaScript project, it will have access to a broader community. This is because any JavaScript developer can jump in and contribute back to the project.

For me, this means I can finally realize my childhood dream of building an iOS app. I don't have to relearn an entire development ecosystem. Sure you'll have to learn new things about native development but the barrier to entry is tremendously lower.

Here's my hot take on where React Native fits into the JS ecosystem. React Native has hit the sweet spot that Cordova and PhoneGap has been trying to hit for years: you can write native apps in JavaScript with no performance implications. As we’ve seen, it’s easy to share the same JavaScript libraries from the web and node (if that’s your thing), to your native app. Now that’s amazing.

Web developers have been trying to recreate "that native experience" in the browser for years. But here's the thing: native is a moving target. So if you can't keep up, why not join them? But join them with your existing JavaScript knowledge. Sounds like the best of both worlds to me!

Once again if you would like to take a look at the completed app here's the GitHub repo. If you have any questions, comments, or feedback I'm always available on Twitter @robdel12

Image credit

Subscribe to our DX newsletter

Receive a monthly curation of resources about testing, design systems, CI/CD, and anything that makes developing at scale easier.