GutenbergでRedux風にストアを登録する。

GutenbergでRedux風にストアを登録する。

今回はcreateReduxStore()をより深く探っていきます。
以下のリンク先が理解できる知識があることが前提となります。

WordPressのブロック開発メモ その9 データの操作(セレクタとアクション&ディスパッチ)
WordPressのブロック開発メモ その10 Redux ストアの作成

Gutenbergでデータ操作するにはレジストリにストアデスクリプタを登録します。
そのストアデスクリプタをRedux風に追加することが出来ます。
Gutenbergのコアは基本的にこの方法で登録されています。

それがcreateReduxStore()です。

まずは最低限「レデューサーセレクターアクション」を定義し、それをcreateReduxStore()に渡すようになります。

ではコードを書いていきます。

インポート

まずは今回使用する全インポートです。

import React from "react"

import { useState } from '@wordpress/element';
import { combineReducers, createReduxStore, register, useDispatch, useSelect } from "@wordpress/data";
import { Button, ButtonGroup, TextControl } from "@wordpress/components";

レデューサーの定義

まずは設定情報、配列、読み込み中かの論理値の3つを扱うレデューサーを作ることにします。

const config = (state = {name: 'KURAGE', description: '', version: 1}, action) =>
{
    switch(action.type)
    {
        case 'SET_CONFIG_NAME':
            return { ...state, name: action.value }
        case 'SET_CONFIG_VERSION':
            return { ...state, version: action.value }
        case 'SET_CONFIG_DESCRIPTION':
            return { ...state, description: action.value }
    }

    return state;
}

const items = (state = ['A', 'B', 'C'], action) =>
{
    switch(action.type)
    {
        case 'SET_ITEMS':
            return action.value;
        case 'ADD_ITEM':
            return [...state, action.value]
        case 'DELETE_ITEM':
            return state.filter(s => s !== action.value)
    }

    return state;
}

const loading = (state = false, action) =>
{
    switch(action.type)
    {
        case 'LOADING_BEGIN':
            return true;
        case 'LOADING_END':
            return false;
    }

    return state;
}

const reducer = combineReducers({ config, items, loading });

まずはこのcombineReducers()です。
これは引数に渡されたレデューサーをすべて実行する関数を返します。
つまり戻り値のreducerを実行すると「config, items, loading」を片っ端から実行していきます。

ではステート無し、アクション無しでreducerを実行します。

const initialize = reducer(undefined, { type: null });

console.log(JSON.stringify(initialize, null, "\t"));
/**
{
    "config": {
        "name": "KURAGE",
        "description": "",
        "version": 1
    },
    "items": [
        "A",
        "B",
        "C"
    ],
    "loading": false
}
 */

それぞれのレデューサーの名前をキーに、ステートの初期値で初期化されていることがわかります。
ちょっとイメージしやすいように以下のようにコードを書き換えても同じようになります。

const initialize = {
    config: config(undefined, {type: null}),
    items: items(undefined, {type: null}),
    loading: loading(undefined, {type: null})
}

console.log(JSON.stringify(initialize, null, "\t"));

いづれもアクションタイプがnullなのでステートはデフォルト引数の値がそのまま戻り初期化されます。

アクションの定義

シンプルなアクションの定義は以下です。
戻り値のtypeはレデューサーのswitch文で処理されます。
単にtypeを持ったリテラルオブジェクトを返しているだけです。

const setConfigName = (name) =>
{
    return ({
        type: 'SET_CONFIG_NAME',
        value: name
    });
}
const setConfigVersion = (version) =>
{
    return ({
        type: 'SET_CONFIG_VERSION',
        value: version
    });
}
const setConfigDescription = (description) =>
{
    return ({
        type: 'SET_CONFIG_DESCRIPTION',
        value: description
    });
}
const addItem = (item) =>
{
    return ({
        type: 'ADD_ITEM',
        value: item
    });
}
const clearItems = () =>
{
    return ({
        type: 'SET_ITEMS',
        value: []}
    );
}
const updateItems = (items) =>
{
    return ({
        type: 'SET_ITEMS',
        value: items
    });
}
const beginLoading = () =>
{
    return ({
        type: 'LOADING_BEGIN'
    })
}
const endLoading = () =>
{
    return ({
        type: 'LOADING_END'
    });
}

const actions = {
    setConfigName,
    setConfigVersion,
    setConfigDescription,
    addItem,
    clearItems,
    updateItems,
    beginLoading,
    endLoading
};

ちょっと高度なアクションの定義

ここでのアクションはディスパッチする関数です。
複数のdispatchを呼び出したい、selectorを取得したいなど複雑なことをする場合には関数を返します。

const setConfigName = (name) => ({ dispatch }) =>
{
    dispatch({type: 'SET_CONFIG_NAME', value: name});
}
const setConfigVersion = (version) => ({ dispatch }) =>
{
    dispatch({type: 'SET_CONFIG_VERSION', value: version});
}
const setConfigDescription = (description) => ({ dispatch }) =>
{
    dispatch({type: 'SET_CONFIG_DESCRIPTION', value: description});
}
const addItem = (item) => ({ dispatch }) =>
{
    dispatch({type: 'ADD_ITEM', value: item});
}
const clearItems = () => ({ dispatch }) =>
{
    dispatch({type: 'SET_ITEMS', value: []});
}
const updateItems = (items) => ({ dispatch }) =>
{
    dispatch({type: 'SET_ITEMS', value: items});
}
const beginLoading = () => ({ dispatch }) =>
{
    dispatch({type: 'LOADING_BEGIN'})
}
const endLoading = () => ({ dispatch }) =>
{
    dispatch({type: 'LOADING_END'});
}

const actions = {
    setConfigName,
    setConfigVersion,
    setConfigDescription,
    addItem,
    clearItems,
    updateItems,
    beginLoading,
    endLoading
};

それぞれの関数はレジストリを受け取りディスパッチする関数を返します。

よく見てほしいところ

  • updateItems()clearItems()はどちらもアクション名がSET_ITEMSになってます。
  • actions変数にまとめている

今回の例はどちらの定義でも使えます。
アクションによって分けて使うことになります。

セレクタの実装

つぎにセレクタです。

const getConfigName = (state) => state.config.name;
const getConfigVersion = (state) => state.config.version;
const getConfigDescription = (state) => state.config.description;

const getLoading = (state) => state.loading;

const getItems = (state) => state.items;
const getItemsCount = (state) =>
{
    // 何も値(items)だけを返すだけではない。
    return state.items.length;
}
const getItemByIndex = (state, index: number) =>
{
    // 引数を受け取ることも。
    return state.items?.[index];
}

const selectors = {
    getConfigName,
    getConfigVersion,
    getConfigDescription,
    getLoading,
    getItems,
    getItemsCount,
    getItemByIndex,
}

セレクタはステートから階層を追って値を返します。

よく見てほしいところ

  • 必ずしもステートの階層をたどるだけでなく計算した値も返してもいい。
  • selectors変数にまとめている

ストアでスクリプタの登録

const store = createReduxStore('kurage/data', {
    reducer,
    selectors,
    actions
});

register(store);

まずはcreateReduxStore()で今回作成した「レデューサー, セレクタ, アクション」からストアデスクリプタを作成し、それをregister()を使ってレジストリに登録します。

ここまでがRedux風にデータ操作を準備する手順です。

ではブロックで実際に使っていきましょう。

ブロックでの実装

ブロックで使用することを前提としてます。

const h2Style = { color: 'red' }
const divStyle = { border: '2px solid gray', margin: 2, padding: 2 }
const listStyle = { border: '1px dotted green', margin: 3 }

export default () =>
{
    const [index, setIndex] = useState(0);
    const [item, setItem] = useState('');

    const {
        configName,
        configVersion,
        loading,
        items,
        itemsCount,
        itemAt
     } = useSelect(select => {
        const s = select('kurage/data') as any;
        return ({
            configName: s.getConfigName(),
            configVersion: s.getConfigVersion(),
            loading: s.getLoading(),
            items: s.getItems(),
            itemsCount: s.getItemsCount(),
            itemAt: s.getItemByIndex(index)
        })
    }, [index]);

    const {
        setConfigVersion,
        addItem,
        clearItems,
        beginLoading,
        endLoading
    } = useDispatch('kurage/data');

    const changeVersion = () =>
    {
        const version = Math.floor(100 + Math.random() * 2000) / 100;
        console.log(version);
        setConfigVersion(version);
    }

    return (
        <div>
            <h2 style={h2Style}>Config</h2>
            <div style={divStyle}>
                <p>name: {configName} : ({configVersion})</p>
                <Button variant="primary" onClick={changeVersion}>
                    Change Version
                </Button>
            </div>

            <h2 style={h2Style}>Items</h2>
            <div>
                <p>ItemsCount: {itemsCount}</p>

                <div style={divStyle}>
                    <p>Add Item</p>

                    <TextControl value={item} onChange={v => setItem(v ?? '')} />
                    <Button variant="primary" onClick={() => addItem(item)}>
                        Add
                    </Button>
                </div>

                <div style={divStyle}>
                    <p>Clear Items</p>
                    <Button variant="primary" onClick={() => clearItems()}>
                        Clear
                    </Button>
                </div>

                <div style={divStyle}>
                    <div style={listStyle}>
                        {
                            items.length ?
                            items.map((item, index) => <p key={index}>{item}</p>) : 'Empty Items'
                        }
                    </div>
                </div>

                <div style={divStyle}>
                    <h3>item({index}): {itemAt ? itemAt : 'index out of range'}</h3>
                    <ButtonGroup>
                        <Button variant="primary" onClick={() => setIndex(index - 1)}>Prev</Button>
                        <Button variant="primary" onClick={() => setIndex(index + 1)}>Next</Button>
                    </ButtonGroup>
                </div>

            </div>

            <h2 style={h2Style}>Loading</h2>

            <div style={divStyle}>
                <p>{ loading ? 'LOADING' : ' - ' }</p>
                <ButtonGroup>
                    <Button variant="primary" onClick={() => beginLoading()}>BEGIN</Button>
                    <Button variant="primary" onClick={() => endLoading()}>END</Button>
                </ButtonGroup>
            </div>

        </div>
    )
}

セレクタやアクションを実行するのにuseDispatch()useSelect()が使用できます。
これらの説明はこれまでにもやってきたので省略します。

では実行結果を見てみます。

見どころ

  • Change Version をクリックするとSET_CONFIG_VERSIONに乱数を渡します。
  • Add をクリックするとテキストボックスに入力した文字列をADD_ITEMに渡します(アイテムのリストに追加する)。
  • Clear をクリックするとSET_ITEMSに空の配列を渡し「アイテムを全部消す」ことになります。
  • Prev/Next をクリックすると現在のインデックス番号のアイテムを表示します。
  • BEGIN/END をクリックすると、LOADING_BEGIN/LOADING_ENDを値無しで呼び出します。

コード全体

import React from "react"

import { useState } from '@wordpress/element';
import { combineReducers, createReduxStore, register, useDispatch, useSelect } from "@wordpress/data";
import { Button, ButtonGroup, TextControl } from "@wordpress/components";

const config = (state = {name: 'KURAGE', description: '', version: 1}, action) =>
{
    switch(action.type)
    {
        case 'SET_CONFIG_NAME':
            return { ...state, name: action.value }
        case 'SET_CONFIG_VERSION':
            return { ...state, version: action.value }
        case 'SET_CONFIG_DESCRIPTION':
            return { ...state, description: action.value }
    }

    return state;
}

const items = (state = ['A', 'B', 'C'], action) =>
{
    switch(action.type)
    {
        case 'SET_ITEMS':
            return action.value;
        case 'ADD_ITEM':
            return [...state, action.value]
        case 'DELETE_ITEM':
            return state.filter(s => s !== action.value)
    }

    return state;
}

const loading = (state = false, action) =>
{
    switch(action.type)
    {
        case 'LOADING_BEGIN':
            return true;
        case 'LOADING_END':
            return false;
    }

    return state;
}

const reducer = combineReducers({ config, items, loading });

const setConfigName = (name) => ({ dispatch }) =>
{
    dispatch({type: 'SET_CONFIG_NAME', value: name});
}
const setConfigVersion = (version) => ({ dispatch }) =>
{
    dispatch({type: 'SET_CONFIG_VERSION', value: version});
}
const setConfigDescription = (description) => ({ dispatch }) =>
{
    dispatch({type: 'SET_CONFIG_DESCRIPTION', value: description});
}
const addItem = (item) => ({ dispatch }) =>
{
    dispatch({type: 'ADD_ITEM', value: item});
}
const clearItems = () => ({ dispatch }) =>
{
    dispatch({type: 'SET_ITEMS', value: []});
}
const updateItems = (items) => ({ dispatch }) =>
{
    dispatch({type: 'SET_ITEMS', value: items});
}
const beginLoading = () => ({ dispatch }) =>
{
    dispatch({type: 'LOADING_BEGIN'})
}
const endLoading = () => ({ dispatch }) =>
{
    dispatch({type: 'LOADING_END'});
}

const actions = {
    setConfigName,
    setConfigVersion,
    setConfigDescription,
    addItem,
    clearItems,
    updateItems,
    beginLoading,
    endLoading
};

const getConfigName = (state) => state.config.name;
const getConfigVersion = (state) => state.config.version;
const getConfigDescription = (state) => state.config.description;

const getLoading = (state) => state.loading;

const getItems = (state) => state.items;
const getItemsCount = (state) =>
{
    // 何も値(items)だけを返すだけではない。
    return state.items.length;
}
const getItemByIndex = (state, index: number) =>
{
    // 引数を受け取ることも。
    return state.items?.[index];
}

const selectors = {
    getConfigName,
    getConfigVersion,
    getConfigDescription,
    getLoading,
    getItems,
    getItemsCount,
    getItemByIndex,
}

const store = createReduxStore('kurage/data', {
    reducer,
    selectors,
    actions
});

register(store);

const h2Style = { color: 'red' }
const divStyle = { border: '2px solid gray', margin: 2, padding: 2 }
const listStyle = { border: '1px dotted green', margin: 3 }

export default () =>
{
    const [index, setIndex] = useState(0);
    const [item, setItem] = useState('');

    const {
        configName,
        configVersion,
        loading,
        items,
        itemsCount,
        itemAt
     } = useSelect(select => {
        const s = select('kurage/data') as any;
        return ({
            configName: s.getConfigName(),
            configVersion: s.getConfigVersion(),
            loading: s.getLoading(),
            items: s.getItems(),
            itemsCount: s.getItemsCount(),
            itemAt: s.getItemByIndex(index)
        })
    }, [index]);

    const {
        setConfigVersion,
        addItem,
        clearItems,
        beginLoading,
        endLoading
    } = useDispatch('kurage/data');

    const changeVersion = () =>
    {
        const version = Math.floor(100 + Math.random() * 2000) / 100;
        console.log(version);
        setConfigVersion(version);
    }

    return (
        <div>
            <h2 style={h2Style}>Config</h2>
            <div style={divStyle}>
                <p>name: {configName} : ({configVersion})</p>
                <Button variant="primary" onClick={changeVersion}>
                    Change Version
                </Button>
            </div>

            <h2 style={h2Style}>Items</h2>
            <div>
                <p>ItemsCount: {itemsCount}</p>

                <div style={divStyle}>
                    <p>Add Item</p>

                    <TextControl value={item} onChange={v => setItem(v ?? '')} />
                    <Button variant="primary" onClick={() => addItem(item)}>
                        Add
                    </Button>
                </div>

                <div style={divStyle}>
                    <p>Clear Items</p>
                    <Button variant="primary" onClick={() => clearItems()}>
                        Clear
                    </Button>
                </div>

                <div style={divStyle}>
                    <div style={listStyle}>
                        {
                            items.length ?
                            items.map((item, index) => <p key={index}>{item}</p>) : 'Empty Items'
                        }
                    </div>
                </div>

                <div style={divStyle}>
                    <h3>item({index}): {itemAt ? itemAt : 'index out of range'}</h3>
                    <ButtonGroup>
                        <Button variant="primary" onClick={() => setIndex(index - 1)}>Prev</Button>
                        <Button variant="primary" onClick={() => setIndex(index + 1)}>Next</Button>
                    </ButtonGroup>
                </div>

            </div>

            <h2 style={h2Style}>Loading</h2>

            <div style={divStyle}>
                <p>{ loading ? 'LOADING' : ' - ' }</p>
                <ButtonGroup>
                    <Button variant="primary" onClick={() => beginLoading()}>BEGIN</Button>
                    <Button variant="primary" onClick={() => endLoading()}>END</Button>
                </ButtonGroup>
            </div>

        </div>
    )
}

アクション中にセレクタが欲しい時

例えばアイテム(文字列)を追加する時を考えてみます。
現状、リストに同じアイテムを追加出来るようになってます。
例えば「X」というアイテムを10個追加することだってできます。

もしアイテムをユニークにしたいといった要件が出てきたらどうでしょう!?
既に同じアイテムが存在するかを調べるためにセレクタからアイテム一覧を取得する必要が出てきます。

それは簡単にできます。
ではアクションのaddItem()を変更しましょう。

const addItem = (item) => ({ dispatch, select }) =>
{
    // セレクタから取得
    const items = select.getItems() as string[];

    if(!items.includes(item))
    {
        dispatch({type: 'ADD_ITEM', value: item});
    }
}

引数からselectを使ってセレクタを取り出せます。
これでセレクタのgetItems()からアイテム一覧を取得出来るようになりました。
もしアイテム一覧に追加しようとする文字列と同じものがあれば無視します。

アクション中で取得出来るもの

const addItem = (item) => (args) =>
{
    const { dispatch, select, registry, resolveSelect } = args;

    // セレクタから取得
    const items = select.getItems() as string[];

    if(!items.includes(item))
    {
        dispatch({type: 'ADD_ITEM', value: item});
    }
}

ディスパッチャやセレクタだけではなくregistry(レジストリ)も取得出来ます。
ソースコード読むとバッチ処理に使われているようです。
resolveSelectは非同期のものです。
この辺は話がややこしくなるので今回は割愛します。

アクションに非同期が絡むとき

import React from "react"

import { useState } from '@wordpress/element';
import { combineReducers, createReduxStore, register, useDispatch, useSelect } from "@wordpress/data";
import { Button, ButtonGroup, Spinner, TextControl } from "@wordpress/components";

const items = (state = [], action) =>
{
    switch(action.type)
    {
        case 'SET_ITEMS':
            return action.value;
        case 'ADD_ITEM':
            return [...state, action.value]
        case 'DELETE_ITEM':
            return state.filter(s => s !== action.value)
    }

    return state;
}

const loading = (state = false, action) =>
{
    switch(action.type)
    {
        case 'LOADING_BEGIN':
            return true;
        case 'LOADING_END':
            return false;
    }

    return state;
}

const reducer = combineReducers({ items, loading });

const sleep = async (ts: number) => new Promise(r => setTimeout(r, ts));

const loadFileByItem = async (item) =>
{
    // 1秒以上3秒以内スリープ
    await sleep(Math.floor(Math.random() * 2000) + 1000);

    return `${item} file data`;
}

const addItem = (item) => async ({ dispatch, select }) =>
{
    // セレクタから取得
    const items = select.getItems() as string[];

    // 読み込み中かどうか
    const loading = select.getLoading();

    if(!items.includes(item) && !loading)
    {
        dispatch({type: 'LOADING_BEGIN'});

        // itemをファイル名だと思い込んでデータを取得する
        const fileData = await loadFileByItem(item);

        // ファイルデータを追加する
        dispatch({type: 'ADD_ITEM', value: fileData});

        dispatch({type: 'LOADING_END'});
    }
}
const clearItems = () => ({ dispatch }) =>
{
    dispatch({type: 'SET_ITEMS', value: []});
}
const updateItems = (items) => ({ dispatch }) =>
{
    dispatch({type: 'SET_ITEMS', value: items});
}

const actions = {
    addItem,
    clearItems,
    updateItems,
};

const getLoading = (state) => state.loading;

const getItems = (state) => state.items;
const getItemsCount = (state) =>
{
    // 何も値(items)だけを返すだけではない。
    return state.items.length;
}
const getItemByIndex = (state, index: number) =>
{
    // 引数を受け取ることも。
    return state.items?.[index];
}

const selectors = {
    getLoading,
    getItems,
    getItemsCount,
    getItemByIndex,
}

const store = createReduxStore('kurage/data', {
    reducer,
    selectors,
    actions
});

register(store);

const h2Style = { color: 'red' }
const divStyle = { border: '2px solid gray', margin: 2, padding: 2 }
const listStyle = { border: '1px dotted green', margin: 3 }

export default () =>
{
    const [item, setItem] = useState('');

    const {
        loading,
        items,
        itemsCount,
     } = useSelect(select => {
        const s = select('kurage/data') as any;
        return ({
            loading: s.getLoading(),
            items: s.getItems(),
            itemsCount: s.getItemsCount(),
        })
    }, []);

    const {
        addItem,
        clearItems,
    } = useDispatch('kurage/data');

    return (
        <div>

            <h2 style={h2Style}>Items</h2>
            <div>
                <p>ItemsCount: {itemsCount}</p>

                <div style={divStyle}>
                    <p>Add Item</p>

                    <TextControl value={item} onChange={v => setItem(v ?? '')} />

                    <ButtonGroup>
                        <Button variant="primary" onClick={() => addItem(item)}>
                            Add
                        </Button>

                        <Button variant="primary" onClick={() => clearItems()}>
                            Clear
                        </Button>                     
                    </ButtonGroup>

                </div>

                <div style={divStyle}>
                    <div style={listStyle}>
                        {
                            loading ? <Spinner /> :
                            items.length ? items.map((item, index) => <p key={index}>{item}</p>) : 'Empty Items'
                        }
                    </div>
                </div>

            </div>

        </div>
    )
}

前回のコードから変わったところ

  • config系をまるっとカット
  • loadingのアクションを排除
  • View周りをまるっとすっきり!
  • アイテムの初期値を空っぽに
// stateのデフォルト引数を空の配列に
const items = (state = ['A', 'B', 'C'], action) =>
{
    switch(action.type)
    {
        case 'SET_ITEMS':
            return action.value;
        case 'ADD_ITEM':
            return [...state, action.value]
        case 'DELETE_ITEM':
            return state.filter(s => s !== action.value)
    }

    return state;
}

ではさっぱり後の実行結果を先に見ていきます。
初期値「A, B, C」を削除しましたのでアイテムのリストは空っぽです。

テキストボックスにhello-kurage.txtと適当に入力してAddボタンをクリックします。
すると「読み込み中」のスピナーが表示されます。

変更後のコードは以下の部分です。

{
    loading ? <Spinner /> :
    items.length ? items.map((item, index) => <p key={index}>{item}</p>) : 'Empty Items'
}

1~3秒のタイムラグを終えてアイテムが更新されます。

アイテムを追加するアクション(getItem())が非同期になっています。

const sleep = async (ts: number) => new Promise(r => setTimeout(r, ts));

const loadFileByItem = async (item) =>
{
    // 1秒以上3秒以内スリープ
    await sleep(Math.floor(Math.random() * 2000) + 1000);

    return `${item} file data`;
}

const addItem = (item) => async ({ dispatch, select }) =>
{
    // セレクタから取得
    const items = select.getItems() as string[];

    // 読み込み中かどうか
    const loading = select.getLoading();

    if(!items.includes(item) && !loading)
    {
        dispatch({type: 'LOADING_BEGIN'});

        // itemをファイル名だと思い込んでデータを取得する
        const fileData = await loadFileByItem(item);

        // ファイルデータを追加する
        dispatch({type: 'ADD_ITEM', value: fileData});

        dispatch({type: 'LOADING_END'});
    }
}

ただ注意が必要で、

if(!items.includes(item) && !loading)

この部分のアイテムの二重登録阻止の機能は働きません。
当然ですが、ファイル名とファイルの中身を両方をステートに持ってないといけないからです。

あくまで非同期処理のやりかたについてなのでこの辺は緩めに行きます。

LOADING_BEGIN / LOADING_ENDは複数の読み込みに対応していません。
以下のように追加ボタンを制御して読み込み中には追加ボタンを押せないようにすることもできます。

<Button disabled={loading} variant="primary" onClick={() => addItem(item)}>
    Add
</Button>

よりコードを洗練させる。

アイテム追加(addItem())が非同期となると一つ問題が出てきます。

updateItems()はどうなるんだろう

こっちも非同期にしないといけません。
さらに言うと、こちらは複数のアイテムを追加することになります。
addItem()をアイテムの数分複数回実行するのはいろいろと不都合があります。

このままでは使いにくいコードになります。

そこでこれらを解決するため、複数のアイテムを追加するaddItems()アクションを追加し、アイテムの追加はすべてこちらに任せることにします。

addItem()は長さが1のアイテムのリストを追加することと同じです。
updateItems()はいったんアイテムリストを空にしてから複数のアイテムを追加することと同じです。

では変更します。

import React from "react"

import { useState } from '@wordpress/element';
import { combineReducers, createReduxStore, register, useDispatch, useSelect } from "@wordpress/data";
import { Button, ButtonGroup, Spinner, TextControl } from "@wordpress/components";

const items = (state = [], action) =>
{
    switch(action.type)
    {
        case 'SET_ITEMS':
            return action.value;
        case 'ADD_ITEM':
            return [...state, action.value]
        case 'DELETE_ITEM':
            return state.filter(s => s !== action.value)
    }

    return state;
}

const loading = (state = false, action) =>
{
    switch(action.type)
    {
        case 'LOADING_BEGIN':
            return true;
        case 'LOADING_END':
            return false;
    }

    return state;
}

const reducer = combineReducers({ items, loading });

const sleep = async (ts: number) => new Promise(r => setTimeout(r, ts));

const loadFileByItem = async (item) =>
{
    // 1秒以上3秒以内スリープ
    await sleep(Math.floor(Math.random() * 2000) + 1000);

    return `${item} file data`;
}

// 新たに複数のアイテムを追加するアクションを追加。
const addItems = (items) => async ({ dispatch, select }) =>
{
    const loading = select.getLoading();

    if(!loading)
    {
        dispatch({type: 'LOADING_BEGIN'});

        for(const item of items)
        {
            const fileData = await loadFileByItem(item);
            dispatch({type: 'ADD_ITEM', value: fileData});
        }

        dispatch({type: 'LOADING_END'});
    }

}
const addItem = (item) => async ({ dispatch }) =>
{
    // アイテムたちを追加する(dispatchからアクションが生えているところに注目)
    await dispatch.addItems([item]);
}
const updateItems = (items) => async ({ dispatch }) =>
{
    // まずは空っぽにしてから
    dispatch({type: 'SET_ITEMS', value: []});

    // アイテムたちを追加する(dispatchからアクションが生えているところに注目)
    await dispatch.addItems(items);
}
const clearItems = () => ({ dispatch }) =>
{
    dispatch({type: 'SET_ITEMS', value: []});
}

const actions = {
    addItem,
    addItems, // ここに追加したことに注目。
    clearItems,
    updateItems,
};

const getLoading = (state) => state.loading;

const getItems = (state) => state.items;
const getItemsCount = (state) =>
{
    // 何も値(items)だけを返すだけではない。
    return state.items.length;
}
const getItemByIndex = (state, index: number) =>
{
    // 引数を受け取ることも。
    return state.items?.[index];
}

const selectors = {
    getLoading,
    getItems,
    getItemsCount,
    getItemByIndex,
}

const store = createReduxStore('kurage/data', {
    reducer,
    selectors,
    actions
});

register(store);

const h2Style = { color: 'red' }
const divStyle = { border: '2px solid gray', margin: 2, padding: 2 }
const listStyle = { border: '1px dotted green', margin: 3 }

export default () =>
{
    const [item, setItem] = useState('');

    const {
        loading,
        items,
        itemsCount,
     } = useSelect(select => {
        const s = select('kurage/data') as any;
        return ({
            loading: s.getLoading(),
            items: s.getItems(),
            itemsCount: s.getItemsCount(),
        })
    }, []);

    const {
        addItem,
        addItems,
        updateItems,
        clearItems,
    } = useDispatch('kurage/data');

    return (
        <div>

            <h2 style={h2Style}>Items</h2>
            <div>
                <p>ItemsCount: {itemsCount}</p>

                <div style={divStyle}>
                    <p>Add Item</p>

                    <TextControl value={item} onChange={v => setItem(v ?? '')} />

                    <ButtonGroup>
                        <Button disabled={loading} variant="primary" onClick={() => addItem(item)}>
                            Add
                        </Button>

                        <Button disabled={loading} variant="primary" onClick={() => addItems(['x.txt', 'y.txt', 'z.txt'])}>
                            Add Items!
                        </Button>

                        <Button disabled={loading} variant="primary" onClick={() => updateItems(['a.txt', 'b.txt', 'c.txt'])}>
                            Update Items!
                        </Button>

                        <Button variant="primary" onClick={() => clearItems()}>
                            Clear
                        </Button>                     
                    </ButtonGroup>

                </div>

                <div style={divStyle}>
                    <div style={listStyle}>
                        { loading && <Spinner /> }
                        {
                            items.length ? items.map((item, index) => <p key={index}>{item}</p>) : 'Empty Items'
                        }
                    </div>
                </div>

            </div>

        </div>
    )
}

Update Items!」を実行
Add Items!」を実行
Add」を実行

した時の結果です。

一番重要なaddItems()です。
ファイルを一つずつ読み込んでいきます。
一つ読み込むたびにADD_ITEMをディスパッチしています。
しかしLOADING_BEGIN / LOADING_ENDはアイテムの数が複数であっても1度しか実行しません。

const addItems = (items) => async ({ dispatch, select }) =>
{
    const loading = select.getLoading();

    if(!loading)
    {
        dispatch({type: 'LOADING_BEGIN'});

        for(const item of items)
        {
            const fileData = await loadFileByItem(item);
            dispatch({type: 'ADD_ITEM', value: fileData});
        }

        dispatch({type: 'LOADING_END'});
    }

}

// addItemsをアクションに追加
const actions = {
    addItem,
    addItems,
    clearItems,
    updateItems,
};

ではaddItem()updateItems()の変更も見ていきます。

const addItem = (item) => async ({ dispatch }) =>
{
    // アイテムたちを追加する(dispatchからアクションが生えているところに注目)
    await dispatch.addItems([item]);
}
const updateItems = (items) => async ({ dispatch }) =>
{
    // まずは空っぽにしてから
    dispatch({type: 'SET_ITEMS', value: []});

    // アイテムたちを追加する(dispatchからアクションが生えているところに注目)
    await dispatch.addItems(items);
}

実はdispatchはアクションに追加されたメソッドが生えており実行できるようになってます。

addItem()は長さ1の配列を追加することと同等

dispatch.addItems([item]);

updateItems()はいったんリストを空にしてからアイテムたちを追加することと同等

dispatch.addItems(items)

ということです。

アクションがそろったところで早速実行していきます。

その前にこれを視覚的に分かりやすいようにView側にも変更を加えています。

{ loading && <Spinner /> }
{
    items.length ? items.map((item, index) => <p key={index}>{item}</p>) : 'Empty Items'
}

読み込み中、リストごとスピナーを表示するのではなく、スピナー表示中でもリスト一覧が見えるようにしました。
複数アイテムの読み込みでも、読み込まれた順に追加されていることが確認できます。

ボタンが二つ追加されました。

<Button disabled={loading} variant="primary" onClick={() => addItems(['x.txt', 'y.txt', 'z.txt'])}>
    Add Items!
</Button>

<Button disabled={loading} variant="primary" onClick={() => updateItems(['a.txt', 'b.txt', 'c.txt'])}>
    Update Items!
</Button>

アクションの非同期についてはここまでです。

ただし、

この状態では一度に一つのファイルしか読み込めませんから、汎用的には使えません。
また読み込み中のファイル名の状態を持ったり、改良する余地はまだまだあります。

セレクタの登録

セレクタの登録を見ているとcreateRegistrySelector()という関数が存在します。
これは、レジストリ下の別のセレクタにアクセスする際にしようするようです。

import React from "react"
import { createReduxStore, createRegistrySelector, register, useSelect } from "@wordpress/data";

const takosan = (state = 1, action) => state;
const getTakosanState = (state) => state;

const ikasan = (state = 2, action) => state;
const getIkasanState = (state) => state;
const getIkasanWithTakosan = createRegistrySelector(
    select => state=> select('kurage/takosan').getTakosanState() + state
);

const takosanStore = createReduxStore('kurage/takosan', {
    reducer: takosan,
    selectors: {
        getTakosanState
    },
    actions: {}
});

const ikasanStore = createReduxStore('kurage/ikasan', {
    reducer: ikasan,
    selectors: {
        getIkasanState,
        getIkasanWithTakosan
    },
    actions: {}
});

register(takosanStore);
register(ikasanStore);

export default () =>
{
    // @ts-ignore
    const takosanState = useSelect(select => select('kurage/takosan').getTakosanState(), []);

    // @ts-ignore
    const { ikasanState, ikasanAndTakosan } = useSelect(select => {
        const s = select('kurage/ikasan') as any;
        return ({
            ikasanState: s.getIkasanState(),
            ikasanAndTakosan: s.getIkasanWithTakosan()
        })
    }, []);

    return (
        <div>
            <p>takosanState: {takosanState}</p>
            <p>ikasanState: {ikasanState}</p>
            <p>ikasan and takosan: {ikasanAndTakosan}</p>
        </div>
    )
}

結果は以下のようになります。

takosanState: 1
ikasanState: 2
ikasan and takosan: 3

それぞれtakosan,ikasanというステートをそのまま返すレデューサーを作ります。
それぞれそのステートをそのまま返すセレクタgetTakosanState()getIkasanState()を定義してます。
唯一の違いはセレクタgetIkasanWithTakosan()kurage/ikasanストアデスクリプションに定義している点です。

この関数でセレクタを作成することによってレジストリ内の別のストアのセレクタを取得出来るようになります。

つまりgetIkasanWithTakosan()kurage/ikasanのセレクタなのに、kurage/takosanのセレクタを呼び出すことが出来ています。

const getIkasanWithTakosan = createRegistrySelector(select => {

    return state=>
    {
        select('kurage/takosan').getTakosanState() + state;
    }

});

単純にkurage/takosanのステートと自身のステートを足した「3」を取得するものです。

ではcreateRegistrySelector()はどういう実装されているのかというと、

const getIkasanWithTakosan = createRegistrySelector(select => {
    console.log('----------- recieve')
    return state=>
    {
        console.log('-------------- state')
        select('kurage/takosan').getTakosanState() + state;
    }
});

// @ts-ignore
console.log(getIkasanWithTakosan.isRegistrySelector);

const ikasanStore = createReduxStore('kurage/ikasan', {
    reducer: ikasan,
    selectors: {
        getIkasanState,
        getIkasanWithTakosan
    },
    actions: {}
});

// @ts-ignore
console.log(getIkasanWithTakosan.registry ?? 'registry is undefined');

register(ikasanStore);

// @ts-ignore 実はここでレジストリが追加されている。
console.log(getIkasanWithTakosan.registry ?? 'registry is undefined');

const s = select(ikasanStore);
s.getIkasanWithTakosan();
s.getIkasanWithTakosan();
s.getIkasanWithTakosan();
s.getIkasanWithTakosan();
true
registry is undefined
{stores: {…}, namespaces: {…}, batch: ƒ, subscribe: ƒ, select: ƒ, …}
----------- recieve
-------------- state
----------- recieve
-------------- state
----------- recieve
-------------- state
----------- recieve
-------------- state

createRegistrySelector()の戻り値はラップされたセレクタでisRegistrySelectorプロパティを生やしてます。
値はtrueです。

register(ikasanStore);

ストアデスクリプタがレジストリに登録される際にisRegistrySelectortrueのセレクタは
自動的にregistryを生やします。
その値は現在のレジストリです。
より厳密にはcreateReduxStore()内で実装してあることに注意してください。

Redux風ではなく直接ストアデスクリプタを定義する時は?

import React from "react"
import { register, useDispatch, useSelect } from "@wordpress/data";
import { useState } from '@wordpress/element';
import { Button, Spinner, TextControl } from "@wordpress/components";

export const KurageDataStore = {
    name: 'kurage/data',
    instantiate: registry =>
    {
        const listeners = new Set<() => void>();

        const state = {
            items: [] as string[],
            loading: false,
            loadingMessage: ''
        }

        const onStoreChanged = () =>
        {
            listeners.forEach(_ => _());
        }

        return {

            getSelectors()
            {
                return {
                    getLoading: () => state.loading,
                    getItems: () => state.items,
                    getLoadingMessage: () => state.loadingMessage,
                    getBlockNames: () =>
                    {
                        // 別のセレクタ(コアのストアから拝借)にアクセス
                        const s = registry.select('core/block-editor');
                        const blockNames = s.getBlocks().map(block => block.name);
                        return blockNames;
                    }
                }
            },

            getActions()
            {
                const sleep = (ts) => new Promise(r => setTimeout(r, ts));

                return {
                    setLoading: (loading: boolean) =>
                    {
                        state.loading = loading;
                        onStoreChanged();
                    },
                    setLoadingMessage: (message: string) =>
                    {
                        state.loadingMessage = message;
                        onStoreChanged();
                    },
                    loadFile: async (fileName: string) =>
                    {
                        if(!state.loading)
                        {
                            const actions = registry.dispatch('kurage/data');
                            actions.setLoading(true);
                            actions.setLoadingMessage(`loading ${fileName}`);

                            await sleep(2000);
                            state.items.push(`loaded file: ${fileName}`);

                            actions.setLoadingMessage('');
                            actions.setLoading(false);
                        }
                    }
                }
            },

            subscribe(listener)
            {
                listeners.add(listener);
                return () => listeners.delete(listener);
            }

        }

    }
}

register(KurageDataStore);

export default () =>
{
    const [fileName, setFileName] = useState('');

    const { loading, message, items, blockNames } = useSelect(select => {
        const s = select('kurage/data') as any;
        return ({
            loading: s.getLoading(),
            message: s.getLoadingMessage(),
            items: s.getItems(),
            blockNames: s.getBlockNames()
        })
    }, []);

    const { loadFile } = useDispatch('kurage/data');

    const load = () =>
    {
        loadFile(fileName);
    }

    return (
        <div>
            <p>{message}</p>
            { loading && <Spinner /> }

            <TextControl value={fileName} onChange={setFileName} />
            <Button variant="primary" disabled={loading} onClick={load}>
                ファイル読み込み
            </Button>

            <div>
                { items.map((item, index) => <p key={index}>{item}</p>) }
            </div>

            <p>
                block names: {blockNames.join(', ')}
            </p>
        </div>
    )
}

aaaを入力してファイル読み込みボタンをクリック
bbbを入力してファイル読み込みボタンをクリック
cccを入力してファイル読み込みボタンをクリック


最後のcccは読み込みに3秒かかるのでその間はスピナーが出現する。
しかし3秒たち読み込みが完了すると以下のようになる。

cccが読み込まれリストに追加されました。

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