Кастомные Gutenberg блоки: продвинутая разработка с React

24 октября 2025
Кастомные Gutenberg блоки: продвинутая разработка с React

За 16 лет работы с WordPress создал более 150 кастомных блоков — от простых карточек до сложных систем с real-time данными. Первые блоки писал на jQuery, переходил на Backbone, потом на React. В 2025 году разработка Gutenberg блоков достигла зрелости — инструментарий мощный, API стабильные, но документация все еще оставляет желать лучшего. Сегодня разберу продвинутые техники, которые редко описаны в официальных гайдах.​

Динамические блоки: серверный vs клиентский рендеринг

Когда нужен server-side rendering

На проекте новостного портала создавал блок «Популярные статьи». Первая реализация через REST API в редакторе работала, но генерировала 3 дополнительных запроса на каждое открытие поста для редактирования. Миграция на серверный рендеринг сократила время загрузки редактора с 4.2 до 1.8 секунды.​

SSR оправдан когда:

  • Данные меняются часто (статистика, погода, курсы валют)
  • Нужен доступ к данным, недоступным через REST API
  • Требуются сложные WP_Query с учетом прав пользователя
  • Критична безопасность (скрытие логики от клиента)

Правильная реализация динамического блока

block.json с render callback:

json<code>{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "mytheme/popular-posts",
  "title": "Popular Posts",
  "category": "widgets",
  "icon": "chart-bar",
  "attributes": {
    "numberOfPosts": {
      "type": "number",
      "default": 5
    },
    "category": {
      "type": "string",
      "default": ""
    },
    "timeRange": {
      "type": "string",
      "default": "week",
      "enum": ["day", "week", "month", "year", "all"]
    },
    "showExcerpt": {
      "type": "boolean",
      "default": true
    },
    "showThumbnail": {
      "type": "boolean",
      "default": true
    }
  },
  "textdomain": "mytheme",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php"
}
</code>

render.php — серверная логика:

php<code><?php
<em>/**
</em><em> * @var array    $attributes Block attributes
</em><em> * @var string   $content    Block default content
</em><em> * @var WP_Block $block      Block instance
</em><em> */</em>

$number_of_posts = isset($attributes['numberOfPosts']) ? $attributes['numberOfPosts'] : 5;
$category = isset($attributes['category']) ? $attributes['category'] : '';
$time_range = isset($attributes['timeRange']) ? $attributes['timeRange'] : 'week';
$show_excerpt = isset($attributes['showExcerpt']) ? $attributes['showExcerpt'] : true;
$show_thumbnail = isset($attributes['showThumbnail']) ? $attributes['showThumbnail'] : true;

<em>// Определение временного диапазона</em>
$date_query = array();
switch ($time_range) {
    case 'day':
        $date_query = array('after' => '1 day ago');
        break;
    case 'week':
        $date_query = array('after' => '1 week ago');
        break;
    case 'month':
        $date_query = array('after' => '1 month ago');
        break;
    case 'year':
        $date_query = array('after' => '1 year ago');
        break;
}

$args = array(
    'posts_per_page' => $number_of_posts,
    'post_status' => 'publish',
    'orderby' => 'meta_value_num',
    'meta_key' => 'post_views_count',
    'order' => 'DESC',
    'ignore_sticky_posts' => true,
);

if (!empty($category)) {
    $args['category_name'] = $category;
}

if (!empty($date_query)) {
    $args['date_query'] = array($date_query);
}

<em>// Кэширование запроса</em>
$cache_key = 'popular_posts_' . md5(serialize($args));
$posts = wp_cache_get($cache_key);

if (false === $posts) {
    $query = new WP_Query($args);
    $posts = $query->posts;
    wp_cache_set($cache_key, $posts, '', 3600); <em>// 1 час</em>
}

if (empty($posts)) {
    return '<div class="popular-posts-empty">No popular posts found.</div>';
}

$wrapper_attributes = get_block_wrapper_attributes(array(
    'class' => 'popular-posts-block'
));
?>

<div <?php echo $wrapper_attributes; ?>>
    <ul class="popular-posts-list">
        <?php foreach ($posts as $post): ?>
            <li class="popular-post-item">
                <?php if ($show_thumbnail && has_post_thumbnail($post->ID)): ?>
                    <div class="popular-post-thumbnail">
                        <a href="<?php echo get_permalink($post->ID); ?>">
                            <?php echo get_the_post_thumbnail($post->ID, 'thumbnail'); ?>
                        </a>
                    </div>
                <?php endif; ?>
                
                <div class="popular-post-content">
                    <h3 class="popular-post-title">
                        <a href="<?php echo get_permalink($post->ID); ?>">
                            <?php echo esc_html($post->post_title); ?>
                        </a>
                    </h3>
                    
                    <?php if ($show_excerpt): ?>
                        <div class="popular-post-excerpt">
                            <?php echo wp_trim_words(get_the_excerpt($post->ID), 20); ?>
                        </div>
                    <?php endif; ?>
                    
                    <div class="popular-post-meta">
                        <span class="post-views">
                            <?php echo number_format_i18n(get_post_meta($post->ID, 'post_views_count', true)); ?> views
                        </span>
                        <span class="post-date">
                            <?php echo human_time_diff(get_the_time('U', $post->ID), current_time('timestamp')); ?> ago
                        </span>
                    </div>
                </div>
            </li>
        <?php endforeach; ?>
    </ul>
</div>
</code>

Edit компонент с ServerSideRender:

jsx<code>import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { 
    PanelBody, 
    RangeControl, 
    SelectControl,
    ToggleControl,
    Spinner
} from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import ServerSideRender from '@wordpress/server-side-render';

export default function Edit({ attributes, setAttributes }) {
    const blockProps = useBlockProps();
    
    <em>// Получение списка категорий</em>
    const categories = useSelect((select) => {
        return select('core').getEntityRecords('taxonomy', 'category', {
            per_page: -1,
            orderby: 'name',
            order: 'asc'
        });
    }, []);
    
    const categoryOptions = [
        { label: __('All Categories', 'mytheme'), value: '' },
        ...(categories || []).map((cat) => ({
            label: cat.name,
            value: cat.slug
        }))
    ];
    
    return (
        <>
            <InspectorControls>
                <PanelBody title={__('Settings', 'mytheme')}>
                    <RangeControl
                        label={__('Number of Posts', 'mytheme')}
                        value={attributes.numberOfPosts}
                        onChange={(value) => setAttributes({ numberOfPosts: value })}
                        min={1}
                        max={20}
                    />
                    
                    <SelectControl
                        label={__('Category', 'mytheme')}
                        value={attributes.category}
                        options={categoryOptions}
                        onChange={(value) => setAttributes({ category: value })}
                    />
                    
                    <SelectControl
                        label={__('Time Range', 'mytheme')}
                        value={attributes.timeRange}
                        options={[
                            { label: __('Last 24 Hours', 'mytheme'), value: 'day' },
                            { label: __('Last Week', 'mytheme'), value: 'week' },
                            { label: __('Last Month', 'mytheme'), value: 'month' },
                            { label: __('Last Year', 'mytheme'), value: 'year' },
                            { label: __('All Time', 'mytheme'), value: 'all' }
                        ]}
                        onChange={(value) => setAttributes({ timeRange: value })}
                    />
                    
                    <ToggleControl
                        label={__('Show Excerpt', 'mytheme')}
                        checked={attributes.showExcerpt}
                        onChange={(value) => setAttributes({ showExcerpt: value })}
                    />
                    
                    <ToggleControl
                        label={__('Show Thumbnail', 'mytheme')}
                        checked={attributes.showThumbnail}
                        onChange={(value) => setAttributes({ showThumbnail: value })}
                    />
                </PanelBody>
            </InspectorControls>
            
            <div {...blockProps}>
                <ServerSideRender
                    block="mytheme/popular-posts"
                    attributes={attributes}
                    LoadingResponsePlaceholder={() => (
                        <div className="loading-placeholder">
                            <Spinner />
                            <p>{__('Loading popular posts...', 'mytheme')}</p>
                        </div>
                    )}
                    ErrorResponsePlaceholder={({ response }) => (
                        <div className="error-placeholder">
                            <p>{__('Error loading posts. Please check your settings.', 'mytheme')}</p>
                            {response?.message && <small>{response.message}</small>}
                        </div>
                    )}
                    EmptyResponsePlaceholder={() => (
                        <div className="empty-placeholder">
                            <p>{__('No popular posts found.', 'mytheme')}</p>
                        </div>
                    )}
                />
            </div>
        </>
    );
}
</code>

Оптимизация ServerSideRender

Стандартный компонент ServerSideRender имеет проблему — при каждом изменении атрибута показывает spinner, что раздражает. Решение через debounce:​

jsx<code>import { useState, useEffect, useCallback } from '@wordpress/element';
import { debounce } from '@wordpress/compose';
import ServerSideRender from '@wordpress/server-side-render';

function OptimizedServerSideRender({ block, attributes, ...props }) {
    const [debouncedAttributes, setDebouncedAttributes] = useState(attributes);
    
    <em>// Debounce на 300ms</em>
    const updateAttributes = useCallback(
        debounce((newAttrs) => {
            setDebouncedAttributes(newAttrs);
        }, 300),
        []
    );
    
    useEffect(() => {
        updateAttributes(attributes);
    }, [attributes, updateAttributes]);
    
    return (
        <ServerSideRender
            block={block}
            attributes={debouncedAttributes}
            {...props}
        />
    );
}
</code>

useSelect и useDispatch: управление состоянием

Получение данных из WordPress stores

Самый мощный инструмент для работы с данными в Gutenberg — хуки useSelect и useDispatch:​

jsx<code>import { useSelect, useDispatch } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { store as editorStore } from '@wordpress/editor';
import { store as blockEditorStore } from '@wordpress/block-editor';

function AdvancedBlockEdit({ attributes, setAttributes, clientId }) {
    <em>// Получение данных из нескольких stores одновременно</em>
    const {
        post,
        postType,
        categories,
        media,
        blocks,
        isBlockSelected
    } = useSelect((select) => {
        const { getCurrentPost, getCurrentPostType } = select(editorStore);
        const { getEntityRecords, getMedia } = select(coreStore);
        const { getBlocks, isBlockSelected: checkSelected } = select(blockEditorStore);
        
        return {
            post: getCurrentPost(),
            postType: getCurrentPostType(),
            categories: getEntityRecords('taxonomy', 'category', { per_page: -1 }),
            media: attributes.mediaId ? getMedia(attributes.mediaId) : null,
            blocks: getBlocks(),
            isBlockSelected: checkSelected(clientId)
        };
    }, [attributes.mediaId, clientId]);
    
    <em>// Диспатч действий</em>
    const { editPost } = useDispatch(editorStore);
    const { insertBlocks, removeBlock } = useDispatch(blockEditorStore);
    
    <em>// Пример: обновление мета-поля поста</em>
    const updatePostMeta = (key, value) => {
        editPost({
            meta: {
                [key]: value
            }
        });
    };
    
    <em>// Пример: программная вставка блока</em>
    const insertRelatedContentBlock = () => {
        const newBlock = createBlock('mytheme/related-content', {
            postId: post.id
        });
        
        <em>// Вставка после текущего блока</em>
        const rootBlocks = blocks;
        const currentIndex = rootBlocks.findIndex(block => block.clientId === clientId);
        
        insertBlocks(newBlock, currentIndex + 1);
    };
    
    <em>// ... остальная логика блока</em>
}
</code>

Продвинутый пример: синхронизация с custom post meta

jsx<code>import { useSelect, useDispatch } from '@wordpress/data';
import { useEffect } from '@wordpress/element';
import { store as editorStore } from '@wordpress/editor';

function MetaSyncedBlock({ attributes, setAttributes }) {
    const { meta, isSaving } = useSelect((select) => {
        const editor = select(editorStore);
        return {
            meta: editor.getEditedPostAttribute('meta'),
            isSaving: editor.isSavingPost()
        };
    }, []);
    
    const { editPost } = useDispatch(editorStore);
    
    <em>// Синхронизация атрибутов блока с мета-полями</em>
    useEffect(() => {
        if (meta && meta.custom_title !== attributes.title) {
            setAttributes({ title: meta.custom_title || '' });
        }
    }, [meta, attributes.title, setAttributes]);
    
    const handleTitleChange = (newTitle) => {
        <em>// Обновляем и атрибут блока, и мета-поле</em>
        setAttributes({ title: newTitle });
        editPost({
            meta: {
                custom_title: newTitle
            }
        });
    };
    
    return (
        <div className="meta-synced-block">
            <TextControl
                label="Title (synced with post meta)"
                value={attributes.title}
                onChange={handleTitleChange}
                disabled={isSaving}
            />
            {isSaving && <Spinner />}
        </div>
    );
}
</code>

Block Context: передача данных между блоками

Создание родительского блока с контекстом

Block Context API позволяет передавать данные от родительского блока к дочерним без prop drilling:​

Parent block (Container):

json<code><em>// block.json</em>
{
  "name": "mytheme/product-container",
  "title": "Product Container",
  "category": "widgets",
  "attributes": {
    "productId": {
      "type": "number"
    },
    "showPricing": {
      "type": "boolean",
      "default": true
    },
    "currency": {
      "type": "string",
      "default": "USD"
    }
  },
  "providesContext": {
    "mytheme/productId": "productId",
    "mytheme/showPricing": "showPricing",
    "mytheme/currency": "currency"
  },
  "supports": {
    "html": false
  }
}
</code>
jsx<code><em>// edit.js</em>
import { InnerBlocks, useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, SelectControl, ToggleControl } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';

const ALLOWED_BLOCKS = [
    'mytheme/product-title',
    'mytheme/product-image',
    'mytheme/product-price',
    'mytheme/product-description'
];

const TEMPLATE = [
    ['mytheme/product-image'],
    ['mytheme/product-title'],
    ['mytheme/product-price'],
    ['mytheme/product-description']
];

export default function Edit({ attributes, setAttributes }) {
    const blockProps = useBlockProps();
    
    <em>// Получение списка продуктов</em>
    const products = useSelect((select) => {
        return select(coreStore).getEntityRecords('postType', 'product', {
            per_page: -1
        });
    }, []);
    
    const productOptions = products ? [
        { label: 'Select a product', value: 0 },
        ...products.map(product => ({
            label: product.title.rendered,
            value: product.id
        }))
    ] : [];
    
    return (
        <>
            <InspectorControls>
                <PanelBody title="Product Settings">
                    <SelectControl
                        label="Product"
                        value={attributes.productId}
                        options={productOptions}
                        onChange={(productId) => setAttributes({ 
                            productId: parseInt(productId) 
                        })}
                    />
                    
                    <ToggleControl
                        label="Show Pricing"
                        checked={attributes.showPricing}
                        onChange={(showPricing) => setAttributes({ showPricing })}
                    />
                    
                    <SelectControl
                        label="Currency"
                        value={attributes.currency}
                        options={[
                            { label: 'USD', value: 'USD' },
                            { label: 'EUR', value: 'EUR' },
                            { label: 'GBP', value: 'GBP' }
                        ]}
                        onChange={(currency) => setAttributes({ currency })}
                    />
                </PanelBody>
            </InspectorControls>
            
            <div {...blockProps}>
                {attributes.productId === 0 ? (
                    <div className="product-container-placeholder">
                        <p>Please select a product from the sidebar.</p>
                    </div>
                ) : (
                    <InnerBlocks
                        allowedBlocks={ALLOWED_BLOCKS}
                        template={TEMPLATE}
                        templateLock="all"
                    />
                )}
            </div>
        </>
    );
}
</code>

Дочерний блок, использующий контекст

Child block (Product Price):

json<code><em>// block.json</em>
{
  "name": "mytheme/product-price",
  "title": "Product Price",
  "category": "widgets",
  "parent": ["mytheme/product-container"],
  "usesContext": [
    "mytheme/productId",
    "mytheme/showPricing",
    "mytheme/currency"
  ],
  "attributes": {
    "showLabel": {
      "type": "boolean",
      "default": true
    }
  }
}
</code>
jsx<code><em>// edit.js</em>
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ToggleControl, Spinner } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';

export default function Edit({ attributes, setAttributes, context }) {
    const blockProps = useBlockProps();
    
    <em>// Получение данных продукта из контекста</em>
    const productId = context['mytheme/productId'];
    const showPricing = context['mytheme/showPricing'];
    const currency = context['mytheme/currency'];
    
    <em>// Загрузка продукта</em>
    const { product, isResolving } = useSelect((select) => {
        const { getEntityRecord, isResolving: checkResolving } = select(coreStore);
        
        return {
            product: productId ? getEntityRecord('postType', 'product', productId) : null,
            isResolving: checkResolving('getEntityRecord', ['postType', 'product', productId])
        };
    }, [productId]);
    
    if (!showPricing) {
        return (
            <div {...blockProps}>
                <p className="pricing-hidden-notice">
                    Pricing is hidden by parent block settings
                </p>
            </div>
        );
    }
    
    if (isResolving) {
        return (
            <div {...blockProps}>
                <Spinner />
            </div>
        );
    }
    
    if (!product) {
        return (
            <div {...blockProps}>
                <p className="no-product-notice">No product selected</p>
            </div>
        );
    }
    
    const price = product.meta?.price || 0;
    const formattedPrice = new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: currency
    }).format(price);
    
    return (
        <>
            <InspectorControls>
                <PanelBody title="Price Display">
                    <ToggleControl
                        label="Show Label"
                        checked={attributes.showLabel}
                        onChange={(showLabel) => setAttributes({ showLabel })}
                    />
                </PanelBody>
            </InspectorControls>
            
            <div {...blockProps}>
                <div className="product-price">
                    {attributes.showLabel && (
                        <span className="price-label">Price: </span>
                    )}
                    <span className="price-amount">{formattedPrice}</span>
                </div>
            </div>
        </>
    );
}
</code>

InnerBlocks: вложенные структуры

Продвинутые паттерны с InnerBlocks

На проекте лендинга создавал блок «Feature Grid» с динамическим количеством колонок:​

jsx<code>import { InnerBlocks, useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, RangeControl, SelectControl } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { createBlock } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';

export default function Edit({ attributes, setAttributes, clientId }) {
    const blockProps = useBlockProps({
        className: `columns-${attributes.columns}`
    });
    
    <em>// Получение текущих innerBlocks</em>
    const { innerBlocks } = useSelect((select) => {
        return {
            innerBlocks: select(blockEditorStore).getBlocks(clientId)
        };
    }, [clientId]);
    
    const { replaceInnerBlocks } = useDispatch(blockEditorStore);
    
    <em>// Автоматическое добавление/удаление колонок при изменении их количества</em>
    useEffect(() => {
        const currentCount = innerBlocks.length;
        const desiredCount = attributes.columns;
        
        if (currentCount === desiredCount) return;
        
        let newBlocks = [...innerBlocks];
        
        if (currentCount < desiredCount) {
            <em>// Добавляем недостающие блоки</em>
            const blocksToAdd = desiredCount - currentCount;
            for (let i = 0; i < blocksToAdd; i++) {
                newBlocks.push(createBlock('mytheme/feature-item'));
            }
        } else {
            <em>// Удаляем лишние блоки</em>
            newBlocks = newBlocks.slice(0, desiredCount);
        }
        
        replaceInnerBlocks(clientId, newBlocks);
    }, [attributes.columns, innerBlocks.length, clientId]);
    
    const ALLOWED_BLOCKS = ['mytheme/feature-item'];
    
    return (
        <>
            <InspectorControls>
                <PanelBody title="Grid Settings">
                    <RangeControl
                        label="Number of Columns"
                        value={attributes.columns}
                        onChange={(columns) => setAttributes({ columns })}
                        min={1}
                        max={6}
                    />
                    
                    <SelectControl
                        label="Gap Size"
                        value={attributes.gap}
                        options={[
                            { label: 'Small', value: 'small' },
                            { label: 'Medium', value: 'medium' },
                            { label: 'Large', value: 'large' }
                        ]}
                        onChange={(gap) => setAttributes({ gap })}
                    />
                </PanelBody>
            </InspectorControls>
            
            <div {...blockProps}>
                <InnerBlocks
                    allowedBlocks={ALLOWED_BLOCKS}
                    orientation="horizontal"
                    renderAppender={false}
                />
            </div>
        </>
    );
}
</code>

Block Variations: создание пресетов

Динамические вариации блока

Block Variations позволяют создавать пресконфигурированные версии блока:​

js<code>import { registerBlockVariation } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';

<em>// Регистрация вариаций для блока кнопки</em>
registerBlockVariation('core/button', {
    name: 'cta-primary',
    title: __('Primary CTA', 'mytheme'),
    description: __('Primary call-to-action button', 'mytheme'),
    icon: 'megaphone',
    attributes: {
        text: __('Get Started', 'mytheme'),
        className: 'is-style-primary-cta',
        backgroundColor: 'primary',
        textColor: 'white',
        fontSize: 'large',
        borderRadius: 50
    },
    scope: ['inserter', 'transform']
});

registerBlockVariation('core/button', {
    name: 'cta-secondary',
    title: __('Secondary CTA', 'mytheme'),
    attributes: {
        text: __('Learn More', 'mytheme'),
        className: 'is-style-secondary-cta',
        style: {
            border: {
                width: '2px'
            }
        }
    }
});

<em>// Вариации с innerBlocks</em>
registerBlockVariation('core/group', {
    name: 'hero-section',
    title: __('Hero Section', 'mytheme'),
    description: __('Pre-configured hero section layout', 'mytheme'),
    icon: 'cover-image',
    attributes: {
        align: 'full',
        className: 'hero-section',
        style: {
            spacing: {
                padding: {
                    top: '80px',
                    bottom: '80px'
                }
            }
        }
    },
    innerBlocks: [
        ['core/heading', {
            level: 1,
            content: __('Welcome to Our Site', 'mytheme'),
            textAlign: 'center'
        }],
        ['core/paragraph', {
            content: __('Your journey starts here', 'mytheme'),
            align: 'center',
            fontSize: 'large'
        }],
        ['core/buttons', {
            layout: { type: 'flex', justifyContent: 'center' }
        }, [
            ['core/button', {
                text: __('Get Started', 'mytheme')
            }]
        ]]
    ],
    scope: ['inserter']
});
</code>

Interactivity API: интерактивные блоки

Современный подход к интерактивности

В WordPress 6.5+ появился Interactivity API — декларативный подход к созданию интерактивных блоков без jQuery:​

view.js — логика взаимодействия:

js<code>import { store, getContext } from '@wordpress/interactivity';

const { state } = store('mytheme/accordion', {
    state: {
        get isAnyOpen() {
            return state.items.some(item => item.isOpen);
        },
        items: []
    },
    actions: {
        toggle: () => {
            const context = getContext();
            context.isOpen = !context.isOpen;
            
            <em>// Закрытие других элементов (accordion behavior)</em>
            if (context.isOpen && context.closeOthers) {
                state.items.forEach((item, index) => {
                    if (index !== context.index) {
                        item.isOpen = false;
                    }
                });
            }
        },
        openAll: () => {
            state.items.forEach(item => {
                item.isOpen = true;
            });
        },
        closeAll: () => {
            state.items.forEach(item => {
                item.isOpen = false;
            });
        }
    },
    callbacks: {
        logToggle: () => {
            const context = getContext();
            console.log(`Item ${context.index} toggled:`, context.isOpen);
        }
    }
});
</code>

render.php — разметка с директивами:

php<code><?php
$unique_id = wp_unique_id('accordion-');
$items = isset($attributes['items']) ? $attributes['items'] : array();

<em>// Инициализация состояния</em>
wp_interactivity_state('mytheme/accordion', array(
    'items' => array_map(function($item, $index) {
        return array(
            'isOpen' => false,
            'index' => $index
        );
    }, $items, array_keys($items))
));

$wrapper_attributes = get_block_wrapper_attributes(array(
    'class' => 'accordion-block',
    'data-wp-interactive' => 'mytheme/accordion'
));
?>

<div <?php echo $wrapper_attributes; ?>>
    <div class="accordion-controls">
        <button 
            data-wp-on--click="actions.openAll"
            class="accordion-control-btn"
        >
            <?php _e('Open All', 'mytheme'); ?>
        </button>
        <button 
            data-wp-on--click="actions.closeAll"
            class="accordion-control-btn"
        >
            <?php _e('Close All', 'mytheme'); ?>
        </button>
    </div>
    
    <div class="accordion-items">
        <?php foreach ($items as $index => $item): ?>
            <div 
                class="accordion-item"
                data-wp-context='<?php echo wp_json_encode(array(
                    'isOpen' => false,
                    'index' => $index,
                    'closeOthers' => $attributes['closeOthers'] ?? true
                )); ?>'
            >
                <button 
                    class="accordion-header"
                    data-wp-on--click="actions.toggle"
                    data-wp-on-async--click="callbacks.logToggle"
                    data-wp-class--is-open="context.isOpen"
                    aria-expanded="false"
                    data-wp-bind--aria-expanded="context.isOpen"
                >
                    <span><?php echo esc_html($item['title']); ?></span>
                    <svg 
                        class="accordion-icon"
                        data-wp-class--rotated="context.isOpen"
                        width="20" 
                        height="20" 
                        viewBox="0 0 20 20"
                    >
                        <path d="M5 7.5l5 5 5-5" stroke="currentColor" />
                    </svg>
                </button>
                
                <div 
                    class="accordion-content"
                    data-wp-bind--hidden="!context.isOpen"
                    data-wp-class--is-visible="context.isOpen"
                >
                    <div class="accordion-content-inner">
                        <?php echo wp_kses_post($item['content']); ?>
                    </div>
                </div>
            </div>
        <?php endforeach; ?>
    </div>
</div>
</code>

Продвинутая типизация с TypeScript

Typed блок с полной type-safety

typescript<code><em>// types.ts</em>
import { BlockEditProps } from '@wordpress/blocks';

export interface ProductAttributes {
    productId: number;
    showPrice: boolean;
    showDescription: boolean;
    layout: 'grid' | 'list';
    columns: number;
}

export interface Product {
    id: number;
    title: {
        rendered: string;
    };
    content: {
        rendered: string;
    };
    meta: {
        price: number;
        sku: string;
        stock: number;
    };
    featured_media: number;
}

export type ProductBlockEditProps = BlockEditProps<ProductAttributes>;

<em>// edit.tsx</em>
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, RangeControl, ToggleControl } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import type { ProductBlockEditProps, Product } from './types';

export default function Edit({ 
    attributes, 
    setAttributes 
}: ProductBlockEditProps): JSX.Element {
    const blockProps = useBlockProps();
    
    const product = useSelect<Product | null>(
        (select) => {
            if (!attributes.productId) return null;
            
            return select(coreStore).getEntityRecord<Product>(
                'postType',
                'product',
                attributes.productId
            );
        },
        [attributes.productId]
    );
    
    const handleColumnsChange = (columns: number): void => {
        setAttributes({ columns });
    };
    
    return (
        <>
            <InspectorControls>
                <PanelBody title="Settings">
                    <RangeControl
                        label="Columns"
                        value={attributes.columns}
                        onChange={handleColumnsChange}
                        min={1}
                        max={4}
                    />
                    
                    <ToggleControl
                        label="Show Price"
                        checked={attributes.showPrice}
                        onChange={(showPrice: boolean) => setAttributes({ showPrice })}
                    />
                </PanelBody>
            </InspectorControls>
            
            <div {...blockProps}>
                {product && (
                    <div className="product-display">
                        <h3>{product.title.rendered}</h3>
                        {attributes.showPrice && (
                            <span className="price">${product.meta.price}</span>
                        )}
                    </div>
                )}
            </div>
        </>
    );
}
</code>

Продвинутая разработка Gutenberg блоков в 2025 — это симбиоз React, WordPress API и глубокого понимания архитектуры редактора. Динамические блоки с серверным рендерингом решают проблемы производительности, useSelect/useDispatch дают полный контроль над данными, Block Context элегантно передает информацию между блоками, а Interactivity API делает блоки по-настоящему интерактивными без костылей. Начинайте с простых статических блоков, постепенно добавляйте динамику, изучайте TypeScript для type-safety, используйте Interactivity API для фронтенда. Каждый проект — это шаг к пониманию всей мощи современного Gutenberg.

Хотите узнать стоимость сайта?

Обсудим проект и рассчитаем стомость вашего сайта

    Нажимая на кнопку, вы даете согласие на обработку своих персональных данных

    This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

    Ваша заявка принята!

    Мы перезвоним вам в ближайшее время.