Sebastian Weber Frontend developer from Germany. Fell in love with CSS over 20 years ago. My fire for web development still blazes. Currently my focus is on React.

Implementing a component visibility sensor with React Native

9 min read 2714

Implementing a component visibility sensor with React Native

This article is about how to implement a visibility sensor for components in a React Native app. With the help of a vertical or horizontal FlatList and its onViewableItemsChanged prop, it is possible to observe events whenever list items appear or disappear in the viewport. Using this concept, you can react to these events, e.g., to start playing videos automatically or to track “component-seen events” for marketing purposes.

In this post, we’ll discuss how to implement a component visibility sensor in React Native using an example project and incrementally building the solution through the following sections:

Each of the “interim solutions” contains an explanation for why the solution is incomplete (i.e., due to a React Native rendering error, as you’ll see). I wanted to explain the different reasons why things do not work; an interplay exists between the limitations of our FlatList callback, which must be stable, and how these React memoization Hooks (useCallback, useEffect, etc.) work.

Scope of the example project

The code examples presented in this article are part of a GitHub companion project. It is an Expo project that uses TypeScript and is scaffolded with create-expo-app.

The demo use case is intentionally kept simple to focus solely on using the FlatList API. It shows Star Wars characters and tracks those who appear on the screen for at least two seconds. If they appear multiple times, they will be tracked only once. Thus, the example app does not feature fancy animations on visibility events because this would be out of scope.

In contrast, the logic invoked when the FlatList triggers visibility change events is just a tracking function to keep things simple. This is in keeping with tracking user events in common business applications.

The following screencast showcases the example app.

Our example app with a visibility sensor

A look at FlatList‘s API

Before I dive into the implementation details of the example project, let’s take a look at the crucial FlatList props to implement a list item visibility detector.

In principle, you have to use two FlatList props:

With viewabilityConfig, you determine what “viewable” means to your app. The config I used most frequently is to detect list items that are at least x percent visible for a minimum amount of time (y ms).

viewabilityConfig={{
  itemVisiblePercentThreshold: 75,
  minimumViewTime: 2000,
}}

With this example config, list items are considered visible when they are at least 75 percent inside of the viewport for at least 2 seconds.

Take a look at the ViewabilityConfig part of ViewabilityHelper to find out about the other valid settings, along with their type definitions.

You need another FlatList prop, onViewableItemsChanged, which is called whenever the visibility of list items changes, according to the settings of our viewabilityConfig type definition.

// ...
  <FlatList
      data={listItems}
      renderItem={renderItem}
      onViewableItemsChanged={info => {
        // access info and do sth with viewable items
      })}
      viewabilityConfig={{
        itemVisiblePercentThreshold: 100,
        minimumViewTime: 2000,
      }}
      // ...
  />
  // ...

Let’s take a close look at the signature of onViewabilityItemsChanged, defined in VirtualizedList, which is used internally by FlatList. The info parameter has the following flow type definition:

// flow definition part of VirtualizedList.js

onViewableItemsChanged?: ?(info: {
    viewableItems: Array<ViewToken>,
    changed: Array<ViewToken>,
    ...
  }) => void

A ViewToken object holds information about the visibility status of a list item.

// flow definition part of ViewabilityHelper.js

type ViewToken = {
  item: any,
  key: string,
  index: ?number,
  isViewable: boolean,
  section?: any,
  ...
};

How can we use this? Let’s take a look at the next TypeScript snippet.

const onViewableItemsChanged = (info: { viewableItems: ViewToken[]; changed: ViewToken[] }): void => {      
      const visibleItems = info.changed.filter((entry) => entry.isViewable);
      visibleItems.forEach((visible) => console.log(visible.item));
  }  

In this example, we are only interested in the changed ViewTokens that we can access with info.changed. Here, we want to log the list items that meet the criteria of viewabilityConfig. As you can see from the ViewToken definition, the actual list item is stored in item.

What is the difference between viewableItems and changed?

After onViewableItemsChanged is invoked by viewabilityConfig, viewableItems stores every list item that meets the criteria of our viewabilityConfig. However, changed only holds the delta of the last onViewableItemsChanged call (i.e., the last iteration).



If you have to do different things for list items that are 100 percent visible for 200ms and those that are 75 percent visible for 500ms, you can utilize FlatList‘s viewabilityConfigCallbackPairs prop, which accepts an array of ViewabilityConfigCallbackPair objects.

This is the flow type definition of viewabilityConfigCallbackPairs, which is part of VirtualizedList.

// VirtualizedList.js

  viewabilityConfigCallbackPairs?: Array<ViewabilityConfigCallbackPair>

The flow type definition of ViewabilityConfigCallbackPair is part of ViewabilityHelper.

// ViewabilityHelper.js
export type ViewabilityConfigCallbackPair = {
  viewabilityConfig: ViewabilityConfig,
  onViewableItemsChanged: (info: {
    viewableItems: Array<ViewToken>,
    changed: Array<ViewToken>,
    ...
  }) => void,
  ...
};

Here is an example:

<FlatList
    data={listItems}
    renderItem={renderItem}
    viewabilityConfigCallbackPairs={[
      {
        viewabilityConfig: {
          minimumViewTime: 200,
          itemVisiblePercentThreshold: 100,
        },
        onViewableItemsChanged: onViewableItemsChanged100
      },
      {
        viewabilityConfig: {
          minimumViewTime: 500,
          itemVisiblePercentThreshold: 75
        },
        onViewableItemsChanged: onViewableItemsChanged75
      }
    ]}
    // ...
/>

If you are interested in the implementation details of the list item detection algorithm, you can read about it here.

FlatList‘s API distilled (onViewableItemsChanged, viewabilityConfig)

With the knowledge we currently have of the relevant API parts, it may seem straightforward to create a visibility sensor. However, the implementation of the function passed to the onViewableItemsChanged prop involves running through a few pitfalls.

Therefore, I will work out the different versions as examples until we get to the final solution. The intermediate solutions each have bugs due to how the FlatList API is implemented and the way that React works.

We’ll cover two different use cases. The first one will be a simple example, firing an event each time a list element appears on screen. The second through fourth more complicated examples build upon each other to demonstrate how to fire events when a list item is in view, but only once.

We’re covering the second use case because it requires managing state, and that’s where the ugly things arise with this FlatList rendering error.

Here are the solutions we’ll try, and what’s wrong with each:

  1. Track every time a Star Wars character (i.e., list element) appears on screen
  2. Track every character only once by introducing state
  3. Try to fix the useCallback solution that causes a stale closure issue (see companion project branch stale closure)
  4. Fix the problem by using a state updater function to access previous state (see companion project branch master)

Interim solution 1: Track every time a list element appears on screen

This first implementation of onViewableItemsChanged tracks every visible item, whenever it appears on the screen.

const trackItem = (item: StarWarsCharacter) =>
    console.log("### track " + item.name);
const onViewableItemsChanged =
  (info: { changed: ViewToken[] }): void => {
    const visibleItems = info.changed.filter((entry) => entry.isViewable);
    visibleItems.forEach((visible) => {
      trackItem(visible.item);
    });
  };

We use the changed object of the info param passed to the function. We iterate over this ViewToken array to store only the list items that are currently visible on the screen in the visibleItems variable. Then, we just call our simplified trackItem function to simulate a tracking call by printing the name of the list item to the console.

This should work, right? Unfortunately, no. We get a render error.

Render error whenever the function is created multiple times

The implementation of FlatList does not allow for the function passed to the onViewableItemsChanged prop to be recreated during the app’s lifecycle.

To solve this, we have to make sure that the function does not change after it is initially created; it needs to be stable during render cycles.

How can we do this? We can use the useCallback Hook.

const onViewableItemsChanged = useCallback(
    (info: { changed: ViewToken[] }): void => {
      const visibleItems = info.changed.filter((entry) => entry.isViewable);
      visibleItems.forEach((visible) => {
        trackItem(visible.item);
      });
    },
    []
  );

With useCallback in place, our function is memoized and is not recreated because there are no dependencies that can change. The render issue disappears and tracking works as expected.

Track a character every time they appear on screen

Interim solution 2: Track a list element only once by introducing state

Next, we would like to track each Star Wars character only once. Therefore, we can introduce a React state, alreadySeen, to keep track of the characters that have already been seen by the user.

As you can see, the useState Hook stores a SeenItem array. The function passed to onViewableItemsChanged is wrapped into a useCallback Hook with one dependency, alreadySeen. This is because we use this state variable to calculate the next state passed to setAlreadySeen.

// TypeScript definitions
interface StarWarsCharacter {
  name: string;
  picture: string;
}
type SeenItem = {
  [key: string]: StarWarsCharacter;
};
interface ListViewProps {
  characters: StarWarsCharacter[];
}
export function ListView({
  characters,
}: ListViewProps) {
const [alreadySeen, setAlreadySeen] = useState<SeenItem[]>([]);
const onViewableItemsChanged = useCallback(
    (info: { changed: ViewToken[] }): void => {
      const visibleItems = info.changed.filter((entry) => entry.isViewable);
      // perform side effect
      visibleItems.forEach((visible) => {
        const exists = alreadySeen.find((prev) => visible.item.name in prev);
        if (!exists) trackItem(visible.item);
      });
      // calculate new state
      setAlreadySeen([
        ...alreadySeen,
        ...visibleItems.map((visible) => ({
          [visible.item.name]: visible.item,
        })),
      ]);
    },
    [alreadySeen]
  );
  // return JSX
}

Again, we have a problem. Because of the dependency alreadySeen, the function gets created more than once and, thus, we are delighted by our render error again.

We could get rid of the render error by omitting the dependency with an ESLint ignore comment.

const onViewableItemsChanged = useCallback(
    (info: { changed: ViewToken[] }): void => {
        // ...
    },
    // bad fix
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );

But, as I pointed out in my article about the useEffect Hook, you should never omit dependencies that you use inside of your Hook. There is a reason that the ESLint react-hooks plugin tells you that dependencies are missing.

In our case, we get a stale closure issue, and our alreadySeen state variable does not get updated anymore. The value remains the initial value, which is an empty array.

The initial value of the array does not get updated

But if we do what the ESLint plugin tells us to do, we get our annoying render error again. We are at a dead end.

Somehow, we need to find a solution with an empty dependency array due to the limitations of the FlatList implementation.

Interim solution 3: Try to fix the stale closure issue and return to an empty dependency array

How do we get back to an empty dependency array? We can use the state updater function, which can accept a function with the previous state as parameter. You can find out more about state updater functions in my LogRocket article on the differences between useState and useRef.

  const onViewableItemsChanged = useCallback(
    (info: { changed: ViewToken[] }): void => {
      const visibleItems = info.changed.filter((entry) => entry.isViewable);
      setAlreadySeen((prevState: SeenItem[]) => {
        // perform side effect
        visibleItems.forEach((visible) => {
          const exists = prevState.find((prev) => visible.item.name in prev);
          if (!exists) trackItem(visible.item);
        });
        // calculate new state
        return [
          ...prevState,
          ...visibleItems.map((visible) => ({
            [visible.item.name]: visible.item,
          })),
        ];
      });
    },
    []
  );

The main difference between them is that the state updater function has access to the previous state, and thus, we do not have to access the state variable alreadySeen directly. This way, we have an empty dependency array and the function works as expected (see the screencast in the above section on the companion project).

The next section discusses the render error and stale closure problem a little bit further.

A close look at the problem with onViewableItemsChanged

React’s memoization Hooks, such as useEffect and useCallback, are built in such a way that every component context variable is added to the dependency array. The reason for this is that Hooks only get invoked when at least one of these dependencies has been changed with respect to the last run.

To assist you with this cumbersome and error-prone task, the React team has built an ESLint plugin. However, even if you know that the dependency will never change again during runtime, as a good developer, you have to add it to the dependency array. The plugin authors know that, e.g., state updater functions are stable, so the plugin does not demand it in the array, in contrast to the other (not pure) functions.

If you return such a state updater function from a custom Hook and use it in a React component, the plugin mistakenly claims to add it as dependency. In such a case, you have to add an ESLint disable comment to mute the plugin (or live with the warning).

However, this is bad practice. Though it shouldn’t be a problem to add it to the dependency array, the usage of the Hook gets more robust as this variable could change over time, e.g., after a refactoring. If it creates a problem for you, you most likely have a bug in your project.

The problem with this onViewableItemsChanged prop from FlatList is that you cannot add any dependencies to the dependency array at all. That’s a limitation of the FlatList implementation that contradicts with the concepts of memoization Hooks.

Thus, you have to find a solution to get rid of a dependency. You most likely want to use the approach as I described above, to use a state updater function that accepts a callback function to have access to the previous state.

If you want to refactor the implementation of the onViewableItemsChanged function — to reduce complexity or to improve testability by putting it into a custom Hook — it is not possible to prevent a dependency. There is currently no way to tell React that the custom Hook returns a stable dependency like the result of the inbuilt useRef Hook or the state updater function.

In my current project at work, I have chosen to add an ESLint disable comment because I know that the dependency coming from my custom Hook is stable. In addition, you can either ignore the pure functions you get as a result of custom Hooks, or import them from another file because they don’t use any dependencies themselves.

useCallback(
    () => {
      /* 
        Uses state updater functions from a custom hook or imported pure functions.
        The ESLint plugin does not know that the functions are stable / do not use any dependencies. It does not know that they can be omitted from the array list.
      /*      
    }, 
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
)

There have been many discussions in the past about marking custom Hooks’ return values as stable, but there is no official solution yet.

Summary

FlatList‘s onViewableItemsChanged API provides the ability to detect components that appear or disappear on screen. A FlatList can be used vertically and horizontally. Thus, this can be used in most use cases, since screen components are usually organized in a list anyway.

The thing with this approach is that the implementation of viewport detection logic is limited, tedious, and error-prone at first. This is because the assigned function must never be recreated after its initial creation. This means that you cannot rely on any dependencies at all! Otherwise, you‘ll get a render error.

To summarize, here are your options for working around this problem:

  • Wrap the function you assign to onViewableItemsChanged into a useCallback Hook with an empty dependency array
  • If you use one or more component state variables inside of your function, you have to use the state updater function that accepts a callback with access to the previous state, so that you get rid of state dependencies
  • If you rely on a state updater function or a pure function from another file (such as an imported or a custom Hook), you can keep the empty dependency array and ignore the ESLint plugin warnings
  • If you rely on any other dependencies (e.g., prop, context, state variable from a custom Hook, etc.), you have to find a solution without using them

If you want to perform complex tasks on visibility change events, you have to design your events in a way that updates your state variables with the state updater function to keep an empty dependency array for the useCallback Hook. Then, you can respond on state changes, e.g., with an useEffect Hook, to perform logic that relies on dependencies. Such a scenario could become complicated, but with the workarounds we’ve discussed here, you should have an easier time finding and implementing a solution that works for you.

LogRocket: Instantly recreate issues in your React Native apps.

LogRocket is a React Native monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your React Native apps.

LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.

Start proactively monitoring your React Native apps — .

Sebastian Weber Frontend developer from Germany. Fell in love with CSS over 20 years ago. My fire for web development still blazes. Currently my focus is on React.

Leave a Reply