Урок 06

Pull to refresh и пагинация

Стандартные паттерны мобильных приложений.

Pull to refresh

function Posts() {
    const { data, isLoading, refetch, isRefetching } = useQuery({
        queryKey: ['posts'],
        queryFn: fetchPosts,
    });

    if (isLoading) return <ActivityIndicator />;

    return (
        <FlatList
            data={data}
            renderItem={({ item }) => <PostCard post={item} />}
            keyExtractor={(item) => item.id.toString()}
            refreshing={isRefetching}
            onRefresh={refetch}
        />
    );
}
  • refreshing — индикатор
  • onRefresh — что вызвать

Кастомный RefreshControl

import { RefreshControl } from 'react-native';

<FlatList
    refreshControl={
        <RefreshControl
            refreshing={isRefetching}
            onRefresh={refetch}
            tintColor="#c1442e"     // iOS
            colors={['#c1442e']}    // Android
        />
    }
    data={data}
    renderItem={renderItem}
/>

Пагинация с useInfiniteQuery

import { useInfiniteQuery } from '@tanstack/react-query';

function Posts() {
    const {
        data,
        fetchNextPage,
        hasNextPage,
        isFetchingNextPage,
        refetch,
        isRefetching,
    } = useInfiniteQuery({
        queryKey: ['posts'],
        queryFn: ({ pageParam }) => api.get(`/posts?page=${pageParam}`).then((r) => r.data),
        initialPageParam: 1,
        getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
    });

    const posts = data?.pages.flatMap((p) => p.data) ?? [];

    return (
        <FlatList
            data={posts}
            keyExtractor={(item) => item.id.toString()}
            renderItem={({ item }) => <PostCard post={item} />}
            refreshing={isRefetching}
            onRefresh={refetch}
            onEndReached={() => hasNextPage && fetchNextPage()}
            onEndReachedThreshold={0.5}
            ListFooterComponent={isFetchingNextPage ? <ActivityIndicator style={{ margin: 16 }} /> : null}
        />
    );
}

Курсорная пагинация

Вместо номера страницы — курсор:

useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam }) => api.get(`/posts?cursor=${pageParam ?? ''}`),
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
});

Преимущества: не сдвигается при добавлении новых записей.

FlatList onEndReached

<FlatList
    onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()}
    onEndReachedThreshold={0.5}
/>

Защита от повторных вызовов.

Footer

ListFooterComponent={() => {
    if (isFetchingNextPage) return <ActivityIndicator style={{ padding: 16 }} />;
    if (!hasNextPage && posts.length > 0) return <Text style={styles.end}>Больше нет</Text>;
    return null;
}}

Empty state

ListEmptyComponent={
    <View style={styles.empty}>
        <Text>Пока нет постов</Text>
    </View>
}

Оптимизация

<FlatList
    data={posts}
    keyExtractor={keyExtractor}
    renderItem={renderItem}
    initialNumToRender={10}
    maxToRenderPerBatch={10}
    windowSize={5}
    removeClippedSubviews
/>

Мемоизируй renderItem и keyExtractor.

Ошибка загрузки следующей страницы

const { isFetchNextPageError, failureReason } = useInfiniteQuery({...});

ListFooterComponent={() => {
    if (isFetchNextPageError) {
        return (
            <View style={styles.error}>
                <Text>Ошибка загрузки</Text>
                <Pressable onPress={() => fetchNextPage()}>
                    <Text>Повторить</Text>
                </Pressable>
            </View>
        );
    }
    return null;
}}

Итоги

  • refreshing + onRefresh для pull
  • useInfiniteQuery для пагинации
  • getNextPageParam для следующей
  • onEndReached для автозагрузки
  • ListFooterComponent для индикатора
  • Курсорная пагинация надёжнее