WordPress Logo WordPress Interactivity API で作るカテゴリー投稿フィルターブロック

WordPress 6.5から導入された新機能「Interactivity API」を使って、カテゴリーごとに投稿を絞り込めるインタラクティブな投稿フィルターブロックを作ってみました。PHP と JavaScript を組み合わせるだけで、プラグインやテーマに簡単に組み込める機能を実装できます。

以下では実際のコードと共に作り方を解説します。

Interactivity API の基本的な使い方については「初めての Interactivity API」を御覧ください。

更新日:2025年08月16日

作成日:2025年08月15日

カテゴリー選択で投稿一覧を切り替えるブロックを作る

Interactivity API を使うと、React や Vue のようなリアクティブ UI を、PHP テンプレートと JS だけで比較的簡単に実装できます。

今回は、この API を利用して「セレクトメニューでカテゴリーを選択すると、そのカテゴリーの投稿一覧を非同期で切り替えるブロック」を作ってみます。

ブロックエディターの設定画面から初期表示カテゴリーやセレクトメニューに表示するカテゴリー、表示項目などのオプションを設定できるようにし、フロントエンドでは Interactivity API を使って動的に投稿を取得・表示する仕組みを実装していきます。

完成すると、次のようなことができるようになります。

  • カテゴリー選択メニューから投稿を動的に切り替え
  • ローディングスピナー&エラー表示あり
  • 初期カテゴリーやセレクトメニューに表示するカテゴリーなどの設定が可能

完成イメージはこんな感じです。ブロックを挿入すると、サイドバーでオプションを設定できます(投稿一覧のプレビューは表示されません)。

フロント側では、セレクトメニューからカテゴリーを選択すると、Ajax的に投稿一覧が切り替わります。

関連ページ:

プロジェクト準備

必要環境

作成するブロックは WordPress 6.5 以降のバージョンでのみ動作し、Node.js と npm、およびローカル開発環境 (WP ENV や MAMP など) が必要になります。

  • WordPress 6.5+
  • Node.js / npm
  • ローカル開発環境

ブロックのひな形の作成

create-block コマンドを使ってブロックの初期構成(ひな形)を作成します。

ターミナルなどでプラグインディレクトリ(wp-content/plugins)に移動して以下を実行します。

% npx @wordpress/create-block@latest my-category-posts --template @wordpress/create-block-interactive-template --namespace my-theme

プラグイン名(slug)や名前空間(--namespace)は必要に応じて適宜変更してください。

  • slug に my-category-posts を指定しているので、プラグインのフォルダ名は my-category-posts、プラグイン名は My Category Post となります。
  • --template にインタラクティブブロックのテンプレート(@wordpress/create-block-interactive-template)を指定します。
  • --namespace に my-theme を指定しているので、名前空間が my-theme になります。--namespace を省略すると、名前空間は create-block になります。

上記コマンドを実行すると、以下のようなディレクトリがプラグインディレクトリに作成されます。

基本的には src ディレクトリ内のファイルを編集します。必要に応じてプラグインファイル my-category-posts.php を編集しますが、この例では使用しません。

build ディレクトリ内のファイルは使用しません(コンパイル時に上書きされます)。

開発プロセスを開始

ひな型が作成できたら、以下のコマンドを実行して、新しく作成したプラグインフォルダーに移動し、npm start で開発プロセスを開始します。

% cd my-category-posts && npm start

プラグインを有効化

作成したプラグイン My Category Post をエディターで使用できるように、管理画面で有効化します。

ブロックを挿入

任意の投稿に作成したブロック My Category Post を挿入します。

エディター側には、テンプレート(ひな型)の初期設定のメッセージが表示されます。投稿を保存します。

ブロックの基本構造

以下のファイルでブロックの基本構造を作成します。

  • block.json:属性(attributes)を定義
  • edit.js:コントロールを追加
  • render.php:PHP で初期投稿一覧を取得し、それを JavaScript 側で使える 「コンテキスト(context)」 として HTML に埋め込みます。

block.json

block.json に、以下の attributes(属性)を定義します。

attributes(属性) 説明 デフォルト値
initialCategories 初期状態で一覧表示する投稿の属するカテゴリー [](空の配列→全てのカテゴリー)
categories セレクトメニューに表示するカテゴリー [](空の配列→全てのカテゴリー)
showLabelForAll セレクトメニューに「すべて」を表示するかどうか true
postsPerPage 表示する投稿件数 10
fields 表示する項目(リンク、投稿日、抜粋) ["link", "date", "excerpt"]
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "my-theme/my-category-posts",
  "version": "0.1.0",
  "title": "My Category Posts",
  "category": "widgets",
  "icon": "media-interactive",
  "description": "An interactive block with the Interactivity API.",
  "example": {},
  "supports": {
    "interactivity": true
  },
  "attributes": {
    "initialCategories": {
      "type": "array",
      "default": []
    },
    "categories": {
      "type": "array",
      "default": []
    },
    "showLabelForAll": {
      "type": "boolean",
      "default": true
    },
    "postsPerPage": {
      "type": "number",
      "default": 10
    },
    "fields": {
      "type": "array",
      "default": ["link", "date", "excerpt"]
    }
  },
  "textdomain": "my-category-posts",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php",
  "viewScriptModule": "file:./view.js"
}

Interactivity API 特有の設定

  • supports プロパティで、interactivity が true に設定(Interactivity API を利用)。
  • render プロパティに render.php が設定。Interactivity API を使う場合、必ずしもダイナミックブロックである必要はありませんが、ダイナミックブロックとして作成されることが一般的です。
  • viewScriptModule プロパティに view.js が設定(JavaScript モジュール)

edit.js

編集画面では、属性(attributes)で定義した値を設定するためのコントロール(SelectControl など)を追加して、サイドバーでセレクトボックスやチェックボックスを使って選択可能にします。

edit.js を以下のように書き換えます。

import { useBlockProps } from "@wordpress/block-editor";
import { __ } from "@wordpress/i18n";
import { useSelect } from "@wordpress/data";
import { useEffect } from '@wordpress/element';
import { InspectorControls } from "@wordpress/block-editor";
import { PanelBody, SelectControl, CheckboxControl, RangeControl, } from "@wordpress/components";

export default function Edit({ attributes, setAttributes }) {
  // 属性を分割代入で取得して変数に代入
  const { categories, initialCategories, showLabelForAll, postsPerPage, fields,} = attributes;

  // すべてのカテゴリーを取得
  const allCategories = useSelect(
    (select) =>
      select("core").getEntityRecords("taxonomy", "category", { per_page: -1 }),
    []
  );

  // 初期化処理:全カテゴリーを選択状態にする
  useEffect(() => {
    if (allCategories && allCategories.length > 0) {
      const allIds = allCategories.map((cat) => cat.id);
      if (!initialCategories?.length) {
        setAttributes({ initialCategories: allIds });
      }
      if (!categories?.length) {
        setAttributes({ categories: allIds });
      }
    }
  }, [allCategories]);

  // 表示項目のチェックボックスの onChange に指定する関数
  const toggleField = (field) => {
    setAttributes({
      fields: fields.includes(field)
        ? fields.filter((f) => f !== field)
        : [...fields, field],
    });
  };

  return (
    <>
      <InspectorControls>
        <PanelBody title={__("設定", "my-category-posts")}>
          <SelectControl
            multiple
            label={__("初期カテゴリー", "my-category-posts")}
            value={Array.isArray(initialCategories) ? initialCategories : []}
            options={(allCategories || []).map((cat) => ({
              label: cat.name,
              value: cat.id,
            }))}
            onChange={(newCats) =>
              setAttributes({ initialCategories: (newCats || []).map(Number) })
            }
            __next40pxDefaultSize
            __nextHasNoMarginBottom
          />
          <SelectControl
            multiple
            label={__("セレクトメニューに表示するカテゴリー", "my-category-posts")}
            value={Array.isArray(categories) ? categories : []}
            options={(allCategories || []).map((cat) => ({
              label: cat.name,
              value: cat.id,
            }))}
            onChange={(newCats) =>
              setAttributes({ categories: (newCats || []).map(Number) })
            }
            __next40pxDefaultSize
            __nextHasNoMarginBottom
          />
          <CheckboxControl
            __nextHasNoMarginBottom
            label="「すべて」を表示"
            checked={showLabelForAll}
            onChange={(checked) => {
              setAttributes({ showLabelForAll: checked });
            }}
          />
          <RangeControl
            __nextHasNoMarginBottom
            __next40pxDefaultSize
            label={__("表示件数", "my-category-posts")}
            value={postsPerPage}
            onChange={(value) => setAttributes({ postsPerPage: value })}
            min={1}
            max={20}
          />
          <p>{__("表示項目", "my-category-posts")}</p>
          <CheckboxControl
            __nextHasNoMarginBottom
            label="リンク"
            checked={fields.includes("link")}
            onChange={() => toggleField("link")}
          />
          <CheckboxControl
            __nextHasNoMarginBottom
            label="日付"
            checked={fields.includes("date")}
            onChange={() => toggleField("date")}
          />
          <CheckboxControl
            __nextHasNoMarginBottom
            label="抜粋"
            checked={fields.includes("excerpt")}
            onChange={() => toggleField("excerpt")}
          />
        </PanelBody>
      </InspectorControls>
      <div {...useBlockProps()}>
        {__("カテゴリー別投稿一覧(プレビューは表示されません)", "my-category-posts")}
      </div>
    </>
  );
}

使用しているコントロールのコンポーネント

  • InspectorControls: ブロックのサイドバーにカスタムコントロールを追加するためのコンテナ
  • PanelBody: 折りたたみ可能なセクションを作成するためのコンテナ
  • SelectControl: セレクトボックスを提供するコントロール
  • CheckboxControl: 複数選択可能なチェックボックスを提供するコントロール
  • RangeControl: スライダーで数値範囲を選択するコントロール
全カテゴリーの取得

以下はすべてのカテゴリーを取得するコードです。

const allCategories = useSelect(
  (select) =>
    select("core").getEntityRecords("taxonomy", "category", { per_page: -1 }),
  []
);
  • useSelect
    • useSelect は Gutenberg の React Hooks の1つで、WordPress データストア(@wordpress/data)から情報を取得できます。
  • select("core").getEntityRecords()
    • select() 関数でコアストア(core)のセレクター getEntityRecords() を呼び出しています。
    • getEntityRecords() は WordPress REST API 経由でエンティティ(投稿、カテゴリー、タグなど)を取得する関数で、以下の引数を受取ます。
    • 第1引数 "taxonomy" → 取得対象のタイプ(分類)
    • 第2引数 "category" → 取得対象の分類名
    • 第3引数 { per_page: -1 } → 全件取得(ページ分割しない)
  • 戻り値
    • カテゴリーオブジェクトの配列([{ id, name, slug, ... }, ...])になります。
    • 取得中は null が返ることもあるので、後で SelectControl の options に指定する際に、(allCategories || []) として安全に扱っています。
全カテゴリーを選択状態にする

Gutenberg の SelectControl では、HTML の <select> のように各 <option> に selected 属性を直接付けるのではなく、value に「選択されている値の配列」を渡すことで選択状態を反映します。

そのため、初期表示で全カテゴリーを選択状態に見せたい場合は、allCategories が取得できたタイミングで、属性の initialCategories(初期カテゴリー)や categories(セレクトメニューに表示するカテゴリー)に全カテゴリー ID の配列 を代入します。

useEffect(() => {
  // allCategories の取得完了を監視し、初期選択を設定
  if (allCategories && allCategories.length > 0) {
    const allIds = allCategories.map((cat) => cat.id);

    // 初期カテゴリーが未設定または空配列の場合、全カテゴリーをセット
    if (!initialCategories?.length) {
      setAttributes({ initialCategories: allIds });
    }

    // 表示カテゴリーが未設定または空配列の場合、全カテゴリーをセット
    if (!categories?.length) {
      setAttributes({ categories: allIds });
    }
  }
}, [allCategories]);
  • getEntityRecords(カテゴリー取得)は非同期のため、useEffect(React Hooks)で allCategories の変化を監視して初期化する必要があります。
  • ?.length を使うと null / undefined チェックと空配列チェックをまとめられます。
    • ?.(オプショナルチェーン)は、initialCategories が null や undefined だった場合でもエラーにならずに undefined を返し、もし配列なら .length で要素数を返します。
    • if (!initialCategories || initialCategories.length === 0) と同じですが、簡潔に記述できます。
  • この処理により、エディター読み込み時点で UI 上も「全てのカテゴリー」が選択状態になります。
セレクトボックスの作成

以下は SelectControl を使ったセレクトボックスの設定です。

<SelectControl
  multiple
  label={__("初期カテゴリー", "my-category-posts")}
  value={ Array.isArray(initialCategories) ? initialCategories : [] }
  options={(allCategories || []).map((cat) => ({
    label: cat.name,
    value: cat.id,
  }))}
  onChange={(newCats) =>
    setAttributes({ initialCategories: (newCats || []).map(Number) })
  }
  __next40pxDefaultSize
  __nextHasNoMarginBottom
/>
  • multiple
    • 複数選択を可能にする指定。true にすると、value も配列で指定する必要がある。
  • label
    • フィールドラベル。__() 関数で翻訳テキストを登録し、多言語対応。
  • value
    • 選択中のカテゴリー ID 配列(ブロック属性)を指定。
    • multiple のため必ず配列が必要なので、Array.isArray(initialCategories) ? initialCategories : [] で null や undefined を回避し、常に配列を渡すようにしている。
  • options
    • セレクトボックスの選択肢一覧。
    • (allCategories || []) で null 回避後、map() で {label: カテゴリー名, value: カテゴリーID} の形式に変換。
  • onChange
    • 選択変更時の処理。
    • newCats : 選択された値の文字列配列(SelectControl の仕様)
    • (newCats || []).map(Number) で空選択や null でも安全に数値配列へ変換し、setAttributes() で属性を更新。value.map(Number) は value.map(x => Number(x)) と同じこと。
  • __next40pxDefaultSize / __nextHasNoMarginBottom
    • Gutenberg UI の見た目調整用の props。将来のデフォルト変更に備えて明示的に設定。

以下のような流れになります。

  • useSelect で全カテゴリーの一覧を取得
  • 取得結果を options に変換して <SelectControl> に渡す
  • ユーザーがカテゴリーを選択すると setAttributes() でブロック属性を更新
  • この initialCategories は初期表示のフィルター条件として利用します。

「セレクトメニューに表示するカテゴリー」のセレクトボックスも同様です。

チェックボックスで切り替える

以下は「表示する項目」をユーザーがチェックボックスで切り替えるための関数で、CheckboxControl の onChange に指定します。

  • 引数 field:切り替えたい項目の識別子(例: "link", "date", "excerpt")。
  • fields:現在ブロックで表示設定されている項目の配列(ブロック属性)。
  • setAttributes():ブロックの属性を更新する関数。
const toggleField = (field) => {
  setAttributes({
    // 現在の配列に field が含まれているか確認
    fields: fields.includes(field)
      ? fields.filter((f) => f !== field)
      : [...fields, field],
  });
};

現在の配列に引数に受け取った field が含まれているか確認し、

  • 含まれている(true の)場合は、fields.filter((f) => f !== field) で配列から field を除外した新しい配列を返し、fields 属性にセットします(fields 属性を更新します)。
  • 含まれている(false の)場合は、[...fields, field] で既存の配列に field を追加した新しい配列を fields 属性にセットします。

そして、例えば「リンク」を表示するかどうかを切り替える以下のチェックボックスの場合、ユーザーがチェックを入れる/外すたびに onChange に指定した上記関数が呼び出され、fields 配列の中身がトグル的に更新されます。

<CheckboxControl
  __nextHasNoMarginBottom
  label="リンク"
  checked={fields.includes("link")}
  onChange={() => toggleField("link")}
/>
編集画面での出力

この例では、以下の JSX を返して、編集画面ではテキストだけを出力しています(必要であれば、編集画面でもダミーの投稿一覧を表示することもできます)。

<div {...useBlockProps()}>
  {__("カテゴリー別投稿一覧(プレビューは表示されません)", "my-category-posts")}
</div>

useBlockProps() は、Gutenberg がブロック用に必要なクラスや属性(例: class="wp-block-xxx")を自動で付与するための関数で、{...useBlockProps()} のようにスプレッド構文で指定して、返ってきたオブジェクトを <div> の属性として展開します。

これにより、ブロックが他のブロックと同じようにエディター上で正しく選択や削除したり、スタイルを適用できるようになります。

__() は WordPress の国際化関数(wp.i18n)で、文字列を翻訳可能にします。第一引数が翻訳元のテキスト、第二引数がテキストドメイン(block.json の textdomain プロパティの値)です。

エディター画面

edit.js を上記のように書き換えると、ブロックを挿入したエディター画面は以下のように表示されます。

render.php

render.php を以下のように書き換えます。

この render.php は、WordPress の Interactivity API を使った「カテゴリー投稿フィルターブロック」のフロントエンド描画処理を行うテンプレートファイルです。

ブロックがページに表示されるときに PHP で初期データを取得し、それを JavaScript 側で使える 「コンテキスト(context)」 として HTML に埋め込みます。

<?php

// 初期表示用に取得するカテゴリーIDの配列を決定
// ブロック属性 "initialCategories" に値があればその配列を使用、なければ空配列
$categories_for_query = !empty($attributes['initialCategories']) ? $attributes['initialCategories'] : [];

// 1ページあたりの表示件数(安全策として、$attributes 未定義時は10件をデフォルト値に)
$posts_per_page = isset($attributes['postsPerPage']) ? (int) $attributes['postsPerPage'] : 10;

// 投稿取得用のクエリパラメータを設定
// numberposts: 表示件数(デフォルト10件)
// category__in: 初期表示カテゴリーID配列
// post_status: 公開済み投稿のみ
$args = [
  'numberposts' => $posts_per_page,
  'category__in'  => $categories_for_query,
  'post_status' => 'publish',
];

// 投稿データを取得(配列形式で返される)
$posts = get_posts($args);

// カテゴリー取得用のクエリパラメータを設定
// hide_empty: 投稿がないカテゴリーも含める
// include: 指定がある場合、そのカテゴリーIDのみ取得
$cat_args = ['hide_empty' => false];
if (!empty($attributes['categories'])) {
  $cat_args['include'] = $attributes['categories'];
}

// カテゴリーデータを取得
$categories = get_categories($cat_args);

// Interactivity API用のコンテキストデータを生成
// JavaScript側からアクセスできる初期状態や設定を含める
$my_context = wp_interactivity_data_wp_context([
  // 投稿リスト(必要な情報のみ整形)
  "posts" => array_map(function ($p) {
    return [
      "id"      => $p->ID,
      "link"    => get_permalink($p),
      "title"   => get_the_title($p),
      "date"    => get_the_date('', $p),
      "excerpt" => get_the_excerpt($p),
    ];
  }, $posts),

  // 「すべて」オプションを表示するか
  "showLabelForAll" => (bool) ($attributes["showLabelForAll"] ?? true),

  // 選択中カテゴリー(初期値は0=未選択)
  "selectedCategory" => 0,

  // 1ページあたりの表示件数
  "postsPerPage" => $posts_per_page,

  // 表示項目(リンク・日付・抜粋など)
  "fields" => $attributes["fields"] ?? ["link", "date", "excerpt"],

  // ローディング状態(初期はfalse)
  "loading" => false,

  // エラーメッセージ(初期はnull)
  "error" => null,

  // REST APIのルートURL(サブディレクトリ対応のため)
  'restRoot' => rest_url(),
]);
?>

<div
  <?php echo get_block_wrapper_attributes(); ?>
  data-wp-interactive="my-theme"
  <?php echo $my_context; ?>>

  <!-- カテゴリー選択ドロップダウン -->
  <select data-wp-on--change="actions.changeCategory">
    <!-- 初期表示用の案内文 -->
    <option value="" disabled selected>
      <?php esc_html_e('カテゴリーを選択', 'my-category-posts'); ?>
    </option>

    <!-- 「すべて」オプション(showLabelForAll が true の場合のみ) -->
    <?php if ($attributes['showLabelForAll']) : ?>
      <option value="0"><?php esc_html_e('すべて', 'my-category-posts'); ?></option>
    <?php endif; ?>

    <!-- カテゴリー一覧をループして選択肢に追加 -->
    <?php foreach ($categories as $cat): ?>
      <option value="<?php echo esc_attr($cat->term_id); ?>">
        <?php echo esc_html($cat->name); ?>
      </option>
    <?php endforeach; ?>
  </select>

  <!-- ローディング表示(context.loading が true の場合のみ表示) -->
  <div data-wp-bind--hidden="!context.loading" class="my-theme-loading-spinner" aria-live="polite" role="status">
    <span class="spinner"></span> <?php esc_html_e('読み込み中...', 'my-category-posts'); ?>
  </div>

  <!-- エラー表示(context.error がセットされている場合のみ) -->
  <div data-wp-bind--hidden="!context.error" style="color: red;">
    <span data-wp-text="context.error"></span>
  </div>

  <!-- 投稿リスト -->
  <ul>
    <!-- context.posts の配列をループ表示 -->
    <template data-wp-each="context.posts">
      <li>
        <h3>
          <!-- リンク表示(state.linkSelected が true の場合のみ) -->
          <a
            data-wp-bind--hidden="!state.linkSelected"
            data-wp-bind--href="context.item.link"
            data-wp-text="context.item.title"></a>

          <!-- リンク非表示時はタイトルをテキストとして表示 -->
          <span
            data-wp-bind--hidden="state.linkSelected"
            data-wp-text="context.item.title"></span>
        </h3>

        <!-- 日付表示(state.dateSelected が true の場合のみ) -->
        <time data-wp-bind--hidden="!state.dateSelected" data-wp-text="context.item.date"></time>

        <!-- 抜粋表示(state.excerptSelected が true の場合のみ) -->
        <p data-wp-bind--hidden="!state.excerptSelected" data-wp-text="context.item.excerpt"></p>
      </li>
    </template>
  </ul>
</div>

このファイルの主な役割

  1. 初期表示用の投稿とカテゴリーを取得
    • $attributes['initialCategories'] に指定があれば、そのカテゴリーの投稿だけを取得。
    • なければ全カテゴリーから投稿を取得。
    • 投稿は get_posts() を使って取得し、件数は $attributes['postsPerPage'](デフォルト 10件)。
    • カテゴリーは get_categories() で取得。特定カテゴリーだけを表示する場合は include を使う。
  2. コンテキスト(data-wp-context)の生成
    • wp_interactivity_data_wp_context() で JavaScript 側に渡すデータ($my_context)を生成。
    • 含まれるデータ例:
      • 投稿情報(ID, リンク, タイトル, 投稿日, 抜粋)
      • セレクトメニューに「すべて」オプションを表示するか
      • 初期設定値(選択中カテゴリー、表示件数、フィールドの表示有無)
      • ローディング状態やエラーメッセージ用の変数
      • REST API のルート URL(restRoot)— サブディレクトリ対応のため(view.js で使用)
  3. HTMLの描画とディレクティブの設定
    • カテゴリー選択セレクトボックス
      • 初期状態は「カテゴリーを選択」を表示。
      • showAllCategories が有効なら「すべて」オプションも表示。
    • ローディング表示
      • context.loading が true の間だけスピナー+「読み込み中…」を表示。
    • エラー表示
      • context.error がセットされたときだけ赤文字でエラーメッセージを表示。
    • 投稿リスト
      • <template data-wp-each="context.posts"> を使って、JavaScript 側でループ描画。
      • タイトル・日付・抜粋は、state の設定に応じて表示/非表示を切り替え可能。

HTMLの描画部分では、WordPress Interactivity API の各種ディレクティブを用いて、要素の表示制御や動作を設定しています。以下はその詳細です。

Interactivity API の有効化とコンテキストの設定

ラッパーの <div> 要素には data-wp-interactive 属性(ディレクティブ)で名前空間(my-theme)を指定し、このブロックで Interactivity API を有効化します。

さらに、wp_interactivity_data_wp_context() 関数で生成したコンテキスト情報($my_context)を出力し、data-wp-context 属性にセットしています。

<div
  <?php echo get_block_wrapper_attributes(); ?>
  data-wp-interactive="my-theme"
  <?php echo $my_context; ?>>

また、get_block_wrapper_attributes() で取得したブロックのラッパー属性を出力してブロックに必要な属性を追加します(この例の場合、class="wp-block-my-theme-my-category-posts" が出力されます)。

上記によりフロントエンドには、例えば以下のようなマークアップが出力されます。

<!-- 見やすいように改行しています -->
<div
  class="wp-block-my-theme-my-category-posts"
  data-wp-interactive="my-theme"
  data-wp-context="{
    "posts":[
      {"id":490,"link":"http://localhost/wp-block/2025/07/31/donation-calculator/","title":"Donation Calculator","date":"2025\u5e747\u670831\u65e5","excerpt":""},
      {"id":487,"link":"http://localhost/wp-block/2025/07/28/interactivity-api-sample/","title":"Interactivity API Sample","date":"2025\u5e747\u670828\u65e5","excerpt":""},
      ...中略...
    ],
    "showLabelForAll":true,
    "selectedCategory":0,
    "postsPerPage":10,
    "fields":["link","excerpt","date"],
    "loading":false,
    "error":null,
    "restRoot":"http://localhost/wp-block/wp-json/"}">
イベントハンドラーの設定(wp-on)

<select> 要素には data-wp-on--change 属性を指定し、ユーザーがカテゴリーを選択すると view.js 内で定義した actions.changeCategory が呼び出されます(change イベントのハンドラーを設定)。

<select data-wp-on--change="actions.changeCategory">
状態に応じた表示制御(wp-bind)

data-wp-bind 属性は、コンテキストや状態(context や state の値)に応じて HTML 属性やテキストを動的に変更できます。ここでは、data-wp-bind--hidden を使って、要素の表示/非表示を切り替えています(例:ローディング表示やエラーメッセージ、表示項目の切り替えなど)。

動作の仕組み

data-wp-bind--hidden="ストアのプロパティへの参照"

  • → 式の評価結果が true の場合、その要素に hidden 属性を付与
  • → hidden 属性が付くと、ブラウザは自動的に display: none として非表示にする(CSS上書きがない場合)
<!-- エラー表示(context.error がセットされている場合のみ) -->
<div data-wp-bind--hidden="!context.error" style="color: red;">
  <span data-wp-text="context.error"></span>
</div>
  • 初期状態:context.error = null (wp_interactivity_data_wp_context で設定)
    • → !context.error は true
    • → hidden 属性が付き、非表示になる
  • エラー発生時:context.error = "投稿の取得に失敗しました。"(view.js のストアで定義)
    • → !context.error は false
    • → hidden 属性が削除され、要素が表示される

ポイント

  • data-wp-text="context.error" は context.error の値を要素内テキストに反映します。
  • data-wp-bind--hidden はシンプルな条件式で表示を制御できるため、ローディング表示やエラーメッセージの切替などに便利です。
  • CSSで display を明示的に指定している場合は、[hidden] { display: none !important; } のように補強が必要になる場合があります(ローディング表示の制御)。
繰り返し表示(wp-each)

投稿一覧の描画には、<template> 要素と data-wp-each ディレクティブを組み合わせています。

<template data-wp-each="context.posts">
  <li>
    <h3>
      <!-- リンク表示(state.linkSelected が true の場合のみ) -->
      <a
        data-wp-bind--hidden="!state.linkSelected"
        data-wp-bind--href="context.item.link"
        data-wp-text="context.item.title"></a>
      <!-- リンク非表示時はタイトルをテキストとして表示 -->
      <span
        data-wp-bind--hidden="state.linkSelected"
        data-wp-text="context.item.title"></span>
    </h3>
    <!-- 日付表示(state.dateSelected が true の場合のみ) -->
    <time data-wp-bind--hidden="!state.dateSelected" data-wp-text="context.item.date"></time>
    <!-- 抜粋表示(state.excerptSelected が true の場合のみ) -->
    <p data-wp-bind--hidden="!state.excerptSelected" data-wp-text="context.item.excerpt"></p>
  </li>
</template>
  • data-wp-each
    • context.posts 配列の各要素に対して繰り返し処理を行い、<template> 内の要素を複製して表示します。
    • 繰り返し処理中は、現在の要素を context.item として参照できます。
  • コンテキスト内の値のバインド
    • 例えば、data-wp-text="context.item.title" は現在の投稿タイトルを表示し、data-wp-bind--href="context.item.link" はコンテキストによりリンク先(href 属性)を設定します。
    • また、data-wp-bind--hidden="!state.linkSelected" のように、状態(state)によってリンクの表示・非表示を切り替えることもできます。

Interactivity API の導入

view.js は、WordPress の Interactivity API を利用して、フロントエンド側での動的な操作を可能にするスクリプトです。HTML 要素と JavaScript の状態(state)やデータ(context)を結びつけ、ページの再読み込みなしで UI を更新できるようになります。

view.js

view.js を以下のように書き換えます。

// WordPress Interactivity API から必要な関数をインポート
import { store, getContext } from "@wordpress/interactivity";

/**
 * 日付フォーマッター
 * REST API から取得した日付 (ISO8601形式) を日本語の年月日形式に変換する
 * 例: 2025-08-13T12:00:00 → 2025年8月13日
 */
const dateFormatter = new Intl.DateTimeFormat("ja-JP", {
  year: "numeric",
  month: "long",
  day: "numeric",
});

/**
 * store() 関数で名前空間「my-theme」のストアを作成
 * state(派生ステート)と actions(ユーザー操作時の処理)を定義
 */
store("my-theme", {
  /**
   * state: UI の表示切り替えに利用する計算用の状態(getter で派生ステートを定義)
   * 実際のデータは context から取得し、その結果を返すだけの役割
   */
  state: {
    // 投稿リンクを表示するかどうか
    get linkSelected() {
      const context = getContext();
      return context.fields.includes("link");
    },
    // 投稿日時を表示するかどうか
    get dateSelected() {
      const context = getContext();
      return context.fields.includes("date");
    },
    // 抜粋を表示するかどうか
    get excerptSelected() {
      const context = getContext();
      return context.fields.includes("excerpt");
    },
  },

  /**
   * actions: ユーザー操作(イベント)に応じてデータや状態を変更する処理
   */
  actions: {
    /**
     * カテゴリー選択変更時の処理
     * - 選択カテゴリーIDを context に保存
     * - REST API を呼び出して該当する投稿一覧を取得
     * - エラーやローディング状態も context に反映
     *
     * ジェネレーター関数(function*)を使用し、非同期処理を yield で記述
     */
    *changeCategory(event) {
      const context = getContext();

      try {
        // 選択されたカテゴリーIDを取得
        const value = event.target.value;
        const categoryId = value === "" ? 0 : parseInt(value, 10);

        // 状態を更新
        context.selectedCategory = categoryId; // 現在選択中のカテゴリ
        context.error = null; // エラー初期化
        context.loading = true; // ローディング開始

        // REST API のベースURL(context.restRoot が未定義なら現在サイト直下の /wp-json/ を使用)
        const restRoot = context.restRoot || `${window.location.origin}/wp-json/`;
        const url = new URL("wp/v2/posts", restRoot);

        // カテゴリーIDと取得件数をクエリパラメータに設定
        if (categoryId) {
          url.searchParams.set("categories", categoryId);
        }
        url.searchParams.set("per_page", context.postsPerPage);

        // REST API 呼び出し(ジェネレーター関数なので async/await ではなく yield を使用)
        // yield を使うことで Interactivity API のランタイムが処理の進行と状態更新を管理できる
        const res = yield fetch(url);  // ← async/await の代わりに yield
        if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);

        // JSONデータを取得
        const data = yield res.json();  // ← async/await の代わりに yield

        // 投稿データを整形して context にセット
        context.posts = data.map((post) => ({
          id: post.id,
          link: post.link,
          title: post.title.rendered,
          date: dateFormatter.format(new Date(post.date)), // 日付フォーマット適用
          excerpt: post.excerpt.rendered.replace(/<[^>]+>/g, ""), // HTMLタグ除去
        }));
      } catch (err) {
        // エラー時の処理
        console.error("Failed to change category:", err);
        context.error = "投稿の取得に失敗しました。";
        context.posts = []; // エラー時は空配列に
      } finally {
        // ローディング終了
        context.loading = false;
      }
    },
  },
});
ストアの定義

ストアを定義する store() 関数の第一引数には名前空間(ここでは "my-theme")を指定し、第二引数に 状態(state) と アクション(actions) を定義します。

store("my-theme", {
  state: { ... },
  actions: { ... },
});

この名前空間は render.php 側で data-wp-interactive="my-theme" と指定することで紐づきます。

state の定義

state は Interactivity API の store() 内で定義する「計算可能なリアクティブ変数」で、ページ内の任意の HTML ノードから参照できます。

値はリアクティブに変化し、context や他の state の状態に基づいて動的に計算されます。

派生ステートの例

以下では、get linkSelected() のようにゲッター関数を使い、context.fields に特定の項目(例: "link")が含まれているかを判定しています。

これにより、HTML 側で要素の表示・非表示を切り替えられます。

以下の linkSelected や dateSelected は、context.fields に基づいて真偽値を返す「派生ステート」です。

state: {
  // 投稿リンクを表示するかどうか
  get linkSelected() {
    const context = getContext();
    return context.fields.includes("link");
  },
  // 投稿日時を表示するかどうか
  get dateSelected() {
    const context = getContext();
    return context.fields.includes("date");
  },
  ...
},

context とは

context は、特定の DOM 要素(data-wp-context 属性を持つ要素)に紐づくローカルな状態や情報です。

その要素および子要素からアクセスでき、JavaScript 側では getContext() 関数で取得します。

PHP 側では、render.php などで wp_interactivity_data_wp_context() 関数を使い、初期データを data-wp-context 属性として出力します。

こうして HTML レンダリング時から JavaScript 側と同じデータを共有できます。

なぜゲッターごとに getContext() を呼ぶのか

  • state のゲッターは、異なる DOM 要素や異なるタイミングで評価されます。
  • その時点・その要素に対応した正しい context を取得する必要があるため、毎回 getContext() を呼び出します。
カテゴリー変更時のアクション(非同期データ取得)

ユーザーがカテゴリーを変更すると、actions.changeCategory が呼び出されます。

WordPress の公式ドキュメントでは、Interactivity API での非同期アクションに対し、async/await ではなくジェネレーター関数(function*)を用いることを推奨しています。

その理由は、async/await による非同期処理では、関数のスコープ(context)が失われたり他の操作と衝突したりする可能性があるからです。

ジェネレーター関数と yield によって非同期処理を実装すると、Interactivity API が実行の開始と再開を管理し、常に正しい context のもとで処理を続行できるため、状態管理が信頼性高く行えます。

以下の関数 changeCategory はジェネレーターとして定義し、非同期処理は yield を使って記述します。

// ジェネレーター関数(function*)として定義
*changeCategory(event) {
  const context = getContext();

  try {
    // 選択されたカテゴリーIDを取得
    const value = event.target.value;
    const categoryId = value === "" ? 0 : parseInt(value, 10);

    ・・・中略・・・

    // REST API 呼び出し(async/await ではなく yield を使用)
    // yield を使うことで Interactivity API のランタイムが処理の進行と状態更新を管理できる
    const res = yield fetch(url);
    if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);

    // JSONデータを取得(await の代わりに yield)
    const data = yield res.json();

    ・・・中略・・・

  }
},

ポイント

  • function* と yield を使うことで、Interactivity API の内部ランタイムが処理の進行を管理し、状態更新を安全にリアクティブ反映できる。
  • async/await を使うと、Interactivity API の再レンダリングや状態管理が正しく動作しない場合があるため、公式に推奨されていない(非同期アクション)。
投稿データの取得(REST API 利用)

カテゴリーが選択されると、WordPress の REST API (/wp-json/wp/v2/posts) を呼び出して、該当カテゴリーの投稿一覧を取得します。

// REST API のベースURL(未定義なら現在サイト直下の /wp-json/ を使用)
const restRoot = context.restRoot || `${window.location.origin}/wp-json/`;
const url = new URL("wp/v2/posts", restRoot);

if (categoryId) {
  url.searchParams.set("categories", categoryId);
}
url.searchParams.set("per_page", context.postsPerPage);

// REST API 呼び出し
const res = yield fetch(url); 

ここでは REST API のベースURL を context.restRoot に保持し、PHP 側(render.php)で rest_url() を使って生成しています。

これは、WordPress がサブディレクトリ(例:https://example.com/subdir/)にインストールされている場合に、単純に

new URL("/wp-json/wp/v2/posts", window.location.origin)

のように書くと ルート直下(https://example.com/wp-json/...) を指してしまい、正しい REST エンドポイントにアクセスできなくなるためです。

もし context.restRoot が未定義の場合は、フォールバックとして window.location.origin + /wp-json/ を使用します。

ローディング表示の制御

この例では、context.loading が true の間だけローディング表示を出します。

HTML 側(render.php)

data-wp-bind--hidden を利用して、hidden 属性の付け外しを動的に行います。

aria-live="polite" と role="status" はスクリーンリーダー対応のためで、ローディング開始時に「読み込み中…」と読み上げられます。

<!-- ローディング表示(context.loading が true の場合のみ表示) -->
<div
  data-wp-bind--hidden="!context.loading"
  class="my-theme-loading-spinner"
  aria-live="polite"
  role="status"
>
  <span class="spinner"></span>
  <?php esc_html_e('読み込み中...', 'my-category-posts'); ?>
</div>

context.loading の値はアクション関数(例:changeCategory)で制御します。

呼び出し時に true にし、finally 句で成功・失敗に関わらず false に戻すことで、ローディング表示を確実に停止します。

CSS 側

hidden 属性はブラウザが自動的に display: none を適用しますが、要素に display が明示的に設定されていると、その効果が打ち消されることがあります。

この例では、.my-theme-loading-spinner に display: flex を指定しているため、hidden が付与された際にも確実に非表示にするために、属性セレクター [hidden] で display: none を強制しています。

.my-theme-loading-spinner {
  display: flex;
  align-items: center;
  gap: 0.5em;
  font-weight: bold;
  color: #555;
}

/* hidden 属性が付いたときに確実に非表示にする */
.my-theme-loading-spinner[hidden] {
  display: none !important;
}

スピナー

この例のシンプルなスピナーは <span class="spinner"></span> をCSSで円形にして、@keyframes を使って回転させます。

.my-theme-loading-spinner .spinner {
  width: 16px;
  height: 16px;
  border: 3px solid #ccc;
  border-top-color: #0073aa; /* スピナーの色 */
  border-radius: 50%;  /* 円形に */
  animation: spin 1s linear infinite;
}

/* 回転アニメーション */
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
  • data-wp-bind--hidden は hidden 属性を制御するが、CSSで display を上書きしている場合は [hidden] で補強する。
  • アクセシビリティのために aria-live と role="status" を付ける。
  • スピナーは CSS の border と @keyframes 回転アニメーションで実装。
日付フォーマット

この例では、REST API から取得した投稿データの日付を、日本語の年月日形式に統一して表示しています。

Intl.DateTimeFormat は JavaScript 標準の国際化 API で、ロケールに合わせた日付や時刻の整形を行えます。例えば "ja-JP" を指定すると "2025年8月13日" のような日本語形式になります。

Intl.DateTimeFormat オブジェクトの生成は比較的コストが高いため、アクションの中で毎回作らず、モジュール読み込み時に一度だけ作って再利用 するのが効率的です。

// モジュール読み込み時に一度だけ作成
const dateFormatter = new Intl.DateTimeFormat("ja-JP", {
  year: "numeric",
  month: "long",
  day: "numeric",
});

REST API から返ってくる日付(post.date)は ISO8601 形式(例:"2025-08-13T12:34:56")ですが、これを Date オブジェクトに変換し、dateFormatter.format() でローカライズ表示します。

// 投稿データを整形して context にセット
context.posts = data.map((post) => ({
  id: post.id,
  link: post.link,
  title: post.title.rendered,
  date: dateFormatter.format(new Date(post.date)), // 日付フォーマット適用
  excerpt: post.excerpt.rendered.replace(/<[^>]+>/g, ""),
}));

style.scss

このCSS(Sass)は、ローディング表示のスピナーとそのラッパー要素のスタイルを定義しています。

.wp-block-my-theme-my-category-posts クラスは、render.php 側で get_block_wrapper_attributes() によってラッパー要素へ自動付与されるもので、create-block コマンドで生成されるひな型の style.scss に初期状態で含まれています。

.wp-block-my-theme-my-category-posts {

  /* スピナーのラッパー */
  .my-theme-loading-spinner {
    display: flex;
    align-items: center;
    gap: 0.5em;
    font-weight: bold;
    color: #555;
  }

  /* hidden 属性が付いたときに確実に非表示にする */
  .my-theme-loading-spinner[hidden] {
    display: none !important;
  }

  /* スピナー */
  .my-theme-loading-spinner .spinner {
    width: 16px;
    height: 16px;
    border: 3px solid #ccc;
    border-top-color: #0073aa;
    border-radius: 50%;
    animation: spin 1s linear infinite;
  }

  /* 回転アニメーション */
  @keyframes spin {
    to {
      transform: rotate(360deg);
    }
  }
}
  • .my-theme-loading-spinner
    • ローディング中の表示領域。display: flex で横並びレイアウトにし、アイコンとテキストを中央揃えで配置します。
  • .my-theme-loading-spinner[hidden]
    • hidden 属性が付与された際に、強制的に非表示にするためのルール(属性セレクターを使用)。
  • .spinner
    • 回転する円形アイコンをCSSだけで実装しています。グレーの枠線をベースに、上側だけテーマカラー(#0073aa)を指定し、@keyframes spin によって1秒ごとに360度回転します。

必要に応じて、.wp-block-my-theme-my-category-posts を使ってブロックのスタイルを設定できます。