🙃

redux-toolkitで非同期処理を扱う

2022/01/11に公開約8,700字

本記事は Redux-toolkitを使ったStoreをtypescriptで作ってみる の続きです。
掲載しているコードの中には無駄にコメントアウトされている部分が残っていますが、これは上記記事との差異を明確にするために残しているものです。

参考

以下の記事を参考にさせていただきました。

PAGE TITLE MEMO(参考にした部分) LINK
createAsyncThunk 公式の非同期実装パターン解説 https://redux-toolkit.js.org/api/createAsyncThunk
【React/Redux】「Redux Tool Kit」で非同期のSliceを作る場合 非公式の非同期Slice実装パターン解説
どこに何を書くなど非常に参考になる
http://www.code-magagine.com/?p=13509
Redux Hooks によるラクラク dispatch & states useDispatchとuseCallbackの組み合わせかたについて https://qiita.com/Ouvill/items/569384e5c8c7ce78f98e
React hooksを基礎から理解する (useCallback編+ React.memo) useCallback, useMemoそれぞれの機能について https://qiita.com/seira/items/8a170cc950241a8fdb23

非同期のSlice実装

同期型のSliceではreducerの中に関数を実装していきましたが、非同期処理はcreateSliceの外にcreateAsyncThunkで処理を実装し、extraReducersで実装した関数を組み込むような実装が推奨されています。
コードを追いながら見ていきたいと思います。

パッケージ群のインストール

今回は外部APIから情報を取得しようと思うのでaxiosをインストールします。

また、扱う情報が今回多くなるので簡単にコンポーネントを実装できるようMUIを追加します。

$ yarn add -E axios
$ yarn add @mui/material @emotion/react @emotion/styled @mui/lab -E

sliceを実装する

今回はzennのトレンドを取得するAPIを見つけたので、こちらを使用させていただきます。

  • Netlify Functions を使って Zenn のトレンド API を非公式で作ってみた

  • src/zennApiSlice.ts

    import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
    import axios, { AxiosResponse } from 'axios';
    import { ZennTrendItem } from './type/zennTrend';
    const base = 'https://zenn-api.netlify.app/.netlify/functions';
    
    type State = {
      trends: Array<ZennTrendItem>;
      loading: boolean;
      error: {
        status: boolean;
        message: string | null;
      };
    };
    
    const initialState: State = {
      trends: [],
      loading: false,
      error: {
        status: false,
        message: null,
      },
    };
    
    export const fetchZennTrend = createAsyncThunk('zennApi/fetchZennTrend', async () => {
      const result: AxiosResponse<Array<ZennTrendItem>> = await axios.get(`${base}/trendTech`);
      return result.data;
    });
    
    export const zennApiSlice = createSlice({
      name: 'zennApi',
      initialState,
      reducers: {},
      extraReducers: (builder) => {
        builder.addCase(fetchZennTrend.pending, (state, action) => {
          state.loading = true;
          state.error.status = false;
          state.error.message = null;
        });
        builder.addCase(fetchZennTrend.fulfilled, (state, action) => {
          state.loading = false;
          state.trends = new Array(...action.payload);
        });
        builder.addCase(fetchZennTrend.rejected, (state, action) => {
          state.loading = true;
          state.error.status = true;
          state.error.message = 'cant fetched data from zenn api(unofficial)';
        });
      },
    });
    
    export default zennApiSlice.reducer;
    
  • src/type/zennTrend.ts
    ※ 本部分についてはAPIの返り値から勝手に推測して型を作っています。

    export type ZennTrendItem = {
      id: number;
      postType: string;
      title: string;
      slug: string;
      published: boolean;
      commentsCount: number;
      likedCount: number;
      bodyLettersCount: number;
      readingTime: number;
      articleType: string;
      emoji: string;
      isSuspendingPrivate: boolean;
      publishedAt: string;
      bodyUpdatedAt: string;
      sourceRepoUpdatedAt: null;
      createdAt: string;
      updatedAt: string;
      user: {
        id: number;
        username: string;
        name: string;
        avatarSmallUrl: string;
      };
      publication: null;
      topics: [
        {
          id: number;
          name: string;
          displayName: string;
          taggingsCount: number;
          imageUrl: string;
        }
      ];
    };
    
    export type ZennTrends = Array<ZennTrendItem>;
    

具体的には、 fetchZennTrend が処理関数になっており、ここでAPIを実行しています。

続いて、 zennApiSlice 内の extraReducer で非同期処理のステータスに対するハンドリングを行っています。

method 意味
pending 非同期実行中、または実行待ち
fulfilled 実行が正常完了(エラーなし)
rejected 実行で異常発生

これらのステータスに対する挙動を builder.addCase を使って設定している感じ。

注意

ソース中のエラーに対する処理などはあまり参考になりません。

エラーハンドリングなどは別途記事を書きたいと思っていますが、あくまでこんなことができる程度の認識にとどめてください。

storeにslice, selectorを追加する

  • src/store.ts

    import { configureStore } from '@reduxjs/toolkit';
    import { useSelector as rawUseSelector, TypedUseSelectorHook } from 'react-redux';
    import counterReducer from './counterSlice';
    import zennApiReducer from './zennApiSlice';
    
    export const store = configureStore({
      reducer: {
        counter: counterReducer,
        zennApi: zennApiReducer,
      },
    });
    
    export type RootState = ReturnType<typeof store.getState>;
    export type AppDispatch = typeof store.dispatch;
    export const useSelector: TypedUseSelectorHook<RootState> = rawUseSelector;
    export const zennTrendSelector = createSelector(zennApiSelector, (zenn) => {
      return zenn.trends;
    });
    

componentから呼び出してみる

実装

  • src/App.tsx

    import './App.css';
    import { FC, useCallback, useEffect, useState } from 'react';
    import { AppDispatch, useSelector, zennTrendSelector } from './store';
    import { useDispatch } from 'react-redux';
    import { additional, subtraction } from './counterSlice';
    import { ZennTrendItem } from './type/zennTrend';
    import { Avatar, Box, Card, CardActions, CardContent, CardHeader, Grid, Typography } from '@mui/material';
    import { fetchZennTrend } from './zennApiSlice';
    
    const TrendCard: FC<{ item: ZennTrendItem }> = ({ item }) => {
      return (
        <Grid item xs={6}>
          <Card>
            <CardHeader
              avatar={<Avatar aria-label="recipe">{item.emoji}</Avatar>}
              title={item.title}
              sx={{ display: 'flex', justifyContent: 'start', textAlign: 'start' }}
            />
            <CardActions>
              <CardContent sx={{ display: 'flex', justifyContent: 'start', textAlign: 'start' }}>
                <Typography variant="body2" color="text.secondary">
                  user: {item.user.name} <br /> category: {`${item.topics.map((t) => t.displayName)}`}
                </Typography>
              </CardContent>
            </CardActions>
          </Card>
        </Grid>
      );
    };
    
    const App: FC = () => {
      const count = useSelector((state) => state.counter.count);
      const dispatch: AppDispatch = useDispatch();
      const trends = useSelector(zennTrendSelector);
      const fetchTrends = useCallback(() => {
        dispatch(fetchZennTrend());
      }, [dispatch]);
    
      useEffect(() => {
        if (trends.length === 0) return fetchTrends();
        return;
      }, [fetchTrends, trends]);
    
      // const [count, setCount] = useState<number>(0);
      // const addition = (num: number) => {
      //   if (Number.isNaN(num)) return;
      //   setCount(count + num);
      // };
      // const subtraction = (num: number) => {
      //   if (Number.isNaN(num)) return;
      //   setCount(count - num);
      // };
    
      return (
        <div className="App">
          <Box sx={{ m: 2 }}>
            <h1>Count: {count}</h1>
            {/* <button onClick={() => addition(1)}>Up</button>
          <button onClick={() => subtraction(1)}>Down</button> */}
            <button onClick={() => dispatch(additional(1))}>Up</button>
            <button onClick={() => dispatch(subtraction(1))}>Down</button>
            {trends.length === 0 ? (
              <></>
            ) : (
              <Grid container justifyContent="center" alignItems="center" sx={{ display: 'flex' }} spacing={2}>
                {trends.map((item) => {
                  return <TrendCard item={item} key={item.id} />;
                })}
              </Grid>
            )}
          </Box>
        </div>
      );
    };
    
    export default App;
    

説明

重要なのは以下の部分です。

  const trends = useSelector(zennTrendSelector);
  const fetchTrends = useCallback(() => {
    dispatch(fetchZennTrend());
  }, [dispatch]);

  useEffect(() => {
    if (trends.length === 0) return fetchTrends();
    return;
  }, [fetchTrends, trends]);
  1. trendsで、storeに格納しているstateを取得しています。
  2. fetchTrendsで sliceの非同期処理(action)を実行しています。この時、useCallbackを使用することで不必要に再描画が走らないよう予防しています。
    (stateが変わる時だけ再描画)
  3. useEffectで画面が描画された段階でtrends(store)にデータが入っていない場合にfetchZennTrendを実行するようにしています。

さいごに

今回実装しているsliceは本来reduxを通す必要は全くないということは理解しています。

非同期に関して紹介するのであれば本来はログイン処理やログイン後のユーザーセッティングなどについてをテーマにすべきですが、ログイン機能についてやその他ライブラリの紹介をせずに非同期で処理するというテーマが浮かばなかったため今回のような内容になっています。

ただ、createAsyncThunkの使い方はおおよそわかる内容になったと思うので、参考にできるところは参考にしていただけるとと思います。

Discussion

ログインするとコメントできます