1. ホーム
  2. WordPress

【WordPress】@wordpress/data で独自のデータストアを登録する方法|createReduxStore と register

Share

カスタムブロックを作っていると、「このデータ、ブロックの外に置きたい」と思う場面が出てきます。同じプラグインの2種類のブロックで同じ値を見たい、REST API から取ってきた一覧を何度も取り直したくない、といったケースです。@wordpress/data は core/block-editor のようなコアのストアを読み書きするためだけのものではなく、自分のストアを作って登録するための API も用意されています。この記事では createReduxStore と register で独自ストアを作り、useSelect と useDispatch からそれを使うまでを、ファイル構成つきで解説します。

attributes にも useState にも置けない状態がある

ブロックが持つ値の置き場所は、ふつう2つです。投稿に保存したい値は block.json の attributes に、開いているパネルの開閉状態のような一時的な値は useState に置きます。どちらも「そのブロック1個の中だけ」で完結する入れ物です。

困るのは、その境界をまたぎたくなったときです。たとえばスタッフ紹介プラグインで「スタッフ一覧ブロック」と「スタッフ詳細ブロック」を作り、一覧で選んだ人を詳細側でも表示したいとします。この「いま選ばれている人」は投稿に保存する値ではないので attributes には向きませんし、別々のコンポーネントなので useState では共有できません。親子関係がある場合は providesContext という手もありますが、離れた場所に置かれたブロック同士では使えません。

もうひとつは、データの取得です。スタッフの一覧を REST API から取る処理を edit の中に書くと、ブロックを5個置けば5回リクエストが飛びます。取得結果を1か所にためておいて、どのブロックからも同じものを読めるようにすれば、通信は1回で済みます。こうした「ブロックをまたいで共有したい状態」の置き場所が、自作のデータストアです。

ストアは reducer・actions・selectors の3点セット

@wordpress/data のストアは Redux とほぼ同じ考え方でできています。値そのものである state があり、それを直接書き換えることは許されていません。更新したいときは「何をしたいか」を表すただのオブジェクト(アクション)を投げ、それを受け取った reducer が新しい state を作って返します。読むときは セレクターという関数を通します。

流れにすると、dispatch でアクションを投げる → reducer が新しい state を返す → その state を見ているセレクターの結果が変わる → useSelect を使っているコンポーネントが再レンダリングされる、という一方通行になります。ストアを作るというのは、この3つの部品を書いて createReduxStore に渡すことだと考えてください。

createReduxStore( storeName, options ) の第1引数はストアを識別する一意な名前、第2引数が設定オブジェクトです。設定に書けるおもなキーは次のとおりです。

キー役割
reducer現在の state とアクションを受け取り、次の state を返す関数。ここだけが必須です。
actionsアクションオブジェクトを返す関数(アクションクリエーター)をまとめたオブジェクト。dispatch から呼びます。
selectors第1引数に state を受け取り、必要な値を返す関数をまとめたオブジェクト。select から呼びます。
resolversセレクターと同じ名前で定義する関数。そのセレクターが呼ばれたときに1度だけ走り、足りないデータを取りにいきます。
controlsアクションの type をキーにして、API 通信などの副作用の実体を書くオブジェクト。
initialStateストアの初期 state。省略した場合は reducer の既定値が使われます。

ストア名は my-plugin/staff のように「名前空間/ストア名」の形にするのが慣例です。コアのストアも core/block-editor や core/notices という名前になっています。ほかのプラグインとぶつからないよう、プラグイン固有の語を先頭に付けてください。

ストアを作って登録する

ここからは、先ほどのスタッフ紹介プラグインを例に作っていきます。まずは「いま選ばれているスタッフの ID」だけを持つ、通信のない最小のストアです。create-block で作ったひな形の src ディレクトリに、次のように store フォルダーを足します。

ディレクトリ構成
src/
├── block.json
├── index.js
├── edit.js
├── save.js
└── store/
    ├── index.js      // createReduxStore と register
    ├── reducer.js
    ├── actions.js
    └── selectors.js

1ファイルにまとめても動きますが、あとで resolvers や controls が増えると読みづらくなるので、最初から分けておくのがおすすめです。

reducer.js:state の更新ルールを書く

reducer は ( state, action ) => 新しい state という形の関数です。第1引数のデフォルト値として初期 state を書いておき、action.type を見て分岐します。知らない type が来たときは、受け取った state をそのまま返すのが決まりごとです。

src/store/reducer.js
const DEFAULT_STATE = {
    selectedStaffId: null,
    staffList: [],
};

export default function reducer( state = DEFAULT_STATE, action ) {
    switch ( action.type ) {
        case 'SET_SELECTED_STAFF':
            // 元の state は書き換えず、新しいオブジェクトを作って返す
            return {
                ...state,
                selectedStaffId: action.staffId,
            };

        case 'RECEIVE_STAFF_LIST':
            return {
                ...state,
                staffList: action.items,
            };
    }

    return state;
}

staffList は後半の REST API の節で使うので、いまは空配列のまま置いておきます。

actions.js:アクションオブジェクトを作る関数

アクションクリエーターは、type を含むプレーンなオブジェクトを返すだけの関数です。ここでは通信も state の書き換えもしません。「こういう出来事が起きた」という伝票を作るだけ、と考えると分かりやすいです。

src/store/actions.js
export function setSelectedStaff( staffId ) {
    return {
        type: 'SET_SELECTED_STAFF',
        staffId,
    };
}

export function receiveStaffList( items ) {
    return {
        type: 'RECEIVE_STAFF_LIST',
        items,
    };
}

selectors.js:state から値を取り出す

セレクターは第1引数に必ず state を受け取ります。呼び出す側は state を渡しません。select( store ).getSelectedStaffId() と書けば、state はデータモジュールが自動で差し込んでくれます。

src/store/selectors.js
export function getSelectedStaffId( state ) {
    return state.selectedStaffId;
}

export function getStaffList( state ) {
    return state.staffList;
}

store/index.js:createReduxStore と register

3つの部品がそろったら、createReduxStore でストアの記述子(ストアオブジェクト)を作り、register で既定のレジストリーに登録します。createReduxStore は作るだけで、登録は register の仕事です。両方を呼ばないとストアは使えません。

src/store/index.js
import { createReduxStore, register } from '@wordpress/data';

import reducer from './reducer';
import * as actions from './actions';
import * as selectors from './selectors';

export const STORE_NAME = 'my-plugin/staff';

export const store = createReduxStore( STORE_NAME, {
    reducer,
    actions,
    selectors,
} );

register( store );

import * as actions と書くと、そのファイルの名前付きエクスポートがそのままオブジェクトになります。actions に入れた関数名が dispatch から呼ぶ名前に、selectors に入れた関数名が select から呼ぶ名前になります。

作ったストアオブジェクトを export しているのがポイントです。select( 'my-plugin/staff' ) のように文字列で指定することもできますが、このオブジェクトを渡すほうがタイプミスに気づきやすく、エディターの補完も効きます。

ブロックの index.js から読み込む

register はモジュールが読み込まれたときに実行されるので、ブロックのエントリーポイントで store を import しておきます。値を使わない import ですが、これが実行されないとストアは登録されません。

src/index.js
import { registerBlockType } from '@wordpress/blocks';

import './store'; // ストアを登録するために読み込む
import metadata from './block.json';
import Edit from './edit';
import save from './save';

registerBlockType( metadata.name, {
    edit: Edit,
    save,
} );

ビルドは @wordpress/scripts(npm run build)に任せます。@wordpress/data の import は自動的に wp.data への参照に置き換えられ、生成される index.asset.php の依存配列にも wp-data が入ります。package.json にわざわざ依存を足したり、wp_enqueue_script の第3引数を手で書き換えたりする必要はありません。

useSelect と useDispatch で自作ストアを使う

登録さえしてしまえば、使い方はコアのストアとまったく同じです。select に渡すのがコアのストアか自作のストアかが違うだけで、書き方は変わりません。

edit.js から読み書きする

useSelect で値を読み、useDispatch でアクションを投げます。useDispatch( store ) は、そのストアの actions に登録した関数と同じ名前のメソッドを持つオブジェクトを返します。呼び出すと、アクションクリエーターの戻り値が自動で dispatch されます。

src/edit.js
import { useBlockProps } from '@wordpress/block-editor';
import { useSelect, useDispatch } from '@wordpress/data';
import { Button } from '@wordpress/components';

import { store as staffStore } from './store';

export default function Edit( { attributes } ) {
    const { staffId } = attributes;

    // 自作ストアから読む
    const selectedStaffId = useSelect(
        ( select ) => select( staffStore ).getSelectedStaffId(),
        []
    );

    // 自作ストアのアクションを取り出す
    const { setSelectedStaff } = useDispatch( staffStore );

    const isSelected = selectedStaffId === staffId;

    return (
        <div { ...useBlockProps() }>
            <Button
                variant={ isSelected ? 'primary' : 'secondary' }
                onClick={ () => setSelectedStaff( staffId ) }
            >
                { isSelected ? '選択中' : 'このスタッフを選ぶ' }
            </Button>
        </div>
    );
}

このブロックをエディターに複数置くと、片方のボタンを押した瞬間にもう片方の表示も切り替わります。state は1つしかなく、useSelect を使っているコンポーネントはすべて同じ state を見ているからです。別の種類のブロックから getSelectedStaffId() を呼んでも同じ値が取れます。

引数のあるセレクターと依存配列

セレクターは state のあとに任意の引数を取れます。一覧から1件を探す getStaff を足してみます。

src/store/selectors.js(追記)
export function getStaff( state, staffId ) {
    return state.staffList.find( ( item ) => item.id === staffId );
}

呼ぶときは state を飛ばして select( staffStore ).getStaff( staffId ) です。このとき、useSelect の第2引数(依存配列)に staffId を入れるのを忘れないでください。依存配列は「この値が変わったらコールバックを作り直す」という指示で、空配列のままだと最初の staffId を閉じ込めたまま古い結果を返し続けます。

src/edit.js(抜粋)
const staff = useSelect(
    ( select ) => select( staffStore ).getStaff( staffId ),
    [ staffId ] // 外側の値を使うときは依存配列に入れる
);

コンポーネントの外から使う select と dispatch

フックはコンポーネントの中でしか使えません。コマンドの登録処理や、非同期処理の途中など React の外で値がほしいときは、@wordpress/data から直接 select と dispatch を import します。

使う場所読み取り更新
React コンポーネントの中useSelectuseDispatch
それ以外(イベント処理・ユーティリティなど)selectdispatch
任意の JS ファイル
import { select, dispatch } from '@wordpress/data';
import { store as staffStore } from './store';

// そのときの値を1回読むだけ(変化は監視しない)
const currentId = select( staffStore ).getSelectedStaffId();

// 更新する
dispatch( staffStore ).setSelectedStaff( 12 );

select はその瞬間の値を返すだけで、state が変わっても何も起きません。画面の表示に使う値は必ず useSelect 側で取ってください。

REST API のデータを1回だけ取って共有する

ここからが自作ストアのうまみです。staffList をカスタム投稿タイプの REST エンドポイントから取ってきて、どのブロックからも同じ配列を参照できるようにします。使うのは controls と resolvers の2つです。

controls:副作用の実体を書く

アクションクリエーターは「伝票を作るだけ」の関数なので、通信をその中に書くわけにはいきません。そこで、通信したいという意思表示のアクション(ここでは FETCH_FROM_API)を用意し、その type と同じ名前で実際の処理を controls に書きます。

src/store/actions.js(追記)
export function fetchFromAPI( path ) {
    return {
        type: 'FETCH_FROM_API',
        path,
    };
}
src/store/controls.js
import apiFetch from '@wordpress/api-fetch';

export const controls = {
    // アクションの type と同じ名前にする
    FETCH_FROM_API( action ) {
        // 戻り値が Promise なら、解決された値が呼び出し元に返る
        return apiFetch( { path: action.path } );
    },
};

コントロールの関数は、対応するアクションを受け取って値または Promise を返します。通信やタイマーといった「外の世界に触る処理」をここに閉じ込めておくと、reducer とアクションは純粋な関数のままでいられます。

resolvers:セレクターが呼ばれたら取りにいく

リゾルバーは、セレクターと同じ名前で定義する「そのデータが必要になったときに1度だけ走る処理」です。getStaffList というセレクターが初めて呼ばれると、同名のリゾルバー getStaffList が自動的に動きます。ジェネレーター関数(function*)で書き、yield でコントロール用のアクションを投げると、コントロールの結果が yield の戻り値として返ってきます。最後に返したアクションはそのまま dispatch されます。

src/store/resolvers.js
import { fetchFromAPI, receiveStaffList } from './actions';

// セレクター getStaffList と同じ名前にする
export function* getStaffList() {
    const items = yield fetchFromAPI( '/wp/v2/staff' );

    // 返したアクションが dispatch され、reducer が state に入れる
    return receiveStaffList( items );
}

リゾルバーはセレクターと同じ引数を受け取ります(state は含みません)。引数の組み合わせごとに1回だけ実行されるので、同じ引数で何度セレクターを呼んでも通信は増えません。作った controls と resolvers を createReduxStore の設定に足します。

src/store/index.js
import { createReduxStore, register } from '@wordpress/data';

import reducer from './reducer';
import * as actions from './actions';
import * as selectors from './selectors';
import * as resolvers from './resolvers';
import { controls } from './controls';

export const STORE_NAME = 'my-plugin/staff';

export const store = createReduxStore( STORE_NAME, {
    reducer,
    actions,
    selectors,
    controls,
    resolvers,
} );

register( store );

読み込み中の表示は解決状態から取る

リゾルバーが走っている間、セレクターは state の初期値(ここでは空配列)を返します。「まだ来ていない」のか「0件だった」のかを見分けるために、データモジュールはすべてのストアに解決状態を調べるセレクターを自動で足してくれます。hasFinishedResolution( セレクター名, 引数の配列 ) と isResolving( セレクター名, 引数の配列 ) が代表的なものです。

src/edit.js
import { useBlockProps } from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
import { Spinner } from '@wordpress/components';

import { store as staffStore } from './store';

export default function Edit() {
    const blockProps = useBlockProps();

    const { staffList, hasLoaded } = useSelect( ( select ) => {
        const { getStaffList, hasFinishedResolution } = select( staffStore );

        return {
            // この呼び出しがリゾルバーの実行のきっかけになる
            staffList: getStaffList(),
            hasLoaded: hasFinishedResolution( 'getStaffList', [] ),
        };
    }, [] );

    if ( ! hasLoaded ) {
        return (
            <div { ...blockProps }>
                <Spinner />
            </div>
        );
    }

    return (
        <div { ...blockProps }>
            <ul>
                { staffList.map( ( staff ) => (
                    <li key={ staff.id }>{ staff.title.rendered }</li>
                ) ) }
            </ul>
        </div>
    );
}

第2引数の配列には、セレクターに渡した引数をそのまま並べます。getStaffList() のように引数なしで呼んだなら空配列です。getStaff( 12 ) の状態を知りたいなら hasFinishedResolution( 'getStaff', [ 12 ] ) になります。

このブロックをエディターに何個置いても、/wp/v2/staff へのリクエストは1回だけです。2個目以降は state にたまった配列をそのまま読むので、待ち時間もありません。これが「取得処理を edit の中に書かない」ことの効果です。

ストアが反応しないときに見る場所

データストアは登場人物が多いぶん、どこで止まっているのか分かりにくくなりがちです。つまずきやすい場所は、だいたい次の4つに絞れます。

register が実行されていない

select( staffStore ) が undefined を返したり、セレクターを呼んだところで「関数ではない」というエラーになったりする場合、まず疑うのは登録忘れです。store/index.js を書いただけでは何も起きません。ブロックの src/index.js などから import './store'; して、そのファイルが実際にバンドルに含まれている必要があります。

確認はブラウザーのコンソールが手軽です。エディターの画面で wp.data.select( 'my-plugin/staff' ) を実行し、セレクターの一覧が入ったオブジェクトが返ればストアは登録できています。undefined なら import 漏れか、スクリプトそのものが読み込まれていません。

reducer で state を書き換えてしまっている

アクションは届いていて値も変わっているのに、画面が再レンダリングされない。これはたいてい reducer の中で元の state を直接いじっているケースです。state.staffList.push( item ) や state.selectedStaffId = 12 と書くと、返ってくるオブジェクトは前と同一の参照のままです。データモジュールは参照が変わったかどうかで変化を判断しているので、同じ参照だと「何も起きていない」とみなされます。

必ずスプレッド構文などで新しいオブジェクト・新しい配列を作って返してください。配列に足すなら [ ...state.items, item ]、消すなら state.items.filter( ... ) のように、元の配列を変更しないメソッドを使います。

セレクターの中で毎回オブジェクトを作っている

逆に、何も操作していないのに再レンダリングが止まらないときは、useSelect のコールバックやセレクターの中で map や filter、オブジェクトリテラルを使っていないか見てください。呼ばれるたびに中身の同じ新しい配列ができるため、比較が常に「変わった」と判定されます。

対処としては、セレクターは state に入っている値をそのまま返すだけにして、加工は受け取ったコンポーネント側で行うのが分かりやすい方法です。加工結果自体を共有したいなら、加工済みの形で state に入れておく手もあります。

リゾルバーが1回しか走らないことを忘れている

リゾルバーは同じ引数につき1回だけの実行です。裏側でデータが更新されても、勝手に取り直してはくれません。あとから追加したスタッフを反映させたいときは、dispatch( staffStore ).invalidateResolution( 'getStaffList', [] ) のように解決済みの記録を無効化して、次にセレクターが呼ばれたときに再実行させます。これもデータモジュールがすべてのストアに用意しているアクションです。

まとめ

自作のデータストアは、reducer・actions・selectors の3つを書いて createReduxStore に渡し、register で登録するだけで作れます。登録したあとは useSelect と useDispatch に自分のストアオブジェクトを渡せばよく、コアのストアを使うときと書き方は変わりません。ストアを登録するファイルをブロックのエントリーから import し忘れないことだけ気をつけてください。

REST API のデータを扱うなら、通信の実体を controls に、取得のきっかけを resolvers に置きます。セレクターが呼ばれたときにだけ、しかも引数ごとに1回だけ走るので、ブロックをいくつ置いてもリクエストは増えません。読み込み中の表示は hasFinishedResolution で判断できます。

すべての状態をストアに入れる必要はありません。投稿に保存する値は attributes、そのコンポーネントだけで完結する値は useState のままで十分です。ブロックの垣根を越えて共有したい値と、何度も取り直したくないデータ。この2つに絞って使うと、ストアはとても扱いやすい道具になります。

参考ページ