Redux Toolkit で非同期なDispatchをキャンセルするには?
本家のドキュメントにも書いてあるけど、
ちょっと理解に時間かかったので忘れる前に簡潔なメモ。
createAsyncThunk()を使う
今回は5秒ほど掛かって乱数を作成する非同期な処理を想定します。
名付けて5秒で乱数取得
です。
引数を渡すと、1からその引数までの乱数を作成します(100を渡せば1~100までの乱数作成)。
ただしだいたい半分の確率で失敗する罠も忍ばせます。
ReduxToolkitで非同期処理を行うにはcreateAsyncThunk()
を使います。
実際に作成したコードをCodeSandboxに載せました。
ボタンを押すと大体5秒ほどで乱数を作成して表示します。
thunk/getRandom/pending( 100, 9pDr63jQD5bx2-aNRHLeG, pending)
↑ボタンを押して5秒ほどはこんな感じに表示されます。
thunk/getRandom/fulfilled( 100, 9pDr63jQD5bx2-aNRHLeG, fulfilled)
72
↑成功するとこんな感じに変化します。
thunk/getRandom/rejected( 100, 9pDr63jQD5bx2-aNRHLeG, rejected)
Error(undefined): RandomError!
↑失敗するとこんな感じになると思います。
コードを詳しく見る前に、pending
, fulfilled
, rejected
と記述されていますが、
今回の5秒で乱数取得
アクションをdispatchしてから非同期処理が終わるまでpending
が、成功したらfulfilled
に、失敗したらrejected
で表されます。
これらの値を使って「pending
の時はローディングを表示して~」といったような処理が出来ます。
ではコード。
App.ts
コンポネント、thunkSlice.ts
スライスの作成、そしてstore.ts
ストアを作成してます。
他は省略します。
App.ts
import "./styles.css";
import { useAppDispatch, useAppSelector } from './store';
import { getRandom, thunkSelector } from './thunkSlice';
const App = () =>
{
const state = useAppSelector(thunkSelector);
const appDispatch = useAppDispatch();
return (
<>
<input type="button" value="実行" onClick={e => appDispatch(getRandom(100))} />
<p>{state.randMessage}</p>
{ state.randError && <p>{state.randError}</p> }
{ state.randResult && <p>{state.randResult}</p> }
</>
)
}
export default App;
appDispatch(getRandom(100))
getRandom
アクションは乱数を取得するために使います。引数の100は乱数を1~100までの間で取得するという意味です。
このアクションやstate
はthunkSlice.ts
で定義してます。
thunkSlice.ts
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { RootState } from "./store";
// tsミリ秒無駄に待機します。
const sleep = (ts: number) =>
{
return new Promise<void>(r => setTimeout(r, ts));
}
export const getRandom = createAsyncThunk(
'thunk/getRandom',
async (len: number, api) =>
{
// 実行が分かるようにログ出力
console.log('getRandom() 実行');
// 5秒待つ
await sleep(5000);
// 約半分の確率でエラーを発生
if(Math.random() < 0.5)
{
throw new Error('RandomError!');
}
// 1~lenまでの乱数を取得する
return Math.floor(Math.random() * len) + 1;
}
)
interface IThunkState
{
randMessage: string;
randResult?: any;
randError?: string;
}
const initialState: IThunkState =
{
randMessage: 'none',
}
const thunkSlice = createSlice({
name: 'thunk',
initialState,
reducers: {},
extraReducers: builder =>
{
builder
.addCase(
getRandom.pending,
(state, action) =>
{
const type = action.type;
const { arg, requestId, requestStatus } = action.meta;
state.randMessage = `${type}( ${arg}, ${requestId}, ${requestStatus})`;
state.randError = state.randResult = undefined;
}
)
.addCase(
getRandom.fulfilled,
(state, action) =>
{
const type = action.type;
const { arg, requestId, requestStatus } = action.meta;
state.randMessage = `${type}( ${arg}, ${requestId}, ${requestStatus})`;
state.randResult = action.payload;
}
)
.addCase(
getRandom.rejected,
(state, action) =>
{
const type = action.type;
const { arg, requestId, requestStatus } = action.meta;
state.randMessage = `${type}( ${arg}, ${requestId}, ${requestStatus})`;
const { name, message, code } = action.error;
state.randError = `${name}(${code}): ${message}`;
}
)
}
});
export const thunkSelector = (state: RootState) => state.thunk;
export default thunkSlice;
コンポネント側で使ったgetRandom
はcreawteAsyncThunk()
を使って作成してます。
export const getRandom = createAsyncThunk(...)
一つ目の引数はアクションタイプの元になる名前。
'thunk/getRandom',
元になるというのは、この名前がプレフィックスになって別の複数のアクションタイプた作られます。
getRandom
をdispatchすると、実行直後thunk/getRandom/pending
というアクションがdispatchされます。
さらに成功時にthunk/getRandom/fulfilled
、失敗時にはthunk/getRandom/rejected
がそれぞれdispatchされます。
console.log(getRandom.typePrefix);
このプレフィックスはこのように取得することもできます。
二つ目の引数にPayloadクリエーターを渡します。
async (len: number, api) => { ... }
ここで「5秒ほどかかって半分の確率で乱数を返す」という非同期なコードを書いてます。
len
でgetRandom(100)
で渡された引数を受け取ります。
api
はいったんおいておきます。
const thunkSlice = createSlice({ ... })
getRandom
をdispatchすると状況に応じてthunk/getRandom/pending
, thunk/getRandom/fulfilled
, thunk/getRandom/rejected
がdispatchされます。
これらをextraReducers
で受け取って処理していきます。
reducers
と違いextraReducers
は全アクションを受け取れます。
.addCase(getRandom.pending, ...)
addCase()の一つ目の引数にgetRandom.pending
を指定することでthunk/getRandom/pending
を受け取れます。
このアクションタイプは次のように文字列で取得することもできます。
console.log(getRandom.pending.type)
二つ目の引数で状態に変更を加えることが出来ます。
(state, action) =>
{
const type = action.type;
const { arg, requestId, requestStatus } = action.meta;
state.randMessage = `${type}( ${arg}, ${requestId}, ${requestStatus})`;
state.randError = state.randResult = undefined;
}
action.type: thunk/getRandom/pending
action.meta.arg: 100
action.meta.requestId: 9pDr63jQD5bx2-aNRHLeG
action.requestStatus: pending
このようにアクションタイプ
、getRandom()で渡した引数
、一意な値
、現在の状況(pending, fulfilled, rejected)
を取得出来ます。
そして
state.randMessage = ${type}( ${arg}, ${requestId}, ${requestStatus})
;
このようにIThunkState型の状態
を変更することが出来ます。
また、
成功した時はthunk/getRandom/fulfilled
でaction.payload
から乱数(1~100)を受け取ることが出来ます。
失敗した時はthunk/getRandom/rejected
でaction.error
から例外を取得出来ますが、このエラーは例外のインスタンスそのものではなく変換されているようです。
ちなみにapi.rejectWithValue()
の戻り値を返すとrejected
ではaction.payload
からその引数を取得出来るようです。
export const getRandom = createAsyncThunk(
'thunk/getRandom',
async (len: number, api) =>
{
// 5秒待つ
await sleep(5000);
try
{
// どえらい確率でエラーを発生
if(Math.random() < 0.95)
{
throw new Error('RandomError!');
}
// 1~lenまでの乱数を取得する
return Math.floor(Math.random() * len) + 1;
}
catch(e)
{
return api.rejectWithValue('エラーを制御出来るよ!');
}
}
);
// builder
.addCase(
getRandom.rejected,
(state, action) =>
{
const type = action.type;
const { arg, requestId, requestStatus } = action.meta;
state.randMessage = `${type}( ${arg}, ${requestId}, ${requestStatus})`;
const { name, message, code } = action.error;
state.randError = `${name}(${code}): ${message} .. ${action.payload}`;
}
)
失敗確率を95%に増やして実験。
thunk/getRandom/rejected( 100, FVzvxk_BQTUb3E9r2YCKC, rejected)
undefined(undefined): Rejected .. エラーを制御出来るよ!
ただaction.error.name
など一部結果が変わっているので注意。
store.ts
ストアはこんな感じです。
面倒なのでuseAppDispatch()やuseAppSelector()などもまとめてここに記述してます。
import { configureStore } from "@reduxjs/toolkit";
import { useDispatch, TypedUseSelectorHook, useSelector } from "react-redux";
import thunkSlice from "./thunkSlice";
export const store = configureStore({
reducer: {
thunk: thunkSlice.reducer
}
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
実行前にキャンセル
本題のキャンセルです。
キャンセルには実行前にキャンセルする方法と、実行中にキャンセルする方法があるようです。
まずは実行前キャンセル。
例えば二重に実行することを防いだり、すでに成功しているものは再実行させないなどの制御ができます。
前者はrandState
がpending
の時は実行前にキャンセルさせることで、
後者はrandState
がfulfilled
の時は実行前にキャンセルさせることで。
実行前にキャンセルさせるにはcondition
でfalseを返す関数を渡します。
キャンセルというよりは実行しないという感じです。
と、その前に、
現在の状況を知るためIThunkState型にrandState
(pending,fulfilled,rejected,それ以外のidle)を追加しました。
interface IThunkState
{
randMessage: string;
randResult?: any;
randError?: string;
randState: 'pending' | 'fulfilled' | 'rejected' | 'idle';
}
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { RootState } from "./store";
const sleep = (ts: number) =>
{
return new Promise<void>(r => setTimeout(r, ts));
}
export const getRandom = createAsyncThunk(
'thunk/getRandom',
async (len: number, api) =>
{
// 実行が分かるようにログ出力
console.log('getRandom() 実行');
// 5秒待つ
await sleep(5000);
// 約半分の確率でエラーを発生
if(Math.random() < 0.5)
{
throw new Error('RandomError!');
}
// 1~lenまでの乱数を取得する
return Math.floor(Math.random() * len) + 1;
},
{
condition: (len: number, api) =>
{
const state = api.getState() as RootState;
if(state.thunk.randState === 'pending')
{
console.log('Cancel')
return false;
}
}
}
);
interface IThunkState
{
randMessage: string;
randResult?: any;
randError?: string;
randState: 'pending' | 'fulfilled' | 'rejected' | 'idle';
}
const initialState: IThunkState =
{
randMessage: 'none',
randState: 'idle'
}
const thunkSlice = createSlice({
name: 'thunk',
initialState,
reducers: {},
extraReducers: builder =>
{
builder
.addCase(
getRandom.pending,
(state, action) =>
{
const type = action.type;
const { arg, requestStatus } = action.meta;
state.randMessage = `${type}(${arg})`;
state.randState = requestStatus;
state.randError = state.randResult = undefined;
}
)
.addCase(
getRandom.fulfilled,
(state, action) =>
{
const type = action.type;
const { arg, requestStatus } = action.meta;
state.randMessage = `${type}(${arg})`;
state.randState = requestStatus;
state.randResult = action.payload;
}
)
.addCase(
getRandom.rejected,
(state, action) =>
{
const type = action.type;
const { arg, requestStatus } = action.meta;
state.randMessage = `${type}(${arg})`;
state.randState = requestStatus;
const { name, message } = action.error;
state.randError = `${name}: ${message}`;
}
)
}
});
export const thunkSelector = (state: RootState) => state.thunk;
export default thunkSlice;
各アクションでstate.randState
を変更するように改良します。
state.randState = requestStatus;
export const getRandom = createAsyncThunk(
'thunk/getRandom',
async (len: number, api) =>
{
// 実行が分かるようにログ出力
console.log('getRandom() 実行');
// 5秒待つ
await sleep(5000);
// 約半分の確率でエラーを発生
if(Math.random() < 0.5)
{
throw new Error('RandomError!');
}
// 1~lenまでの乱数を取得する
return Math.floor(Math.random() * len) + 1;
},
{
condition: (len: number, api) =>
{
const state = api.getState() as RootState;
if(state.thunk.randState === 'pending')
{
console.log('Cancel')
return false;
}
}
}
);
createAsyncThunk()
の三つ目の引数(オプション)にcondition
を追加します。
この関数がfalseを返すと今回の例題である非同期処理5秒で乱数取得
が実行されません。
今回は
if(state.thunk.randState === 'pending')
としているので、二重に実行されません。
試しにボタンを連打するとコンソールログは次のようになります。
getRandom() 実行
Cancel
Cancel
Cancel
Cancel
api.getState()
からはルートステートが取得できます。
const state = api.getState() as RootState;
ということは、Stateの構造に依存してしまうということで、なんかちょっと・・・って感じが。
実行結果を載せておきます。
実行中キャンセル
実はdispatchはPromiseを返します。
const promise = appDispatch(getRandom(100));
しかもabort()
メソッドが生えてるらしく、これを実行することでキャンセルされます。
import "./styles.css";
import { useAppDispatch, useAppSelector } from './store';
import { getRandom, thunkSelector } from './thunkSlice';
import { useRef } from "react";
const App = () =>
{
const state = useAppSelector(thunkSelector);
const appDispatch = useAppDispatch();
const promise = useRef<any>();
const getRandomDispatch = () =>
{
promise.current = appDispatch(getRandom(100));
}
return (
<>
<input type="button" value="実行" onClick={e => getRandomDispatch()} />
<input type="button" value="キャンセル" onClick={e => promise.current?.abort()} />
<p>{state.randMessage}</p>
{ state.randError && <p>{state.randError}</p> }
{ state.randResult && <p>{state.randResult}</p> }
</>
)
}
export default App;
ただ、このキャンセルはfulfilled
やrejected
が呼び出されなくなるだけで、非同期処理そのものを停止するものではないようです。
export const getRandom = createAsyncThunk(
'thunk/getRandom',
async (len: number, api) =>
{
// 実行が分かるようにログ出力
console.log('getRandom() 実行');
// 5秒待つ
await sleep(5000);
// このログを追加しておく。
console.log('5秒たちましたよ!');
// 約半分の確率でエラーを発生
if(Math.random() < 0.5)
{
throw new Error('RandomError!');
}
// 1~lenまでの乱数を取得する
return Math.floor(Math.random() * len) + 1;
}
);
createAsyncThunk()を少し改良します。
console.log('5秒たちましたよ!');
5秒で乱数取得
をdispatchして5秒立つとこのログを表示するようにします。
「ボタンを押す、5秒立つ前にキャンセルする」を連続で数回繰り返します。
getRandom() 実行
5秒たちましたよ!
getRandom() 実行
5秒たちましたよ!
getRandom() 実行
5秒たちましたよ!
getRandom() 実行
5秒たちましたよ!
5秒たちましたよ!
が表示されているということは、非同期処理自体が停止されているわけでないことが確認出来ます。
非同期処理自体を停止する方法は2とおりあるようです。
一つはポーリングする方法、もう一つはそれようのAPIを直接叩く方法。
.NET
の知識のある人限定の話だけどThreadのAbort()で強制的にスレッドを停止させる方法と、TPLだとIsCancellationRequestedをポーリングして自発的に停止させる方法があります。
JavaScriptはマルチスレッドには対応しておらず事情が異なりますが、両方が出来るようなクラスAbortController
が標準で追加されているようです。
このAbortController
についてはここでは説明しません。
キャンセルとポーリング
新しいコードを書くのは大変なのでこれまでのcreateActionThunk()
を再利用します。
もはやgetRandom()
としての使い方をしていませんが、
機能が5秒で乱数取得
からお皿を1秒ごとに数え、10枚数え終わったら乱数を返す
に変わりました。
ログに出力されます。
実行ボタンを押して、数秒後キャンセルをクリックしてください。
ブラウザには次のように表示されます。
thunk/getRandom/rejected(100)
AbortError: Aborted
コンソールには次のように出力されます。
1 まーい!
2 まーい!
3 まーい!
4 まーい!
5 まーい!
export const getRandom = createAsyncThunk(
'thunk/getRandom',
async (len: number, api) =>
{
let count = 0;
while(count < 10)
{
count++;
await sleep(1000);
console.log(`${count} まーい!`);
if(api.signal.aborted)
{
throw new Error('Cancelled');
}
}
console.log('おしまーい♪');
}
);
if(api.signal.aborted)
api.signal
からシグナルを取得出来るようになります。
キャンセルされるとaborted
がtrue
になります。
sleep()
毎にこのプロパティを監視しつづけ、true
になった時点で抜け出します。
sleep()
の引数を3000
とかにすると分かりますが、停止までに若干のタイムラグが確認できます。
即終了させているわけではないからです。
fetch()
などこのシグナルに対応しているものはそのまま使えますが、
対応していないようなAPIに対してはapi.signal.addEventlistener()
を使います。
XHRって大分使ってないけど使い方忘れました!
実行してないですが、
export const getRandom = createAsyncThunk(
'thunk/getRandom',
async (len: number, api) =>
{
const xhr = new XMLHttpRequest();
xhr.open(...) ... 略;
api.signal.addEventListener('abort', () => xhr.abort());
// 1~lenまでの乱数を取得する
return Math.floor(Math.random() * len) + 1;
}
);
こんな感じかな!?
useEffect()とuseRef()を使って制御
ここまでのキャンセルでは「実行ボタンを2度以上連続で押した」場合複数の非同期処理が実行され、
キャンセルボタンを押しても最後の非同期処理しかキャンセルされません。
さらにアンマウントした場合もまたキャンセルし忘れになってしまいます。
そこで非同期処理は常に1回しか処理できない、すでに非同期が動いている場合は1回キャンセルしてから、アンマウント時もキャンセルするといった徹底的なキャンセル対応をしてみました。
本来ここで頑張るべきものなのか分かりませんが・・・。
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { RootState } from "./store";
const sleep = (ts: number) =>
{
return new Promise(r => setTimeout(r, ts));
}
export const count = createAsyncThunk(
'thunk/count',
async (label: string, api) =>
{
let count = 0;
while(count < 10)
{
count++;
await sleep(1000);
console.log(`[ ${label} ] ${count} まーい!`);
if(api.signal.aborted)
{
throw new Error('Cancelled');
}
}
console.log(`[ ${label} ] おしまーい!`);
}
);
interface IThunkState
{
randMessage: string;
randResult?: any;
randError?: string;
randState: 'pending' | 'fulfilled' | 'rejected' | 'idle';
}
const initialState: IThunkState =
{
randMessage: 'none',
randState: 'idle'
}
const thunkSlice = createSlice({
name: 'thunk',
initialState,
reducers: {},
extraReducers: builder =>
{
builder
.addCase(
count.pending,
(state, action) =>
{
const type = action.type;
const { arg, requestStatus } = action.meta;
state.randMessage = `${type}(${arg})`;
state.randState = requestStatus;
state.randError = state.randResult = undefined;
}
)
.addCase(
count.fulfilled,
(state, action) =>
{
const type = action.type;
const { arg, requestStatus } = action.meta;
state.randMessage = `${type}(${arg})`;
state.randState = requestStatus;
state.randResult = action.payload;
}
)
.addCase(
count.rejected,
(state, action) =>
{
const type = action.type;
const { arg, requestStatus } = action.meta;
state.randMessage = `${type}(${arg})`;
state.randState = requestStatus;
const { name, message } = action.error;
state.randError = `${name}: ${message}`;
}
)
}
});
export const thunkSelector = (state: RootState) => state.thunk;
export default thunkSlice;
import "./styles.css";
import { useAppDispatch, useAppSelector } from './store';
import { count, thunkSelector } from './thunkSlice';
import { useEffect, useRef, useState } from "react";
const Item = () =>
{
//console.log(`${renderCount++} Rendering...`);
const state = useAppSelector(thunkSelector);
const appDispatch = useAppDispatch();
const cancellation = useRef<any>();
useEffect(() => {
// 必ずPromiseの参照を取得しておく。
const cleanup = cancellation.current;
return () =>
{
// リファレンスから直接参照するのではなく、直前のcleanupの参照をabort()する。
cleanup?.abort();
}
},[cancellation.current]);
const getRandomDispatch = () =>
{
const str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const label = str.charAt(Math.floor(Math.random() * str.length));
cancellation.current = appDispatch(count(label));
}
const cancel = () =>
{
cancellation.current?.abort();
}
return (
<>
<p>{state.randState}</p>
<input type="button" value="実行" onClick={e => getRandomDispatch()} />
<input type="button" value="キャンセル" onClick={e => cancel()} />
<p>{state.randMessage}</p>
{ state.randError && <p>{state.randError}</p> }
{ state.randResult && <p>{state.randResult}</p> }
</>
)
}
const App = () =>
{
const [flag, setFlag] = useState(true);
return (
<>
<div><input type="button" value="ちぇんじ" onClick={e => setFlag(!flag)} /></div>
{ flag ? <Item /> : <div>Delete</div> }
</>
)
}
export default App;
useRef()とuseEffect()が分らない人は先にuseRef及びuseEffect()をご覧ください。
実行結果がこちら。