Redux Toolkit で非同期なDispatchをキャンセルするには?

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までの間で取得するという意味です。
このアクションやstatethunkSlice.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;

コンポネント側で使ったgetRandomcreawteAsyncThunk()を使って作成してます。

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秒ほどかかって半分の確率で乱数を返す」という非同期なコードを書いてます。
lengetRandom(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/fulfilledaction.payloadから乱数(1~100)を受け取ることが出来ます。
失敗した時はthunk/getRandom/rejectedaction.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;

実行前にキャンセル

本題のキャンセルです。
キャンセルには実行前にキャンセルする方法と、実行中にキャンセルする方法があるようです。

まずは実行前キャンセル。
例えば二重に実行することを防いだり、すでに成功しているものは再実行させないなどの制御ができます。
前者はrandStatependingの時は実行前にキャンセルさせることで、
後者はrandStatefulfilledの時は実行前にキャンセルさせることで。

実行前にキャンセルさせるには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;

ただ、このキャンセルはfulfilledrejectedが呼び出されなくなるだけで、非同期処理そのものを停止するものではないようです。

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からシグナルを取得出来るようになります。
キャンセルされるとabortedtrueになります。
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()をご覧ください。

実行結果がこちら。

BlockEditor certificate css DataGrid Docker Gutenberg Hyper-V iframe MUI openssl PHP React ReduxToolkit REST ubuntu WordPress オレオレ認証局 フレームワーク