While working on an Expo app, I needed to implement a component that could be used to search through a user's own saved words and the entire English dictionary simultaneously. Infinite scrolling was an obvious utility in this scenario.
React Native and Expo each provide tools for this specific use case. In this post, I'll show you how to use them together.
Expo's SQLite API has methods built in for the purpose of paginating results. getEachAsync
and getEachSync
return Iterables that can be used to fetch rows from a query in chunks.
Here's an example of a query that fetches 10 rows:
for await (const entry of db.getEachAsync('SELECT * FROM words')) {
console.log(entry)
}
This is already looking clean and minimal! But what about the question of pagination? How do we fetch more rows as needed?
I created a custom React hook called useSearch
that handles the pagination logic. It exposes a search
function to set the search term and returns the result
as well as an additional function loadNext
to fetch more rows.
import { useCallback, useState, useRef } from 'react';
import { useSQLiteContext } from 'expo-sqlite';
import { parseDictionaryEntry } from '@/helpers/parseDictionaryEntry';
export function useSearch() {
const dbQuery = useRef<AsyncIterableIterator<any>>();
const shouldEmptyResultsList = useRef<boolean>(false);
const [result, setResult] = useState<any[]>([]);
const db = useSQLiteContext();
const search = useCallback((term: string) => {
dbQuery.current = db.getEachAsync(
`
SELECT *
FROM words
WHERE words.word LIKE ?
ORDER BY words.word
`,
[`${term}%`]
);
shouldEmptyResultsList.current = true;
}, [db]);
const loadNext = useCallback(async (count: number) => {
if (!dbQuery.current) return;
const results: any[] = [];
let i = 0;
while (i < count) {
const row = await dbQuery.current.next();
if (row.done) break;
const parsedEntry = parseDictionaryEntry(row.value);
results.push(parsedEntry);
i++;
}
setResult((prev) => {
if (shouldEmptyResultsList.current) {
shouldEmptyResultsList.current = false;
return results;
} else {
return [...prev, ...results];
}
});
}, []);
return {
result,
search,
loadNext
}
}
The shouldEmptyResultsList
flag is used to clear the results list only when new search results are returned. This way, the user isn't shown a flashing empty list while they're typing.
React Native also offers a solution specific to infinite scrolling: the FlatList
component. This component is designed to render only the items that are currently visible on the screen and offers event callbacks for when the user scrolls near to the end of the list.
Here's how we integrate it with our useSearch
hook:
import { View, FlatList } from 'react-native';
import { SearchResult } from './SearchResult';
import { useSearch } from '@/hooks/useSearch';
import { useEffect } from 'react';
interface SearchResultsListProps {
search: string;
}
export function SearchResultsList({ search }: SearchResultsListProps) {
const dictionary = useSearch();
useEffect(() => {
dictionary.search(search);
dictionary.loadNext(10);
}, [search]);
return (
<View>
<FlatList
data={dictionary.result}
extraData={dictionary.result}
renderItem={({ item }) => <SearchResult word={item} />}
keyExtractor={(item) => item.id}
onEndReached={() => dictionary.loadNext(10)}
onEndReachedThreshold={0.1}
/>
</View>
)
}
In this case, the SearchResult
component is a simple component that renders a word and its definition, and the search
parameter is passed down from an input in the parent component.
The onEndReached
callback is executed when the user scrolls to the end of the list, and then we load 10 more results.
The endReachedThreshold
and number of results to load in at once should be tweaked to provide the best user experience for the details of your appβthe height of the items and the performance cost of loading in a batch of resultsβto ensure smooth scrolling with no performance hangs.