EY-Office ブログ

Reduxのdispatchはawait出来るぞ

下の画像は、Redux公式ページのRedux Application Data FlowにあるReduxを使ったReactアプリのstate更新を現したアニメーションGIFです。

  1. Reactコンポーネントのボタンが押され、イベントハンドラーが起動
  2. イベントハンドラー内でReduxのActionがDispatchされる
  3. Redux内のReducerがstateを更新される
  4. 対応するReactコンポーネントが再描画され画面に表示されているstateが更新される

そして、重要な事はDispatchやReactコンポーネントが再描画は非同期に行われます。

Redux Application Data Flow Redux Application Data Flowより

今回の説明コード

今回のコードは、ボタンを押すとバックエンドから取得した日時を表示します。ただしバックエンドのAPIは

  • 日時データ取得 (getTimestamp): 日時データ(timestamp)を取得。 GET /timestamp
  • 最新日時に更新 (updateTimestamp) : 日時データを最新日時に更新、ただしこのAPIはレスポンスを返すまでに時間がかかります。 PUT /now
バックエンドのコード
import express, { Request, Response, Express } from "express";

const app: Express = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.listen(8080, () => {
  console.log("Start on port 8080.");
});

let now = new Date();

app.get("/timestamp", (_req: Request, res: Response) => {
  res.json({ timestamp: now.toISOString() });
});

app.put("/now", (_req: Request, res: Response) => {
  setTimeout(() => {
    now = new Date();
    res.json({ status: "ok" });
  }, 3000);
});

うっかりコード

フロントエンドのコード(React)

ボタンが押されると、最新日時に更新APIと日時データ取得APIをdispatchしていますが、dispatchは非同期処理なので、2つのAPIはほぼ同時に実行されます。ただし最新日時に更新APIは遅いので、日時データ取得APIが先に終了してしまい過去の日時が表示されてしまいます。

import { useDispatch, useSelector } from "react-redux";
import { RootState, AppDispatch, getTimestamp, updateTimestamp } from "./store";

function App() {
  const dispatch = useDispatch<AppDispatch>();
  const now = useSelector((state: RootState) => state.now);
  const loading = useSelector((state: RootState) => state.loading);

  return (
    <>
      <p>日時: {now}</p>
      <button
        onClick={() => {
          dispatch(updateTimestamp());
          dispatch(getTimestamp());
        }}
      >
        更新
      </button>
      {loading && <p>loading...</p>}
    </>
  );
}

export default App;
フロントエンドのコード(Redux Toolkit)
import { configureStore, createAsyncThunk, createSlice} from "@reduxjs/toolkit";

type TimestampState = {
  now: string;
  loading: boolean;
};

const initialState: TimestampState = {
  now: "",
  loading: false
};

const slice = createSlice({
  name: "timestamp",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(getTimestamp.pending, (state, _action) => {
      state.now = "";
      state.loading = true;
    });
    builder.addCase(getTimestamp.fulfilled, (state, action) => {
      state.now = action.payload;
      state.loading = false;
    });
    builder.addCase(getTimestamp.rejected, (state, action) => {
      console.log("Error :", action.payload);
      state.loading = false;
    });

    builder.addCase(updateTimestamp.pending, (state, _action) => {
      state.loading = true;
    });
    builder.addCase(updateTimestamp.fulfilled, (state, _action) => {
      state.loading = false;
    });
    builder.addCase(updateTimestamp.rejected, (state, action) => {
      console.log("Error :", action.payload);
      state.loading = false;
    });
  }
});

export const getTimestamp = createAsyncThunk<string, void>(
  "timestamp/get",
  async (_args, { rejectWithValue }) => {
    try {
      const res = await fetch("/timestamp");
      const ts = await res.json();
      console.log("=== get ", ts);
      return ts.timestamp;
    } catch (error: any) {
      return rejectWithValue(error);
    }
  }
);

export const updateTimestamp = createAsyncThunk<void, void>(
  "timestamp/update",
  async (_args, { rejectWithValue }) => {
    try {
      await fetch("/now", { method: "PUT" });
      console.log("=== put ");
      return;
    } catch (error: any) {
      return rejectWithValue(error);
    }
  }
);

const store = configureStore({
  reducer: slice.reducer,
});

export default store;
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;

更新を待つコード

リファクタリングのお仕事をしてみた(2)の後半に書いたように、更新済み状態をstateに持つてば正しく動作します。しかし面倒ですね。

フロントエンドのコード(React)
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState, AppDispatch, getTimestamp, updateTimestamp } from "./store";

function App() {
  const dispatch = useDispatch<AppDispatch>();
  const now = useSelector((state: RootState) => state.now);
  const loading = useSelector((state: RootState) => state.loading);
  const updated = useSelector((state: RootState) => state.updated);

  useEffect(() => {
    if (updated) {
      dispatch(getTimestamp());
    }
  }, [updated])

  return (
    <>
      <p>日時: {now}</p>
      <button
        onClick={() => {
          dispatch(updateTimestamp());
        }}
      >
        更新
      </button>
      {loading && <p>loading...</p>}
    </>
  );
}

export default App;
フロントエンドのコード(Redux Toolkit)

stateに更新済み状態updatedを追加しました、コードは変更部分のみです。

・・・

type TimestampState = {
  now: string;
  loading: boolean;
  updated: boolean;
};
const initialState: TimestampState = {
  now: "",
  loading: false,
  updated: false
};

const slice = createSlice({
  name: "timestamp",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(getTimestamp.pending, (state, _action) => {
      state.now = "";
      state.loading = true;
    });
    builder.addCase(getTimestamp.fulfilled, (state, action) => {
      state.now = action.payload;
      state.loading = false;
      state.updated = false;
    });
    builder.addCase(getTimestamp.rejected, (state, action) => {
      console.log("Error :", action.payload);
      state.loading = false;
    });

    builder.addCase(updateTimestamp.pending, (state, _action) => {
      state.loading = true;
      state.updated = false;
    });
    builder.addCase(updateTimestamp.fulfilled, (state, _action) => {
      state.loading = false;
      state.updated = true;
    });
    builder.addCase(updateTimestamp.rejected, (state, action) => {
      console.log("Error :", action.payload);
      state.loading = false;
      state.updated = false;
    });
  }
});

・・・

dispatchはawait出来るぞ!

よくよく調べると非同期通信を行うようなThunx系のActionはawaitで待つ事ができます!

ボタンのイベントハンドラー内で最新日時に更新APIをawaitで実行を待ってから、日時データ取得APIが実行されます。

import { useDispatch, useSelector } from "react-redux";
import { RootState, AppDispatch, getTimestamp, updateTimestamp } from "./store";

function App() {
  const dispatch = useDispatch<AppDispatch>();
  const now = useSelector((state: RootState) => state.now);
  const loading = useSelector((state: RootState) => state.loading);

  return (
    <>
      <p>日時: {now}</p>
      <button
        onClick={async () => {
          await dispatch(updateTimestamp());
          dispatch(getTimestamp());
        }}
      >
        更新
      </button>
      {loading && <p>loading...</p>}
    </>
  );
}

export default App;

Redux Toolkitはうっかりコードと同じです。簡単ですね😊

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ