Open6

URQLで無限スクロール

yosiyosi

https://github.com/danbovey/react-infinite-scroller を使ってみる

package.json
    "react-infinite-scroller": "^1.2.6",

キャッシュの設定はこんな感じ

const App: FC = () => {
  const client = createClient({
    // 省略
    exchanges: [
      dedupExchange,
      cacheExchange({
        resolvers: {
          Query: {
            posts: relayPagination(),
          },
        },
      }),
      fetchExchange,
    ],
  })

投稿一覧を表示してみる

import { Suspense, useState } from "react"
import type { FC } from "react"
import { useQuery, gql } from "urql"
import InfiniteScroll from "react-infinite-scroller"
import Box from "@mui/material/Box"
import Container from "@mui/material/Container"
import Grid from "@mui/material/Grid"

import { PostCard } from "components/PostCard/PostCard"

import { GetPostsQuery } from "generated/types"

import style from "./index.module.scss"

const TopPage: FC = () => {
  const [variables, setVariables] = useState<{
    first: number
    limit: number
    after: string | null | undefined
  }>({ first: 12, limit: 12, after: "" })
  const [{ data, fetching }] = useQuery<GetPostsQuery>({
    query: gql`
      query GetPosts($first: Int!, $after: String) {
        posts(first: $first, after: $after) {
          edges {
            node {
              id
              title
              description
              thumbnailUrl(size: S)
            }
          }
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    `,
    variables,
  })

  const loadMore = () => {
    if (fetching) {
      return
    }
    setVariables({
      ...variables,
      after: data?.posts.pageInfo.endCursor,
    })
  }

  return (
    <Box component="div" className={style.postsBackground} sx={{ pt: 3 }}>
      <Container maxWidth="lg">
        <Suspense fallback={"loading..."}>
          {data && (
            <InfiniteScroll
              pageStart={0}
              loadMore={loadMore}
              hasMore={data.posts.pageInfo.hasNextPage}
              loader={
                <div className="loader" key={0}>
                  Loading ...
                </div>
              }
            >
              <Grid container spacing={2}>
                {data.posts?.edges?.map(
                  (edge) =>
                    edge?.node && (
                      <Grid item key={edge.node.id} xs={6} sm={3}>
                        <PostCard
                          title={edge.node.title}
                          src={edge.node.thumbnailUrl}
                          href={`/posts/${edge.node.id}`}
                        />
                      </Grid>
                    )
                )}
              </Grid>
            </InfiniteScroll>
          )}
        </Suspense>
      </Container>
    </Box>
  )
}

export default TopPage

動いたけれど、キャッシュの設定を追加したくない(exchangesにこの設定を書いていると他の個所でドキュメントキャッシュが動作しなかった)

https://zenn.dev/adwd/articles/f4c5c5120467bb
https://formidable.com/open-source/urql/docs/architecture/

yosiyosi

ごちゃついたコードだけど、似たことができた

同じ要素が何度も表示される問題に困っていたが、exchangesのキャッシュの設定を消していなかったからだった

ただ、ページ表示時に2回GraphqlApiからデータを取得してしまっている(つまり12*2=24件目まで取得している)
なぜ。。。

import { Suspense, useState, useEffect } from "react"
import type { FC, Dispatch, SetStateAction } from "react"
import { useQuery, gql } from "urql"
import InfiniteScroll from "react-infinite-scroller"
import Box from "@mui/material/Box"
import Container from "@mui/material/Container"
import Grid from "@mui/material/Grid"

import { PostCard } from "components/PostCard/PostCard"

import { GetPostsQuery } from "generated/types"

import style from "./index.module.scss"

interface ResultPostCardsProps {
  variables: {
    first: number
    limit: number
    after: string | null | undefined
  }
  setPageInfo: Dispatch<
    SetStateAction<{
      endCursor: string
      hasNextPage: boolean
    }>
  >
}

const ResultPostCards: FC<ResultPostCardsProps> = (props) => {
  const variables = props.variables
  const [{ data, fetching }] = useQuery<GetPostsQuery>({
    query: gql`
      query GetPosts($first: Int!, $after: String) {
        posts(first: $first, after: $after) {
          edges {
            node {
              id
              title
              description
              thumbnailUrl(size: S)
            }
          }
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    `,
    variables,
  })

  useEffect(() => {
    if (data?.posts.pageInfo.endCursor && data?.posts.pageInfo) {
      console.log("setPageInfo")
      props.setPageInfo({
        endCursor: data.posts.pageInfo.endCursor,
        hasNextPage: data.posts.pageInfo.hasNextPage,
      })
    }
  }, [data?.posts.pageInfo.endCursor, data?.posts.pageInfo])

  return (
    <>
      {data?.posts?.edges?.map(
        (edge) =>
          edge?.node && (
            <Grid item key={edge.node.id} xs={6} sm={3}>
              <PostCard
                title={edge.node.title}
                src={edge.node.thumbnailUrl}
                href={`/posts/${edge.node.id}`}
              />
            </Grid>
          )
      )}
    </>
  )
}

const TopPage: FC = () => {
  const [pageVariablesList, setPageVariablesList] = useState<
    {
      first: number
      limit: number
      after: string | null | undefined
    }[]
  >([{ first: 12, limit: 12, after: "" }])
  const [pageInfo, setPageInfo] = useState<{
    endCursor: string
    hasNextPage: boolean
  }>({ endCursor: "", hasNextPage: true })

  const loadMore = () => {
    console.log("loadMore")
    console.log(pageVariablesList)
    if (
      pageVariablesList[pageVariablesList.length - 1].after !=
      pageInfo.endCursor
    ) {
      setPageVariablesList([
        ...pageVariablesList,
        { first: 12, limit: 12, after: pageInfo.endCursor },
      ])
    }
  }

  return (
    <Box component="div" className={style.postsBackground} sx={{ pt: 3 }}>
      <Container maxWidth="lg">
        <Suspense fallback={"loading..."}>
          <InfiniteScroll
            pageStart={0}
            loadMore={loadMore}
            hasMore={pageInfo.hasNextPage}
            loader={
              <div className="loader" key={0}>
                Loading ...
              </div>
            }
          >
            <Grid container spacing={2}>
              {pageVariablesList.map((pageVariables, index) => (
                <ResultPostCards
                  key={index}
                  variables={pageVariables}
                  setPageInfo={setPageInfo}
                />
              ))}
            </Grid>
          </InfiniteScroll>
        </Suspense>
      </Container>
    </Box>
  )
}

export default TopPage

yosiyosi

IntersectionObserverを使ってInfiniteScrollを自作してみた
loadMoreが実行されたときにloadMore内で使われているState変数の値が初期値になっている。
おそらくコールバック関数がnew IntersectionObserverで登録されたときに固定されてしまってそう。

import { Suspense, useState, useEffect } from "react"
import type { FC, Dispatch, SetStateAction, ReactNode } from "react"
import { useQuery, gql } from "urql"
// import InfiniteScroll from "react-infinite-scroller"
import Box from "@mui/material/Box"
import Container from "@mui/material/Container"
import Grid from "@mui/material/Grid"

import { PostCard } from "components/PostCard/PostCard"

import { GetPostsQuery } from "generated/types"

import style from "./index.module.scss"

interface ResultPostCardsProps {
  variables: {
    first: number
    limit: number
    after: string | null | undefined
  }
  setPageInfo: Dispatch<
    SetStateAction<{
      endCursor: string
      hasNextPage: boolean
    }>
  >
}

const ResultPostCards: FC<ResultPostCardsProps> = (props) => {
  const variables = props.variables
  const [{ data, fetching }] = useQuery<GetPostsQuery>({
    query: gql`
      query GetPosts($first: Int!, $after: String) {
        posts(first: $first, after: $after) {
          edges {
            node {
              id
              title
              description
              thumbnailUrl(size: S)
            }
          }
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    `,
    variables,
  })

  useEffect(() => {
    if (
      data?.posts.pageInfo.endCursor &&
      data?.posts.pageInfo.endCursor !== "" &&
      data?.posts.pageInfo
    ) {
      console.log("setPageInfo")
      console.log({
        endCursor: data.posts.pageInfo.endCursor,
        hasNextPage: data.posts.pageInfo.hasNextPage,
      })
      props.setPageInfo({
        endCursor: data.posts.pageInfo.endCursor,
        hasNextPage: data.posts.pageInfo.hasNextPage,
      })
    }
  }, [data?.posts.pageInfo.endCursor, data?.posts.pageInfo])

  return (
    <>
      {data?.posts?.edges?.map(
        (edge) =>
          edge?.node && (
            <Grid item key={edge.node.id} xs={6} sm={3}>
              <PostCard
                title={edge.node.title}
                src={edge.node.thumbnailUrl}
                href={`/posts/${edge.node.id}`}
              />
            </Grid>
          )
      )}
    </>
  )
}

interface InfiniteScrollProps {
  children: ReactNode
  loadMore: () => void
  hasMore: boolean
  loader: ReactNode
}

const InfiniteScroll: FC<InfiniteScrollProps> = (props) => {
  useEffect(() => {
    const el = document.getElementById("InfiniteScrollLoader")

    const observer = new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (e.isIntersecting) {
          console.log("loadMore")
          props.loadMore()
        }
      }
    })

    if (el) {
      observer.observe(el)
    }
  }, [])
  return (
    <>
      {props.children}
      <div id="InfiniteScrollLoader">{props.loader}</div>
    </>
  )
}

const TopPage: FC = () => {
  const [pageVariablesList, setPageVariablesList] = useState<
    {
      first: number
      limit: number
      after: string | null | undefined
    }[]
  >([{ first: 12, limit: 12, after: "" }])
  const [pageInfo, setPageInfo] = useState<{
    endCursor: string | undefined
    hasNextPage: boolean
  }>({ endCursor: undefined, hasNextPage: true })

  const loadMore = () => {
    console.log("loadMore")
    console.log(pageVariablesList)
    console.log(pageInfo)
    if (
      pageVariablesList[pageVariablesList.length - 1].after !=
      pageInfo.endCursor
    ) {
      setPageVariablesList([
        ...pageVariablesList,
        { first: 12, limit: 12, after: pageInfo.endCursor },
      ])
    }
  }

  console.log("aaa")
  console.log(pageInfo)

  return (
    <Box component="div" className={style.postsBackground} sx={{ pt: 3 }}>
      <Container maxWidth="lg">
        <Suspense fallback={"loading..."}>
          <InfiniteScroll
            loadMore={loadMore}
            hasMore={pageInfo.hasNextPage}
            loader={
              <div className="loader" key={0}>
                Loading ...
              </div>
            }
          >
            <Grid container spacing={2}>
              {pageVariablesList.map((pageVariables, index) => (
                <ResultPostCards
                  key={index}
                  variables={pageVariables}
                  setPageInfo={setPageInfo}
                />
              ))}
            </Grid>
          </InfiniteScroll>
        </Suspense>
      </Container>
    </Box>
  )
}

export default TopPage

yosiyosi

react-intersection-observer使ってみてうまく動いてそう

https://github.com/thebuilder/react-intersection-observer

import { Suspense, useState, useEffect } from "react"
import type { FC, Dispatch, SetStateAction, ReactNode } from "react"
import { useQuery, gql } from "urql"
// import InfiniteScroll from "react-infinite-scroller"
import Box from "@mui/material/Box"
import Container from "@mui/material/Container"
import Grid from "@mui/material/Grid"
import { InView } from "react-intersection-observer"

import { PostCard } from "components/PostCard/PostCard"

import { GetPostsQuery } from "generated/types"

import style from "./index.module.scss"

interface ResultPostCardsProps {
  variables: {
    first: number
    limit: number
    after: string | null | undefined
  }
  setPageInfo: Dispatch<
    SetStateAction<{
      endCursor: string
      hasNextPage: boolean
    }>
  >
}

const ResultPostCards: FC<ResultPostCardsProps> = (props) => {
  const variables = props.variables
  const [{ data, fetching }] = useQuery<GetPostsQuery>({
    query: gql`
      query GetPosts($first: Int!, $after: String) {
        posts(first: $first, after: $after) {
          edges {
            node {
              id
              title
              description
              thumbnailUrl(size: S)
            }
          }
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    `,
    variables,
  })

  useEffect(() => {
    if (data?.posts.pageInfo.endCursor && data?.posts.pageInfo) {
      console.log("setPageInfo")
      console.log({
        endCursor: data.posts.pageInfo.endCursor,
        hasNextPage: data.posts.pageInfo.hasNextPage,
      })
      props.setPageInfo({
        endCursor: data.posts.pageInfo.endCursor,
        hasNextPage: data.posts.pageInfo.hasNextPage,
      })
    }
  }, [data?.posts.pageInfo.endCursor, data?.posts.pageInfo])

  return (
    <>
      {data?.posts?.edges?.map(
        (edge) =>
          edge?.node && (
            <Grid item key={edge.node.id} xs={6} sm={3}>
              <PostCard
                title={edge.node.title}
                src={edge.node.thumbnailUrl}
                href={`/posts/${edge.node.id}`}
              />
            </Grid>
          )
      )}
    </>
  )
}

interface InfiniteScrollProps {
  children: ReactNode
  loadMore: () => void
  hasMore: boolean
  loader: ReactNode
}

const InfiniteScroll: FC<InfiniteScrollProps> = (props) => {
  return (
    <>
      {props.children}
      <InView
        as="div"
        onChange={(inView, entry) => {
          if (inView && props.hasMore) {
            props.loadMore()
          }
        }}
      >
        {props.loader}
      </InView>
    </>
  )
}

const TopPage: FC = () => {
  const [pageVariablesList, setPageVariablesList] = useState<
    {
      first: number
      limit: number
      after: string | null | undefined
    }[]
  >([{ first: 12, limit: 12, after: "" }])
  const [pageInfo, setPageInfo] = useState<{
    endCursor: string
    hasNextPage: boolean
  }>({ endCursor: "", hasNextPage: true })

  const loadMore = () => {
    console.log("loadMore")
    console.log(pageVariablesList)
    console.log(pageInfo)
    if (
      pageVariablesList[pageVariablesList.length - 1].after !=
      pageInfo.endCursor
    ) {
      setPageVariablesList([
        ...pageVariablesList,
        { first: 12, limit: 12, after: pageInfo.endCursor },
      ])
    }
  }

  console.log("aaa")
  console.log(pageInfo)

  return (
    <Box component="div" className={style.postsBackground} sx={{ pt: 3 }}>
      <Container maxWidth="lg">
        <Suspense fallback={"loading..."}>
          <InfiniteScroll
            loadMore={loadMore}
            hasMore={pageInfo.hasNextPage}
            loader={
              <div className="loader" key={0}>
                Loading ...
              </div>
            }
          >
            <Grid container spacing={2}>
              {pageVariablesList.map((pageVariables, index) => (
                <ResultPostCards
                  key={index}
                  variables={pageVariables}
                  setPageInfo={setPageInfo}
                />
              ))}
            </Grid>
          </InfiniteScroll>
        </Suspense>
      </Container>
    </Box>
  )
}

export default TopPage

yosiyosi

もうちょっと細かいとこ詰めたら実際に使えそう