wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

GraphQL + Firestoreでクエリカーソルを使用したページングを実装②

前回の記事でサーバー側の実装が完了したので、今回はアプリ側を対応していきます。

www.wheatandcat.me

Pull Request

github.com

実装

まず、ページング用のCustom Hooksを作成

src/hooks/useItemsInPeriodPaging.tsx

import { useState, useEffect, useCallback } from 'react';
import {
  ItemsInPeriodQuery as Query,
  ItemsInPeriodQueryHookResult as QueryHookResult,
} from 'queries/api/index';

export type Item = NonNullable<EdgesNode<Query['itemsInPeriod']>>;
export type ItemsInPeriodPageInfo = PageInfo<Query['itemsInPeriod']>;

type Props = QueryHookResult;
type Option = {
  merge?: boolean;
};

const useItemsInPeriodPaging = (
  props: Props,
  option: Option = { merge: false }
) => {
  const { data, loading } = props;
  const [nodes, setNodes] = useState<Item[]>([]);
  const pageInfo = getPageInfo(data);

  useEffect(() => {
    if (!data?.itemsInPeriod?.edges) return;
    if (loading) return;

    if (option.merge) {
      const tmp = new Set();
      setNodes((s) =>
        [...s, ...getNodes(data)].filter((n) => !tmp.has(n.id) && tmp.add(n.id))
      );
    } else {
      setNodes(getNodes(data));
    }
  }, [data, loading, option.merge]);

  const reset = useCallback(() => {
    setNodes([]);
  }, []);

  return {
    items: nodes,
    pageInfo: pageInfo,
    reset,
  };
};

export default useItemsInPeriodPaging;

const getPageInfo = (data: Query | undefined) =>
  data?.itemsInPeriod?.pageInfo ||
  ({
    endCursor: '',
    hasNextPage: false,
  } as ItemsInPeriodPageInfo);

const getNodes = (data: Query | undefined) =>
  (data?.itemsInPeriod?.edges || []).map((w) => w?.node) as Item[];

props.data(GraphQLのResponseの値)に変更があったらnodesに値をマージして追加

  const { data, loading } = props;
  const [nodes, setNodes] = useState<Item[]>([]);
  const pageInfo = getPageInfo(data);

  useEffect(() => {
    if (!data?.itemsInPeriod?.edges) return;
    if (loading) return;

    if (option.merge) {
      const tmp = new Set();
      setNodes((s) =>
        [...s, ...getNodes(data)].filter((n) => !tmp.has(n.id) && tmp.add(n.id))
      );
    } else {
      setNodes(getNodes(data));
    }
  }, [data, loading, option.merge]);

上記のCustom Hooksを以下のように使用

src/components/pages/Memoir/Connected.tsx

import React, { memo, useCallback, useState } from 'react';
import dayjs from 'dayjs';
import advancedFormat from 'dayjs/plugin/advancedFormat';
import 'dayjs/locale/ja';
import { useItemsInPeriodQuery } from 'queries/api/index';
import useItemsInPeriodPaging from 'hooks/useItemsInPeriodPaging';
import Plain from './Plain';

dayjs.locale('ja');
dayjs.extend(advancedFormat);

type Props = {
  startDate: string;
  endDate: string;
};

export type State = {
  after: string | null;
};

export type ConnectedType = {
  startDate: string;
  endDate: string;
  onItem: () => void;
  onLoadMore: (after: string | null) => void;
};

const initialState = () => ({
  after: '',
});

const Connected: React.FC<Props> = (props) => {
  const [state, setState] = useState<State>(initialState());

  const queryResult = useItemsInPeriodQuery({
    variables: {
      input: {
        startDate: props.startDate,
        endDate: props.endDate,
        first: 8,
        after: state.after,
      },
    },
  });

  const { items, pageInfo } = useItemsInPeriodPaging(queryResult, {
    merge: true,
  });

  const onLoadMore = useCallback((after: string | null) => {
    setState((s) => ({
      ...s,
      after,
    }));
  }, []);

  const onItem = useCallback(() => {}, []);

  return (
    <Plain
      startDate={props.startDate}
      endDate={props.endDate}
      items={items}
      pageInfo={pageInfo}
      onLoadMore={onLoadMore}
      loading={queryResult.loading}
      error={queryResult.error}
      onItem={onItem}
    />
  );
};

export default memo(Connected);

onLoadMoreが実行されると、variablesのafterが更新されて、次のカーソル位置のデータが取得され、上記のCustom Hooksでマージされた値を返す方式なっている

  const onLoadMore = useCallback((after: string | null) => {
    setState((s) => ({
      ...s,
      after,
    }));
  }, []);

インフィニティスクロールの実装なのでonLoadMore実行はFlatListのonEndReachedの設定。 pageInfo.hasNextPage == true(次のページが存在する)場合にonLoadMoreを発火させてページングを実装

src/components/organisms/Memoir/DateCards.tsx

  const handleLoadMore = useCallback(() => {
    if (!props.pageInfo.hasNextPage) return;
    if (props.loading) return;

    props.onLoadMore(props?.pageInfo.endCursor);
  }, [props]);

  return (
    <View style={styles.root}>
      <FlatList<RenderedItem>
        keyExtractor={(_, index) => `search_${index}`}
        data={data}
        renderItem={renderItemCall}
        ListFooterComponent={<ListFooterComponent loading={props.loading} />}
        onEndReachedThreshold={0.8}
        onEndReached={handleLoadMore}
      />
    </View>
  );

動作

実装の動作は以下のようになりました

www.youtube.com