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:
FlatList
‘s API
onViewableItemsChanged
, viewabilityConfig
)useCallback
solution that causes a stale closure issueonViewableItemsChanged
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.
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.
FlatList
‘s APIBefore 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 ViewToken
s 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
.
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:
useCallback
solution that causes a stale closure issue (see companion project branch stale closure)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.
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.
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.
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.
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.
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.
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:
onViewableItemsChanged
into a useCallback
Hook with an empty dependency arrayIf 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 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 — try LogRocket for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowCompare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.