💡

インナーブロックの属性を動的に変更するのは簡単じゃなかった!

2023/08/03に公開

Gutenbergのブロック開発をしたことがある方はご経験があると思いますが、ちょっと実用的なブロックを開発しようと思うとインナーブロックを利用することは必須の技術になると思います。そのインナーブロックを制御するのにかなり深いドツボにハマったという経験をご披露したいと思います。

作成しようとしたブロックの概要

先日次のようなブロックを作成しました。

①、②はインナーブロックで①はAというインナーブロックを、②はB,Cというインナーブロックを持っています。Aは内部にHTMLのinput要素を持っていてユーザーが入力するようになっています。
Bは静的なラベルです。
CはHTMLのtable要素で、Gutenbergのcore/tableでレンダリングしています。
このブロックはユーザーがAに入力した内容をCのテーブルに反映させ、入力内容を確認できるようにするというもので、お問合せフォームなどでよく利用されるものです。上の画像は説明のため①、②を縦に並べましたが、実用段階においては①と②は同時に表示されるのではなく、①のボタンを押すと②が表示され、②のボタンを押すと①に戻るというものです。
一見単純な操作に見えるのですが、Aの入力にあわせてCを再レンダリングするというのが、実はかなり難しいのです。

まず、インナーブロックの設置方法から

まず、インナーブロックの設置方法を説明します。
これは比較的簡単でuseInnerBlocksPropsというフックを使います。具体的には

import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';

const TEMPLATE = [
	['core/paragraph', {}],
	['core/table', {}],
];//①

export default function Edit() {
	const blockProps = useBlockProps();
	const innerBlocksProps = useInnerBlocksProps(
		{ blockProps },
		{
			template: TEMPLATE,
			templateLock: false
		}
	);//②

	return (
		<div {...innerBlocksProps} />//③
	);
}

これは@wordpress/create-blockで作ったブロックのひな型の中のedit.jsファイルです。

①でインナーブロックのテンプレートを作ります。そして②でuseInnerBlocksPropsフックを実行して、③でレンダリングという仕組みです。
すると、次のようなブロックがブロックエディタ上に現れます。

固定ページを編集 “テストデータを入れたブロックです” ‹ ブロックテストのテーマ — WordPr.png

テンプレートによる初期化

先ほどのコードはTEMPLATEにブロックの名称しか入れなかったので、ブロックエディタ上ブロックは初期化されない状態でレンダリングされましたが、TEMPLATEの配列内の要素に初期化するための属性値をオブジェクトとして与えてやれば、初期化することができます。
コアブロックを初期化するためには、初期化したいブロックに応じたオブジェクトを用意する必要があります。単純なものから複雑なものまで様々です。
こちらの公式ページに初期化できる属性名が掲載されているので、まずはこのページで調べるのですが、属性名がわかってもそれはオブジェクトのキーの部分がわかるだけで、オブジェクトの値をどのようにするのかがわからないという問題につきあたります。この話をしだすと大きく本題からそれるのでしませんが、今回はcore/paragraphcore/tableの初期化方法だけ説明します。
core/paragraphは単純でcontent: '初期化しました'というオブジェクトを用意すれば初期化できます。
core/tableは複雑です。headbodyfootという属性を初期化すればtable要素がレンダリングされるのですが、属性値は単純な文字列ではありません。cellsというキーを持つオブジェクトである必要があります。そこで次のような関数を作りました。

// セル要素を生成する関数
const cellObjects = (inputInnerBlocks) => {
	return inputInnerBlocks.map((input_elm) => ({
		cells: [
			{
				content: input_elm.attributes.labelContent,
				tag: 'th'
			},
			{
				content: input_elm.attributes.inputValue,
				tag: 'td'
			}
		]
	}));
}

これを使って次のようにオブジェクトを用意します。

const tableHead = [];
const tableBody = cellObjects(inputFigureBlocks);
const tablefoot = [];
const tableAttributes = { head: tableHead, body: tableBody, foot: tablefoot };

今回はtableHeadtablefootは空配列にしましたが、ここにも関数で値を入れればHTMLのthead要素とtfoot要素がレンダリングされます。

ここまでできたら、TEMPLATEにあてはめます。

const TEMPLATE = [
	['core/paragraph', { content: '初期化しました' }],
	['core/table', {...tableAttributes}],
];

これでこんなふうに初期化されます。

固定ページを編集 “テストデータを入れたブロックです” ‹ ブロックテストのテーマ — WordPr.png

ここまでの全コードは次のようになります。



import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';

// セル要素を生成する関数
const cellObjects = (inputInnerBlocks) => {
	return inputInnerBlocks.map((input_elm) => ({
		cells: [
			{
				content: input_elm.attributes.labelContent,
				tag: 'th'
			},
			{
				content: input_elm.attributes.inputValue,
				tag: 'td'
			}
		]
	}));
}

export default function Edit() {
	//とりあえずinputBlocksを静的にハードコードします
	const inputBlocks = [
		{ attributes: { labelContent: 'ラベル1', inputValue: 'インプット1' } },
		{ attributes: { labelContent: 'ラベル2', inputValue: 'インプット2' } },
	]
	//テーブルボディを初期化
	const tableBody = cellObjects(inputBlocks);
	const tableAttributes = { body: tableBody };

	const TEMPLATE = [
		['core/paragraph', { content: '初期化しました' }],
		['core/table', { ...tableAttributes }],

	];
	const blockProps = useBlockProps();
	const innerBlocksProps = useInnerBlocksProps({ blockProps }, {
		template: TEMPLATE,
		templateLock: false
	});

	return (
		<div {...innerBlocksProps} />
	);
}

inputBlocksを外部のブロックから取得する

上記のコードは、とりあえずinputBlocksを静的にハードコードしましたが、最初の課題に立ち返ると、inputBlocksは外部のブロックのインナーブロックから情報を取得して配列として生成する必要があります。
そこで、次のようにブロックエディタで別のブロックを用意しました。

固定ページを編集 “テストデータを入れたブロックです” ‹ ブロックテストのテーマ — WordPr.png

core/groupの中にitmar/design-text-ctrlという自作のカスタムブロックをインナーブロックとして2つ入れました。
ここで一つ重要な注意点があります。本題からそれてしまいますが、ここでもかなりハマったので書き留めておきます。

const TEMPLATE1 = [
	['itmar/design-text-ctrl', {}],
	['itmar/design-text-ctrl', {}],

];
const TEMPLATE2 = [
	['core/paragraph', { content: '初期化しました' }],
	['core/table', { ...tableAttributes }],

];

const innerBlocksProps1 = useInnerBlocksProps({ blockProps }, {
	template: TEMPLATE1,
	templateLock: false
});
const innerBlocksProps2 = useInnerBlocksProps({ blockProps }, {
	template: TEMPLATE2,
	templateLock: false
});


	return (
		<>
			<div {...innerBlocksProps1} />
			<div {...innerBlocksProps2} />
		</>
	);

こんなふうに書きたくなりませんか?
このコードでエラーはでません。インナーブロックも2つレンダリングされます。しかし、2種類のテンプレートがレンダリングされることはないのです。こんなふうになります。

固定ページを編集 “固定ページにブロックを入れます” ‹ ブロックテストのテーマ — WordPre.png

最初にセットした<div {...innerBlocksProps1} />のテンプレートの内容しかレンダリングされません。これを回避する方法はなさそうです。
したがって、同じブロックの中に複数のインナーブロックコンポーネントを入れることはできないと覚えておくことが重要です。
ですから、インナーブロックのエリアを複数作りたいのであれば、まったく別のブロックにインナーブロックをいれるという必要があります。

では、本題に戻ります。
今回は自分以外のブロックから情報を引き出す必要あります。そこで必要なのがuseSelectフックです。このフックはブロックエディタ内のすべてのブロックの状態を監視してくれます。
具体的なコードを示します。

const inputBlocks = useSelect((select) => {
	const {getBlocks} = select('core/block-editor');
	//全ブロック
	const allInnerBlocks = getBlocks();
	//親ブロックを取得
	const parentBlock = allBlocks.find(block => block.name === 'core/group');
	//その中のインナーブロック
	const targetBlocks = parentBlock ? parentBlock.innerBlocks : [];
	return targetBlocks; 
}, []);

初めてuseSelectを使う方にはわかりにくいと思うので、もう一度この画像で説明します。

A.jpg

上記のコードでやりたいのは、2つの「A」の状態を監視することです。つまり、まず「A」を抽出しないといけないのです。
「親ブロック」はブロックエディタ全体、つまり'core/block-editor'です。その中のブロック全部であるallInnerBlocksは①と②です。今回は「A」があるブロックは①であり、その名前はcore/groupであるということがわかっているので、const parentBlock = allBlocks.find(block => block.name === 'core/group');で①をparentBlockとすることができました。あとはその中のインナーブロック全部ということで、const targetBlocks = parentBlock ? parentBlock.innerBlocks : [];でtargetBlocksが2つの「A」ということになります。
これで「A」の状態が監視できるようになりました。

ここでまた重要な注意点があります。useSelectは「A」の状態を監視してinputBlocksを書き換えてくれます。しかし、この処理は非同期処理なのです。
つまり、いつ書き換わるかわからないということです。書き換わった時にインナーブロックのテンプレートが確実に書き換わるようにするためにどうしたらいいかという課題が出てきます。これを解決するのがuseEffectです。
こんなふうにしてテンプレートを書き換えます。

const [TEMPLATE, setTemplate] = useState([]);

useEffect(() => {
	//テーブルボディを初期化
	const tableBody = cellObjects(inputBlocks);
	const tableAttributes = { body: tableBody };
	setTemplate(
		[
			['core/paragraph', { content: '初期化しました' }],
			['core/table', { ...tableAttributes }],
		]
	)
}, [inputBlocks]);

このuseEffectinputBlocksを依存配列に持っているのでuseSelectinputBlocksを書き換えると発火してくれます。それでテンプレートを書き換えるのですが、useEffect内でconst宣言した定数は外にスコープが効かないので、useInnerBlocksPropsから見えません。
そこで、TEMPLATEを状態変数にしてuseStateで書き換えるのです。こうすればuseInnerBlocksPropsTEMPLATEの状態変化を検知して、インナーブロックを再レンダリングしてくれるはずです。

テンプレートは動的に書き換わらない!?

ここまでかなり苦労してインナーブロックの再レンダリングの仕組みを作ってきました。
しかし、本当の苦難はこれからでした。
結論から言ってしまいますが、TEMPLATEが書き換わってもインナーブロックの属性は書き換わりません。初期化したときはTEMPLATEの内容にしたがってレンダリングしてくれますが、その後TEMPLATEの内容が書き換わってもその変化には対応してくれません。これはブロックというのはユーザーが手動で書き換えることを前提としたコンポーネントだからでしょう。自動的に書き換えることは、ある意味で御法度なのかもしれません。
一度初期化したブロックは動的に書き換えることはできないということです。
ではどうしたらいいのか?
一旦削除するという方法があるのです。一旦削除すれば、次のレンダリングは初期化から始まるので、新しいテンプレートでレンダリングされます。
では、どうやって削除するのか。

削除の方法はuseDispatchというフックでremoveBlocksという関数を取得し、その関数に引数としてブロックのclientIDを渡せば削除されます。
ただし、単純に削除してテンプレートを書き換えればいいというものではありません。removeBlocksも非同期の関数なのです。つまり、removeBlocksを実行してすぐにテンプレートを書き換えても、ブロックはまだ削除されていないので効果はありません。もう一つuseEffectを用意してremoveBlocksが完了した後に処理が始まるようにしなければなりません。

整理すると次のようになります。

  1. inputBlocks(画像のAのブロック)の変化
  2. 画像のB,Cブロックの削除
  3. 削除されたことの確認
  4. テンプレートの再生成
  5. インナーブロックの再レンダリング

具体的には次のようなコードになります。

import { useSelect, useDispatch } from '@wordpress/data';

export default function Edit() {

	//removeBlocks関数の取得
	const { removeBlocks } = useDispatch('core/block-editor');
	//インナーブロックの監視
	const innerBlockIds = useSelect((select) =>
		select('core/block-editor').getBlocks(clientId).map((block) => block.clientId)
	);
	//inputBlocksに変化があればブロックを一旦削除
	useEffect(() => {
		removeBlocks(innerBlockIds);

	}, [inputBlocks]);

	//ブロックの削除を確認して再度レンダリング
	useEffect(() => {
		if (innerBlockIds.length === 0) {
			//テーブルボディを初期化
			const tableBody = cellObjects(inputBlocks);
			const tableAttributes = { body: tableBody };
			setTemplate(
				[
					['core/paragraph', { content: '初期化しました' }],
					['core/table', { ...tableAttributes }],
				]
			)
		}
	}, [innerBlockIds.length]);
	
	・・・

これで、何とかなるんですが、これだけの工程を踏まないといけないというのは、ちょっと大変すぎますよね。

replaceInnerBlocksという関数が用意されていた

この存在を最近知りました。この関数を使えば一旦レンダリングされたインナーブロックの差し替えができるのです。removeBlocksで削除して、その完了を待って、再レンダリングというのに比べたらはるかに効率的です。
その実行部分のコードを示します。

useEffect(() => {
	//テーブルボディを初期化
	const tableBody = cellObjects(inputBlocks);
	const tableAttributes = { body: tableBody };
	const newInnerBlocks = [
		createBlock('core/paragraph', {}),
		createBlock('core/table', { ...tableAttributes }),
	];
	replaceInnerBlocks(clientId, newInnerBlocks, false);
}, [inputBlocks]);

useEffectはこの一本だけです。
replaceInnerBlocks関数はテンプレートではなく、createBlock関数でブロックをつくって、既存のインナーブロックと差し替えるというものです。したがって、useInnerBlocksPropsで再レンダリングということも行いません。ですから、useInnerBlocksPropsは、次のように最初に初期化していないインナーブロックの枠だけ作るという役割を果たしてくれればいいのです。

const TEMPLATE = [];

const innerBlocksProps = useInnerBlocksProps({ blockProps }, {
	template: TEMPLATE,
	templateLock: false
});

TEMPLATEは変化しませんから、状態変数にする必要はなく、そのためのuseStateも必要なくなりました。

最後に

長々説明しましたが、最終的な全コードを示します。

import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';
import { useSelect, useDispatch } from '@wordpress/data';
import { useEffect } from '@wordpress/element';
import { createBlock } from '@wordpress/blocks';

// セル要素を生成する関数
const cellObjects = (inputInnerBlocks) => {
	return inputInnerBlocks.map((input_elm) => ({
		cells: [
			{
				content: input_elm.attributes.labelContent,
				tag: 'th'
			},
			{
				content: input_elm.attributes.inputValue,
				tag: 'td'
			}
		]
	}));
}

export default function Edit({ clientId }) {

	//replaceInnerBlocks関数の取得
	const { replaceInnerBlocks } = useDispatch('core/block-editor');

	//ブロックエディタ全体('core/block-editor')から'itmar/design-text-ctrl'を抽出
	const inputBlocks = useSelect((select) => {
		const { getBlocks } = select('core/block-editor');
		// 全ブロックを取得
		const allBlocks = getBlocks();
		//親ブロックを取得
		const parentBlock = allBlocks.find(block => block.name === 'core/group');
		//その中のインナーブロック
		const targetBlocks = parentBlock ? parentBlock.innerBlocks : [];
		return targetBlocks;
	}, []); //

	//インナーブロックの置き換え
	useEffect(() => {
		//テーブルボディを初期化
		const tableBody = cellObjects(inputBlocks);
		const tableAttributes = { body: tableBody };
		const newInnerBlocks = [
			createBlock('core/paragraph', {}),
			createBlock('core/table', { ...tableAttributes }),
		];
		replaceInnerBlocks(clientId, newInnerBlocks, false);
	}, [inputBlocks]);


	const blockProps = useBlockProps();

	const TEMPLATE = [];

	const innerBlocksProps = useInnerBlocksProps({ blockProps }, {
		template: TEMPLATE,
		templateLock: false
	});

	return (
		<>
			<div {...innerBlocksProps} />
		</>
	);
}

ちょっと実用的なブロックを作成しようとするとインナーブロックの活用は不可欠です。今回はフロントエンドでのレンダリングについては触れませんでしたが、

<InnerBlocks.Content />

というコンポーネントでレンダリングできるのは、コーディングの作業量を大幅に削減してくれます。
ですから、インナーブロックは積極的に使っていくべきだと思っています。

今回の制作作業で是非覚えておきたいことを箇条書きにします

  • インナーブロックはテンプレートで初期化することができる。
  • インナーブロックはテンプレートで複数のブロックを入れることはできるが、テンプレートを分割して複数のエリアに配置することはできない。
  • インナーブロックはテンプレートを差し替えても一度レンダリングした属性は動的に変更されない。
  • 一度レンダリングしたインナーブロックの属性を変更するにはcreateBlock関数でブロックを作成し、それを配列にしてreplaceInnerBlocks関数で差し替える。

こんな感じでまとめることができると思います。

いかがだったでしょうか。
このブログが、これからブロック制作をしようと思っている方の参考になれば幸いです。

Discussion