この記事について
現在のプロジェクトでは、RTKQueryとRedux Toolkitを使用しています。
RTKQueryでは、ReactQueryとは異なり現状無限スクロール用のライブラリ?を用意してないようです。
(ReactQueryは、react-query react-infinite-scroller というものがあるらしい)
ということで、RtkQueryを使用して無限スクロールと似たような「もっと見るボタン」を実装したのでまとめます。
いわゆるスクロールして自動で表示が増えるとは別ですが、スクロールとボタン以外は同じなので「似たような」と表現しています。
※RTKQueryとmicroCMS(が出てきます)の基本的な内容や細かい部分はこちらではお話ししていません。いつかやる..
イメージ
イメージです。
こちらの画面では検索結果の記事一覧が表示されています。
もっと見るボタンを押すことでより多くの記事が見れます。
デフォルトで10件、もっと見るボタンを押すと+10件 = 合計20件表示という感じ。
実装方法
設計について
設計は色々な方法があると思いますが、今回はブラウザバックも考慮する必要があったのでreduxStateを使いました。(ページ間でstateを保持したい)
クエリとuseStateだけで実装した場合、例えば記事詳細を押した後ブラウザバックボタンを押すと初期状態に戻ってしまいます。
挙動)30件目の記事詳細→ブラウザバックボタン→画面上に戻り最初の10件だけの表示に戻る
UI/UX的によろしくないのでreduxStateは必須でした。
その中で下記方法があります。
①APIを叩いて記事全てのデータをもらう → その後記事全てをreduxStateに保管しておく → もっと見るボタンを押すたびに表示だけ変える
②もっと見るボタンを押す度に、10件ずつAPIから取ってきてreduxStateに保管する。
実装的には、①の方が圧倒的に簡単です。
しかし、今回のAPI連携先はmicroCMSになるので(記事を作る管理画面)、②を選びました。
一度に取ってくるデータ量の規定を超えた場合エラーになってしまう為です。
上限値はありませんが、レスポンスサイズ(レスポンスヘッダのcontent-lengthの値)が約5MBを超えるとエラーが発生します。
miscoCms用のrtkQueryで新しいendpoint作成
export const articlesApi = cmsApi.injectEndpoints({
endpoints: (builder) => ({
// 省略
getSearchedArticles: builder.query<
ResponseSummaryArticlesType,
string | void
>({
query: (query) => ({
url: `cancer-information?orders=-createdAt&fields=id,publishedAt,category,title,eye_catch,contributor,cancer_category,change_log,is_banner_display,summary${
query ? query : ''
}`
})
}),
}),
overrideExisting: false
})
- クエリはpage(またはコンポーネント)から送ります
- microCmsは、
fields=
を使うことで取ってくる情報を絞れます(また今度miscroCMSについて記事書くかも)
sliceを作成(reduxActionとreducer)
export type DisplayArticlesType = {
contents: SummaryArticleType[]
totalCount: number
isLoading: boolean
}
const displayArticlesSlice = createSlice({
name: 'displayArticles',
initialState: <DisplayArticlesType>{
contents: [],
totalCount: -1,
isLoading: false
},
reducers: {
addDisplayArticles: (
state,
action: PayloadAction<Omit<DisplayArticlesType, 'isLoading'>>
) => {
const newObj = {
...state,
contents: [...state.contents, ...action.payload.contents],
totalCount: action.payload.totalCount
}
return newObj
},
deleteDisplayArticles: (state) => {
state.contents = []
},
setDisplayArticlesLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload
}
}
})
export const {
addDisplayArticles,
deleteDisplayArticles,
setDisplayArticlesLoading
} = displayArticlesSlice.actions
const stateSelector = (state: RootState) => state.displayArticles
export const displayArticlesSelectors = {
displayArticles: createSelector(stateSelector, (state) => state)
}
export const displayArticlesReducer = displayArticlesSlice.reducer
- 好きなタイミングでstateをアップデートしたいので、extraReducerは使っていません
- extraReducerで最初試しましたが下記現象↓で沼にハマったので、大人しくreducerを使っています
- extraReducerを使うとendpointが叩かれる度に、stateがpushされてしまう(そのように書いてるので)
- 異なるデータの値をstateとしてpushされたくないので、前の値を消すようにする
- キャッシュから取りに行く性質上、消してしまうと明示的にrefetchしなければいけない
- refetchするとブラウザバック時もrefetchをするので、思うように実装ができない(データが重複)
- 作ったアクションとstate
- 記事を配列に追加するアクション 、
state:[data1, data2, ...]
- 記事を全て消すアクション、
state:[]
- ローディング用のアクション、
state:isLoading: true
- 記事を配列に追加するアクション 、
もっと見るボタン用のカスタムhookを作成
ファイル名paginationはちょっと違和感があるかもですが、構造上そうしてます(いつか直すかも)
export const usePaginationArticles = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const articles = useAppSelector(displayArticlesSelectors.displayArticles)
const createCmsQuery = () => {
// 長いので省略。クエリ作る作業してます
return cmsQuery
}
const getFirstArticles = async ({ firstFetch }: { firstFetch: boolean }) => {
// 最初の画面表示の時だけローディングを表示。もっと見るボタン押下は不要
if (firstFetch) {
dispatch(setDisplayArticlesLoading(true))
}
// RtkQueryを叩く
const { data } = await dispatch(
articlesApi.endpoints.getSearchedArticles.initiate(
`${createCmsQuery()}&offset=${articles.contents.length}`
)
)
// 返ってきた値を元にreduxStateを更新
if (data) {
dispatch(
addDisplayArticles({
contents: data.contents,
totalCount: data.totalCount
})
)
}
// ローディング状態を戻す
if (firstFetch) {
dispatch(setDisplayArticlesLoading(false))
}
}
// 初回データ取得用
useEffect(() => {
// 配列の中身が無い場合のみ、データを取ってくる
if (!articles.contents.length) {
getFirstArticles({ firstFetch: true })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router])
// もっと見るボタン取得用
const handleLeadMore = () => {
getFirstArticles({ firstFetch: false })
}
return {
handleLeadMore
}
}
- getFirstArticles関数で、RtkQueryを叩いてデータをdispatchし、reduxStateを更新しています
- loadingは初期表示以外は表示してほしくないので、firstFetchで分岐しています
- useEffectで初期表示時に、reduxStateが空の場合だけデータを取ってくるようにしています
※ そうしないと他の検索結果のデータが混ざって配列に追加されてしまいます - もっと見るボタンを押した時は必ず〇〇件目以降のデータを取ってきます
- RTKQueryを叩いてる部分にある、
offset=
は、microCMSのクエリです
何件目から取得するかを指定します。
デフォルト値は0です。
https://document.microcms.io/content-api/get-list-contents#h41838110ca
- 配列20個ある場合、21個目からデータを取ってきてほしいので、
&offset=${articles.contents.length}
と指定しています。
※microCMSがデフォルト0なので-1の指定で大丈夫です。
記事のコンポーネント
export const SummaryArticles = ({
articles,
searchText
}: SummaryArticlesProps & BoxProps) => {
const { handleLeadMore } = usePaginationArticles()
return (
// 省略
{articles.totalCount > 0 ? (
<>
{articles.contents.map((article) => (
<CardArticlesSummaryItem
article={article}
key={article.id}
/>
))}
</>
) : (
<Text
color={'text.200'}
fontSize={{ base: '.875rem', md: '1rem' }}
py={'1.25rem'}
textAlign={'center'}
>
検索条件に一致する結果が見つかりません
</Text>
)}
{articles.totalCount > articles.contents.length && (
<Box mt={{ base: '1rem', md: '2.5rem' }} textAlign={'center'}>
<Button
sizes={{ base: 'md', md: 'lg' }}
variant={'outline'}
borderRadius={'2rem'}
w={{ base: '100%', md: 'sm' }}
h={{ base: '3rem', md: '4rem' }}
onClick={handleLeadMore}
>
もっと見る
</Button>
</Box>
)}
)
}
- articles.contentsに記事データが入ってるので、mapで回す。
- articles.totalCountは合計値が入っていて、articles.contents.lengthには現在の配列数が入っているので、比べて合計値の方が多ければ「もっと見る」ボタンを表示
- もっと見るボタンのonClickは、先程のカスタムhookの関数を指定
以上です!
まとめ
当初、extraReducerでいい感じに途中まで実装していたのですが、最終的にreducerを使った方が良いということになり、設計から少しやり直しました。
兎にも角にも、RTKQueryで無限スクロールのライブラリ用意してくれると嬉しいですね。
ボタンなしの無限スクロールはこちらを少しいじれば作れそうです。