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

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

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

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

更新日:2025年08月20日

作成日: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, InspectorControls } from "@wordpress/block-editor";
import { __ } from "@wordpress/i18n";
import { useSelect } from "@wordpress/data";
import { useEffect } from '@wordpress/element';
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) => {
      return select("core").getEntityRecords("taxonomy", "category", {
        per_page: -1, // すべてのカテゴリー
        hide_empty: true, //投稿がないカテゴリーも取得する場合は false を指定
      });
    },
    []
  );

  // 初期化処理:全カテゴリーを選択状態にする
  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) => {
    return select("core").getEntityRecords("taxonomy", "category", {
      per_page: -1, // すべてのカテゴリー
      hide_empty: true, //投稿がないカテゴリーも取得する場合は false を指定
    });
  },
  []
);
  • useSelect
    • useSelect は Gutenberg の React Hooks の1つで、WordPress データストア(@wordpress/data)から情報を取得できます。
    • 第1引数:コールバック関数→この関数内で引数に渡された select 関数を使い、ストアから必要なデータを取得して返します
    • 第2引数: 依存配列→依存配列を指定することで、特定の値が変化したときにだけコールバック関数が再評価されます。この例の場合、空の配列を渡しているので、依存配列を省略したのと同じことですが、getEntityRecords はキャッシュを使うので、同じリクエストは繰り返しコールされません。
  • select("core").getEntityRecords()
    • select() 関数でコアストア(core)のセレクター getEntityRecords() を呼び出しています。
    • getEntityRecords() は WordPress REST API 経由でエンティティ(投稿、カテゴリー、タグなど)を取得する関数で、以下の引数を受取ます。
    • 第1引数 "taxonomy" → 取得対象のタイプ(分類)
    • 第2引数 "category" → 取得対象の分類名
    • 第3引数 { per_page: -1, hide_empty: true } → 全件取得(ページ分割しない)。但し、投稿がないカテゴリーは取得しない
  • 戻り値
    • カテゴリーオブジェクトの配列([{ 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 上も「全てのカテゴリー」が選択状態になります。

カテゴリーが追加された場合

上記の方法の場合、ブロック挿入後に新しいカテゴリが追加されても categories 属性は更新されません(フロントエンドのセレクトメニューに新しいカテゴリのオプションは表示されません)。

以下は、ユーザーが選択から外したカテゴリはそのまま維持し、新しく追加されたカテゴリだけ自動的に選択に加える(フロントエンドのセレクトメニューに追加されたカテゴリを表示する)例です。

useEffect(() => {
  if (allCategories && allCategories.length > 0) {
    const allIds = allCategories.map((cat) => cat.id);

    // 初期状態(まだ未設定なら全部選択)
    if (!initialCategories?.length) {
      setAttributes({ initialCategories: allIds });
    }

    // 追加されたカテゴリだけ選択に加える
    if (categories?.length) {
      const missing = allIds.filter(
        (id) => !initialCategories.includes(id) && !categories.includes(id)
      );
      if (missing.length > 0) {
        setAttributes({ categories: [...categories, ...missing] });
      }
    } else {
      // 初期化時は全部選択
      setAttributes({ categories: allIds });
    }
  }
}, [allCategories]);
  • initialCategories は「ブロック挿入時」の全カテゴリのスナップショットとして保持
  • その後の比較で
    • initialCategories に存在しないID → 新しく追加されたカテゴリ
    • categories に入っていない → まだユーザーが外していないカテゴリ
  • この両方を満たすものだけ自動的に追加します。

但し、この方法を使用するかは運用方針によるので、例えば、属性に autoSelectAll: true(常に新カテゴリを自動追加するかどうか)のようなフラグを設定して、インスペクターで切り替えられるようにするなどを検討するのが良いかもしれません。

セレクトボックスの作成

以下は 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' => true];
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,  // 投稿ID
      "link"    => get_permalink($p),  // リンク, タイトル, 投稿日, 抜粋
      "title"   => get_the_title($p),  // タイトル
      "date"    => get_the_date('', $p),  // 投稿日
      "datetime" => get_the_date('c', $p),  // 投稿日(datetime 属性用 ISO 8601 機械可読用)
      "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
  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-bind--datetime="context.item.datetime"
          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, リンク, タイトル, 投稿日, datetime属性用投稿日, 抜粋)
      • セレクトメニューに「すべて」オプションを表示するか
      • 初期設定値(選択中カテゴリー、表示件数、フィールドの表示有無)
      • ローディング状態やエラーメッセージ用の変数
      • 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 の値("投稿の取得に失敗しました。")を要素内テキストに反映します(span 要素のテキストとして出力します)。
  • data-wp-bind--hidden はシンプルな条件式で表示を制御できるため、ローディング表示やエラーメッセージの切替などに便利です。
  • CSSで display を明示的に指定している場合は、[hidden] { display: none !important; } のように補強が必要になる場合があります(ローディング表示の制御)。

data-wp-bind--xxxx の値が文字列の場合

wp-bind ディレクティブは data-wp-bind--xxxx の値の型によって挙動が変わります。

  • 真偽値(true / false)
    • 指定した属性 xxxx を追加・削除します(例:hidden の制御)。
  • 文字列
    • 属性 xxxx が追加され、その属性値として文字列が代入されます。

以下の例では、context.item.datetime が ISO8601 形式の文字列を返すため、time 要素の datetime 属性にその文字列が設定されます。

<time
  data-wp-bind--hidden="!state.dateSelected"
  data-wp-bind--datetime="context.item.datetime"
  data-wp-text="context.item.date"
></time>

以下は出力例です(!state.dateSelected が false の場合)。

<time datetime="2025-07-28T10:33:53.000Z">2025年7月28日</time>
繰り返し表示(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)), // 日付フォーマット適用
          datetime: new Date(post.date).toISOString(),  // datetime 属性用
          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データを取得
    const data = yield res.json();  // ← async/await の代わりに yield

    ・・・中略・・・

  }
},

ポイント

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

カテゴリーが選択されると、アクション関数 changeCategory の中で 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);
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);

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

REST API のベース URL は PHP 側(render.php)で rest_url() を使って生成してコンテキストに設定した context.restRoot を使っています。

これは、WordPress がサブディレクトリ(例:https://example.com/subdir/)にインストールされている場合に、単純に以下のように書くと ルート直下(https://example.com/wp-json/...) を指してしまい、正しい REST エンドポイントにアクセスできなくなるためです。

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

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

投稿データを context にセット

取得した投稿の JSONデータ(data)を使って、コンテキストにセットします。

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

この例では、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)), // 日付フォーマット適用
  datetime: new Date(post.date).toISOString(),  // datetime 属性用
  excerpt: post.excerpt.rendered.replace(/<[^>]+>/g, ""),
}));

HTML の <time> 要素の datetime 属性用の値は、post.date を基準にして toISOString() で ISO 8601 に基づく簡略化された形式にしています(例:datetime="2025-07-31T01:35:39.000Z")。

view.js では @wordpress/date に直接アクセスできない

@wordpress/date パッケージには、日付の管理やフォーマットのための関数(dateI18n や format など)が含まれています。

これは主にブロックエディター(Gutenberg)開発環境で利用できる依存パッケージであり、edit.js では使用できますが、view.js(フロント側のスクリプト)にはデフォルトではバンドルされません。

そのため、view.js から直接アクセスすることはできません。もしフロントエンドで利用したい場合は、wp_enqueue_script() で wp-date を依存関係に追加する必要があります。

ローディング表示の制御

この例では、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 に戻すことで、ローディング表示を確実に停止します。

try {
  ...
  context.loading = true; // ローディング開始
  ...
} catch (err) {
  ...
} finally {
  // ローディング終了
  context.loading = 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 回転アニメーションで実装。

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 を使ってブロックのスタイルを設定できます。

編集画面でプレビューを表示

編集画面(edit.js) でも、「実際のプレビュー」を表示することができます。

現在は固定テキストを出力して「エディタではプレビューしない」方式ですが、もしプレビューを表示したい場合は以下のような方法があります。

  1. ServerSideRender コンポーネントを使う
  2. エディタ用のプレビューを JSX で自作する

ServerSideRender コンポーネントを使う

WordPress が用意している <ServerSideRender> コンポーネントを使うと、PHP 側の render_callback の結果をそのままエディター上に表示することができます。

ただし、ServerSideRender はサーバーサイドレンダリング方式に依存しており、新規ブロックの開発ではクライアントサイドでの JSX プレビューや React ベースの方法が推奨されます。そのため、新しい機能開発での利用は基本的に推奨されません。

また、Interactive API と組み合わせた場合は、公式にはサポートされておらず、正しくプレビューが表示されないことがあります。

それでも簡単にプレビューを確認したい場合は、<ServerSideRender> コンポーネントをインポートして利用することで、エディター上にサーバーサイドのレンダリング結果を表示できます。

// ServerSideRender コンポーネントをインポート
import ServerSideRender from '@wordpress/server-side-render';

コンテンツのレンダリング部分(固定テキスト出力の JSX)を以下に置き換えます。

<div {...useBlockProps()}>
  <ServerSideRender
    block="my-theme/my-category-posts" // ブロック名
    attributes={attributes} // PHP に渡す属性→ Edit() で分割代入して取得した attributes
  />
</div>

ブロック名は block.json の name フィールドに指定されている値になります。

上記に変更して試してみましたが、一応プレビューは表示されますが、表示されるのはタイトルのみで、セレクトメニューでカテゴリーを変更しても、変更は反映されませんでした(機能しませんでした)。

import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
import { __ } from "@wordpress/i18n";
import { useSelect } from "@wordpress/data";
import { useEffect } from '@wordpress/element';
import { PanelBody, SelectControl, CheckboxControl, RangeControl, } from "@wordpress/components";
// ServerSideRender コンポーネントをインポート
import ServerSideRender from '@wordpress/server-side-render';

export default function Edit({ attributes, setAttributes }) {
  const { categories, initialCategories, showLabelForAll, postsPerPage, fields,} = attributes;

  const allCategories = useSelect(
    (select) => {
      return select("core").getEntityRecords("taxonomy", "category", {
        per_page: -1,
        hide_empty: true,
      });
    },
    []
  );

  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]);

  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()}>
        <ServerSideRender
          block="my-theme/my-category-posts" // ブロック名
          attributes={attributes} // PHP に渡す属性→ Edit() で分割代入して取得した attributes
        />
      </div>
    </>
  );
}

プレビューを JSX で自作

プレビューを JSX で自作する方法はいくつかありますが、この例では useSelect を使って WordPress の REST API から投稿データ(posts)を取得し、エディター内に一覧をプレビューとして表示します。

プレビュー用セレクトメニューに表示するカテゴリー(selectedCategories)や、現在選択されているカテゴリーの ID(categoryId)は useState で管理します。

useState を使ってこれらの状態を保持することで、ユーザーがセレクトメニューを操作した際に即座に UI が更新されます。単なる変数に格納しているだけでは、値が変わっても再レンダリングが発生せず、画面に反映されません。

さらに、useEffect を使うことで、allCategories や categories が更新されたタイミングで selectedCategories を自動的に計算し直すことができます。これにより、セレクトメニューの表示内容や選択肢の整合性を常に保ちながら、プレビューの UI を最新の状態に保つことができます。

import { useBlockProps, InspectorControls } from "@wordpress/block-editor";
import { __ } from "@wordpress/i18n";
import { useSelect } from "@wordpress/data";
// useState を追加
import { useEffect, useState } from "@wordpress/element";
import { PanelBody, SelectControl, CheckboxControl, RangeControl, } from "@wordpress/components";
// 日付の管理やフォーマットのための関数
import { dateI18n, format, getSettings } from "@wordpress/date";

export default function Edit({ attributes, setAttributes }) {
  const { categories, initialCategories, showLabelForAll, postsPerPage, fields,} = attributes;

  const allCategories = useSelect((select) => {
    return select("core").getEntityRecords("taxonomy", "category", {
      per_page: -1,
      hide_empty: true,
    });
  }, []);

  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]);

  const toggleField = (field) => {
    setAttributes({
      fields: fields.includes(field)
        ? fields.filter((f) => f !== field)
        : [...fields, field],
    });
  };

  // プレビュー用セレクトメニューに表示するためのカテゴリー一覧(ユーザーがインスペクターで指定したものだけを抽出)
  const [selectedCategories, setSelectedCategories] = useState(null);

  useEffect(() => {
    if (allCategories && allCategories.length > 0) {
      // すべてのカテゴリーの中から、属性 categories に含まれるものだけを抽出
      const selectedCats = allCategories.filter((cat) =>
        categories.includes(cat.id)
      );
      setSelectedCategories(selectedCats);
    }
  }, [allCategories, categories]);

  // プレビュー用セレクトメニューで現在選択されているカテゴリーの ID(単一選択用、0=すべて、null=未選択)
  const [categoryId, setCategoryId] = useState(null);

  // 投稿リストのプレビュー用データを取得
  const posts = useSelect(
    (select) => {
      const query = {
        per_page: postsPerPage,
      };
      // セレクトメニューでカテゴリーを選択した場合
      // 0 の場合は「すべて」として categories を指定しない
      if (categoryId !== null && categoryId !== 0) {
        query.categories = [categoryId] ;
      } else if (categoryId === null && initialCategories?.length) {
        // 初期状態(セレクトメニュー未選択時)は initialCategories を使用
        query.categories = initialCategories;
      }
      return select("core").getEntityRecords("postType", "post", query);
    },
    [categoryId, postsPerPage]
  );

  // HTML 文字列からプレーンテキストを取り出すユーティリティ関数
  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  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()}>
        <select
          value={categoryId || ""}
          onChange={(e) =>
            setCategoryId(e.target.value ? Number(e.target.value) : null)
          }
        >
          <option value="" disabled>
            {__("カテゴリーを選択", "my-category-posts")}
          </option>
          {showLabelForAll && (
            <option value="0">{__("すべて", "my-category-posts")}</option>
          )}
          {selectedCategories &&
            selectedCategories.map((cat) => (
              <option key={cat.id} value={cat.id}>
                {cat.name}
              </option>
            ))}
        </select>
        <ul>
          {posts &&
            posts.map((post) => (
              <li key={post.id}>
                {fields.includes("link") ? (
                  <h3>
                    <a href={post.link}>
                      {getTextContent(post.title.rendered)}
                    </a>
                  </h3>
                ) : (
                  <h3>{getTextContent(post.title.rendered)}</h3>
                )}
                {fields.includes("date") && post.date_gmt && (
                  <time dateTime={format("c", post.date_gmt)}>
                    {dateI18n(getSettings().formats.date, post.date_gmt)}
                  </time>
                )}
                {fields.includes("excerpt") && post.excerpt?.rendered && (
                  <p>{getTextContent(post.excerpt.rendered)}</p>
                )}
              </li>
            ))}
        </ul>
      </div>
    </>
  );
}

以下は、WordPress のブロック開発で利用される JSX(React)で <select> 要素をレンダリングして、ユーザーがカテゴリーを選択できるプルダウンメニューを構築しています。

<select
  value={categoryId || ""}
  onChange={(e) =>
    setCategoryId(e.target.value ? Number(e.target.value) : null)
  }
>
  <option value="" disabled>
    {__("カテゴリーを選択", "my-category-posts")}
  </option>
  {showLabelForAll && (
    <option value="0">{__("すべて", "my-category-posts")}</option>
  )}
  {selectedCategories &&
    selectedCategories.map((cat) => (
      <option key={cat.id} value={cat.id}>
        {cat.name}
      </option>
    ))}
</select>

JSX における属性の記述

  • value 属性
    • JSX では HTML と同じ属性名を使えますが、動的な値を埋め込む場合は {} を使います。
    • value={categoryId || ""} は、categoryId が null や undefined の場合に空文字列 "" を代わりにセットし、未選択状態を表現しています。
  • onChange 属性
    • JSX ではイベントハンドラはキャメルケースで記述します(例: onchange → onChange)。
    • 値が変更されたとき、アロー関数でイベントオブジェクトを受け取り、選択値を setCategoryId に渡します。数値化のため Number() を使用し、空欄なら null をセットしています。

<option> 要素の selected 属性

JSX では <option selected> を直接指定せず、<select> に value を与えて選択状態を制御します。

このコードでは初期状態で「カテゴリーを選択」を表示するために、ダミーの <option value="">カテゴリーを選択</option> を用意し、categoryId が null の場合には value={""} が選ばれるようにしています。

条件付きレンダリング

{showLabelForAll && (
  <option value="0">{__("すべて", "my-category-posts")}</option>
)}
  • JSX では if 文を直接書けないため、論理積 (&&) 演算子 を使って条件付きレンダリングを行います。
  • showLabelForAll が true の場合のみ "すべて" の <option> が表示されます。

配列のループ処理

{selectedCategories &&
  selectedCategories.map((cat) => (
    <option key={cat.id} value={cat.id}>
      {cat.name}
    </option>
  ))}
  • JSX ではループ処理に map() を使います。
  • 各 cat オブジェクトから <option> 要素を生成し、key 属性に cat.id を指定して一意性を確保します(React の再レンダリング効率化のため)。

以下のコードは、WordPress の REST API から取得した投稿データ (posts) を JSX でループ処理し、<ul> 要素のリストとして表示しています。

<ul>
  {posts &&
    posts.map((post) => (
      <li key={post.id}>
        {fields.includes("link") ? (
          <h3>
            <a href={post.link}>
              {getTextContent(post.title.rendered)}
            </a>
          </h3>
        ) : (
          <h3>{getTextContent(post.title.rendered)}</h3>
        )}
        {fields.includes("date") && post.date_gmt && (
          <time dateTime={format("c", post.date_gmt)}>
            {dateI18n(getSettings().formats.date, post.date_gmt)}
          </time>
        )}
        {fields.includes("excerpt") && post.excerpt?.rendered && (
          <p>{getTextContent(post.excerpt.rendered)}</p>
        )}
      </li>
    ))}
</ul>

配列のループ処理と条件付きレンダリング

  • posts && ... は、posts が存在する場合のみ .map() を実行するための条件付きレンダリングです。
  • .map() を使って投稿データを <li> 要素に変換しています。
  • 各 <li> には key={post.id} を指定し、React がリストを効率的に更新できるようにしています。

また、オプショナルチェーン演算子を使えば、以下の論理積(&&)を使ったガードは、

posts && posts.map((post) => ( ... ))

以下のように書くことができます(posts が null または undefined のときだけ undefined を返す)。

posts?.map((post) => ( ... ))
  • React の JSX では、レンダー結果として null や undefined を返すと何も描画されません。これはエラーではなく「空」として扱われます。
  • そのため、posts?.map(...) のように書いて、posts が undefined の場合はそのまま undefined を返しても画面には何も表示されない、という挙動になります。

条件に応じて異なる要素を表示

{fields.includes("link") ? (
  <h3>
    <a href={post.link}>
      {getTextContent(post.title.rendered)}
    </a>
  </h3>
) : (
  <h3>{getTextContent(post.title.rendered)}</h3>
)}
  • 三項演算子を使って「タイトルをリンク付きで表示するか」「ただのテキストにするか」を切り替えています。
  • fields.includes("link") が true のときは <a> タグ付き、そうでなければリンクなしで <h3> を表示します。fields 属性は ["link", "date", "excerpt"] のような配列。
  • getTextContent() は HTML タグを除去してプレーンテキストを取得するユーティリティ関数です。

日付の条件付きレンダリング

{fields.includes("date") && post.date_gmt && (
  <time dateTime={format("c", post.date_gmt)}>
    {dateI18n(getSettings().formats.date, post.date_gmt)}
  </time>
)}
  • fields.includes("date") && post.date_gmt により、
    • 日付フィールドを表示する設定が有効で
    • post.date_gmt が存在する場合のみ
    • <time> 要素を出力します。
  • dateTime 属性には ISO8601 形式の日付を format("c", post.date_gmt) で渡し、機械可読性を確保しています。
  • 表示用の日付は WordPress の dateI18n() を使ってローカライズされたフォーマットに変換します。
  • format(), dateI18n(), getSettings() は @wordpress/date からインポートしています。

抜粋の条件付きレンダリング

{fields.includes("excerpt") && post.excerpt?.rendered && (
  <p>{getTextContent(post.excerpt.rendered)}</p>
)}
  • fields.includes("excerpt") が有効かつ post.excerpt.rendered が存在する場合のみ <p> 要素を出力します。
  • post.excerpt?.rendered?. は オプショナルチェーン演算子 で、excerpt が null や undefined の場合でもエラーを出さず安全にアクセスできます。

プレビューの表示

例えば、編集画面に以下のようなプレビューが表示されます。

セレクトメニューからカテゴリーを選択したり、インスペクターパネルでの変更もプレビューに反映されます。

但し、インスペクターパネルでの「初期カテゴリー」の変更は、投稿を保存して再読み込みするまで反映されません。