Mastering Event Handling and DOM Interactions

The WordPress Interactivity API provides a declarative approach to event handling through the data-wp-on directive, eliminating the need for manual addEventListener calls and ensuring proper clean up. Whether you’re capturing clicks, keyboard input, form submissions, or touch gestures, data-wp-on offers a consistent pattern that integrates seamlessly with your state management architecture.

In this article, we’ll explore comprehensive event handling patterns—from basic interactions to advanced techniques like event delegation, preventing defaults, accessing event properties asynchronously, and implementing complex user interaction flows across your WordPress blocks.

Understanding data-wp-on: The Foundation

The data-wp-on directive connects DOM events to actions in your store, creating a reactive bridge between user interactions and state updates.

Basic Syntax and Event Registration

The fundamental pattern follows this structure:

<button
    data-wp-interactive="myPlugin"
    data-wp-on--click="actions.handleClick"
>
    Click Me
</button>

Key Concepts:

  • data-wp-on--[eventName] defines which event to listen for
  • The value references an action in your store
  • Event listeners are automatically registered during activation
  • Clean up is handled automatically when elements are removed

Defining Actions in Your Store

Actions receive the native DOM event object as their first parameter:

// view.js
import { store } from '@wordpress/interactivity';

store( 'myPlugin', {
    state: {
        clickCount: 0,
        lastClickTime: null
    },

    actions: {
        handleClick( event ) {
            const { state } = store( 'myPlugin' );

            // Access event properties
            console.log( 'Button clicked at:', event.clientX, event.clientY );

            // Update state
            state.clickCount += 1;
            state.lastClickTime = new Date().toISOString();
        }
    }
} );

The action receives the full event object, providing access to properties like targetcurrentTargetclientXclientY, and methods like preventDefault() and stopPropagation().

Common Event Types and Patterns

Mouse Events

Handle clicks, hovers, and mouse movements with these common events:

<div data-wp-interactive="myPlugin">
    <!-- Click Events -->
    <button data-wp-on--click="actions.handleClick">
        Click
    </button>

    <!-- Double Click -->
    <button data-wp-on--dblclick="actions.handleDoubleClick">
        Double Click
    </button>

    <!-- Mouse Enter/Leave (for hover effects) -->
    <div
        data-wp-on--mouseenter="actions.handleMouseEnter"
        data-wp-on--mouseleave="actions.handleMouseLeave"
        data-wp-class--hovered="state.isHovered"
    >
        Hover over me
    </div>

    <!-- Mouse Move (for tracking cursor position) -->
    <div
        data-wp-on--mousemove="actions.trackMousePosition"
        class="canvas"
    >
        <span>X: <span data-wp-text="state.mouseX"></span></span>
        <span>Y: <span data-wp-text="state.mouseY"></span></span>
    </div>
</div>
store( 'myPlugin', {
    state: {
        isHovered: false,
        mouseX: 0,
        mouseY: 0,
        clickCount: 0
    },

    actions: {
        handleClick() {
            const { state } = store( 'myPlugin' );
            state.clickCount += 1;
        },

        handleDoubleClick() {
            const { state } = store( 'myPlugin' );
            console.log( 'Double clicked!' );
        },

        handleMouseEnter() {
            const { state } = store( 'myPlugin' );
            state.isHovered = true;
        },

        handleMouseLeave() {
            const { state } = store( 'myPlugin' );
            state.isHovered = false;
        },

        trackMousePosition( event ) {
            const { state } = store( 'myPlugin' );
            state.mouseX = event.clientX;
            state.mouseY = event.clientY;
        }
    }
} );

Keyboard Events

Capture keyboard input for shortcuts, navigation, and form interactions:

<div data-wp-interactive="myPlugin">
    <!-- Key Press Detection -->
    <input
        type="text"
        data-wp-on--keydown="actions.handleKeyDown"
        data-wp-on--keyup="actions.handleKeyUp"
        placeholder="Type something..."
    />

    <!-- Keyboard Shortcut (Ctrl/Cmd + S) -->
    <div
        data-wp-on--keydown="actions.handleSaveShortcut"
        tabindex="0"
    >
        Press Ctrl/Cmd + S to save
    </div>

    <!-- Enter Key Submission -->
    <input
        type="text"
        data-wp-bind--value="state.searchQuery"
        data-wp-on--keydown="actions.handleSearchKeyDown"
        placeholder="Search (press Enter)"
    />
</div>
store( 'myPlugin', {
    state: {
        searchQuery: '',
        lastKey: '',
        isTyping: false
    },

    actions: {
        handleKeyDown( event ) {
            const { state } = store( 'myPlugin' );
            state.lastKey = event.key;
            state.isTyping = true;
        },

        handleKeyUp() {
            const { state } = store( 'myPlugin' );
            state.isTyping = false;
        },

        handleSaveShortcut( event ) {
            const { actions } = store( 'myPlugin' );

            // Check for Ctrl+S or Cmd+S
            if ( ( event.ctrlKey || event.metaKey ) && event.key === 's' ) {
                event.preventDefault(); // Prevent browser save dialog
                actions.saveData();
            }
        },

        handleSearchKeyDown( event ) {
            const { actions } = store( 'myPlugin' );

            if ( event.key === 'Enter' ) {
                event.preventDefault();
                actions.performSearch();
            }
        },

        performSearch() {
            const { state } = store( 'myPlugin' );
            console.log( 'Searching for:', state.searchQuery );
        },

        saveData() {
            console.log( 'Saving data...' );
        }
    }
} );

Accessibility Note: When implementing keyboard shortcuts, always provide alternative methods (buttons, menu items) to ensure accessibility for all users.

Form Events

Handle form interactions, validation, and submissions:

<form
    data-wp-interactive="myPlugin"
    data-wp-on--submit="actions.handleSubmit"
>
    <!-- Input Change Events -->
    <input
        type="text"
        name="username"
        data-wp-bind--value="state.formData.username"
        data-wp-on--input="actions.handleUsernameInput"
        data-wp-on--blur="actions.validateUsername"
    />
    <span
        data-wp-class--visible="state.errors.username"
        data-wp-text="state.errors.username"
        class="error"
    ></span>

    <!-- Checkbox Change -->
    <label>
        <input
            type="checkbox"
            data-wp-bind--checked="state.formData.subscribe"
            data-wp-on--change="actions.handleSubscribeChange"
        />
        Subscribe to newsletter
    </label>

    <!-- Select Change -->
    <select
        data-wp-bind--value="state.formData.country"
        data-wp-on--change="actions.handleCountryChange"
    >
        <option value="">Select country</option>
        <option value="us">United States</option>
        <option value="uk">United Kingdom</option>
        <option value="ca">Canada</option>
    </select>

    <!-- Submit Button -->
    <button
        type="submit"
        data-wp-bind--disabled="state.isSubmitting"
    >
        <span data-wp-text="state.isSubmitting ? 'Submitting...' : 'Submit'"></span>
    </button>
</form>
store( 'myPlugin', {
    state: {
        formData: {
            username: '',
            subscribe: false,
            country: ''
        },
        errors: {
            username: ''
        },
        isSubmitting: false
    },

    actions: {
        handleUsernameInput( event ) {
            const { state } = store( 'myPlugin' );
            state.formData.username = event.target.value;

            // Clear error when user starts typing
            if ( state.errors.username ) {
                state.errors.username = '';
            }
        },

        validateUsername() {
            const { state } = store( 'myPlugin' );
            const username = state.formData.username.trim();

            if ( username.length === 0 ) {
                state.errors.username = 'Username is required';
            } else if ( username.length < 3 ) {
                state.errors.username = 'Username must be at least 3 characters';
            } else {
                state.errors.username = '';
            }
        },

        handleSubscribeChange( event ) {
            const { state } = store( 'myPlugin' );
            state.formData.subscribe = event.target.checked;
        },

        handleCountryChange( event ) {
            const { state } = store( 'myPlugin' );
            state.formData.country = event.target.value;
        },

        async handleSubmit( event ) {
            event.preventDefault(); // Prevent default form submission

            const { state, actions } = store( 'myPlugin' );

            // Validate all fields
            actions.validateUsername();

            if ( state.errors.username ) {
                return;
            }

            state.isSubmitting = true;

            try {
                const response = await fetch( '/wp-json/myplugin/v1/submit', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify( state.formData )
                } );

                const result = await response.json();
                console.log( 'Form submitted successfully:', result );

                // Reset form
                state.formData = {
                    username: '',
                    subscribe: false,
                    country: ''
                };
            } catch ( error ) {
                console.error( 'Form submission failed:', error );
            } finally {
                state.isSubmitting = false;
            }
        }
    }
} );

Form Handling Best Practices:

  • Always call event.preventDefault() on form submissions to prevent page reloads
  • Validate on both blur (when user leaves field) and submit events
  • Provide immediate feedback for validation errors
  • Disable submit button during async operations

Touch Events

Support mobile interactions with touch event handlers:

<div
    data-wp-interactive="myPlugin"
    data-wp-on--touchstart="actions.handleTouchStart"
    data-wp-on--touchmove="actions.handleTouchMove"
    data-wp-on--touchend="actions.handleTouchEnd"
    class="swipeable"
>
    Swipe me
    <div data-wp-text="state.swipeDirection"></div>
</div>
store( 'myPlugin', {
    state: {
        touchStartX: 0,
        touchStartY: 0,
        swipeDirection: ''
    },

    actions: {
        handleTouchStart( event ) {
            const { state } = store( 'myPlugin' );
            const touch = event.touches[ 0 ];

            state.touchStartX = touch.clientX;
            state.touchStartY = touch.clientY;
        },

        handleTouchMove( event ) {
            // Optionally prevent scrolling during swipe
            // event.preventDefault();
        },

        handleTouchEnd( event ) {
            const { state } = store( 'myPlugin' );
            const touch = event.changedTouches[ 0 ];

            const deltaX = touch.clientX - state.touchStartX;
            const deltaY = touch.clientY - state.touchStartY;

            const minSwipeDistance = 50;

            if ( Math.abs( deltaX ) > Math.abs( deltaY ) ) {
                // Horizontal swipe
                if ( Math.abs( deltaX ) > minSwipeDistance ) {
                    state.swipeDirection = deltaX > 0 ? 'right' : 'left';
                }
            } else {
                // Vertical swipe
                if ( Math.abs( deltaY ) > minSwipeDistance ) {
                    state.swipeDirection = deltaY > 0 ? 'down' : 'up';
                }
            }
        }
    }
} );

Advanced Event Handling Patterns

Preventing Default Behaviour and Stopping Propagation

For synchronous event property access, use withSyncEvent():

import { store, withSyncEvent } from '@wordpress/interactivity';

store( 'myPlugin', {
    actions: {
        // Synchronous event handling for preventDefault
        preventDefaultLink: withSyncEvent( ( event ) => {
            event.preventDefault();
            console.log( 'Link click prevented' );
        } ),

        // Stop event propagation
        stopPropagation: withSyncEvent( ( event ) => {
            event.stopPropagation();
            console.log( 'Event stopped from bubbling' );
        } ),

        // Combined: prevent default and stop propagation
        handleCustomBehavior: withSyncEvent( ( event ) => {
            event.preventDefault();
            event.stopPropagation();

            const { actions } = store( 'myPlugin' );
            actions.performCustomAction();
        } ),

        performCustomAction() {
            console.log( 'Custom action performed' );
        }
    }
} );
<div data-wp-interactive="myPlugin">
    <!-- Prevent default link navigation -->
    <a
        href="/some-page"
        data-wp-on--click="actions.preventDefaultLink"
    >
        Click (won't navigate)
    </a>

    <!-- Stop event propagation -->
    <div data-wp-on--click="actions.handleParentClick">
        Parent
        <button data-wp-on--click="actions.stopPropagation">
            Child (won't trigger parent)
        </button>
    </div>
</div>

Important: Use withSyncEvent() when you need to call preventDefault()stopPropagation(), or access event properties like event.key before the action completes. This ensures synchronous event handling. See [Article 5: State Flow](The Interactivity API State Flow_ A Comprehensive Guide for Large-Scale Applications.md) for more on withSyncEvent().

Accessing Context and Element Data

Use getContext() and getElement() to access contextual data and DOM references:

import { store, getContext, getElement } from '@wordpress/interactivity';

store( 'myPlugin', {
    actions: {
        handleItemClick() {
            const context = getContext();
            const element = getElement();

            // Access item data from context
            console.log( 'Clicked item:', context.item );

            // Access DOM element
            console.log( 'Element:', element.ref );

            // Read data attributes
            const itemId = element.ref.dataset.itemId;
            console.log( 'Item ID:', itemId );
        }
    }
} );
<div data-wp-interactive="myPlugin">
    <template data-wp-each="state.items">
        <button
            data-wp-on--click="actions.handleItemClick"
            data-wp-bind--data-item-id="context.item.id"
        >
            <span data-wp-text="context.item.name"></span>
        </button>
    </template>
</div>

This pattern is especially useful within data-wp-each loops where you need to identify which item was clicked. See [Article 6: Building Dynamic Lists](Building Dynamic Lists and Collections with data-wp-each.md) for more list interaction patterns.

Event Delegation and Dynamic Content

The Interactivity API automatically handles event delegation—events on dynamically added elements work without re-registering listeners:

store( 'myPlugin', {
    state: {
        items: [ { id: 1, name: 'Item 1' } ]
    },

    actions: {
        addItem() {
            const { state } = store( 'myPlugin' );

            state.items = [
                ...state.items,
                {
                    id: Date.now(),
                    name: `Item ${ state.items.length + 1 }`
                }
            ];
        },

        removeItem() {
            const context = getContext();
            const { state } = store( 'myPlugin' );

            // Remove item by filtering
            state.items = state.items.filter(
                item => item.id !== context.item.id
            );
        }
    }
} );
<div data-wp-interactive="myPlugin">
    <button data-wp-on--click="actions.addItem">
        Add Item
    </button>

    <ul>
        <template data-wp-each="state.items">
            <li>
                <span data-wp-text="context.item.name"></span>
                <!-- This event handler works even for newly added items -->
                <button data-wp-on--click="actions.removeItem">
                    Remove
                </button>
            </li>
        </template>
    </ul>
</div>

Key Advantage: Unlike manual addEventListener, you don’t need to worry about attaching events to new DOM elements—the Interactivity API handles this automatically.

Debouncing and Throttling Events

For high-frequency events like scroll, resize, or input, implement debouncing or throttling:

store( 'myPlugin', {
    state: {
        searchQuery: '',
        searchResults: [],
        scrollPosition: 0,
        isSearching: false
    },

    actions: {
        // Debounced search (waits for user to stop typing)
        handleSearchInput( event ) {
            const { state, actions } = store( 'myPlugin' );

            state.searchQuery = event.target.value;

            // Clear existing timeout
            if ( state.searchTimeout ) {
                clearTimeout( state.searchTimeout );
            }

            // Set new timeout
            state.searchTimeout = setTimeout( () => {
                actions.performSearch();
            }, 300 ); // Wait 300ms after user stops typing
        },

        async performSearch() {
            const { state } = store( 'myPlugin' );

            if ( ! state.searchQuery.trim() ) {
                state.searchResults = [];
                return;
            }

            state.isSearching = true;

            try {
                const response = await fetch(
                    `/wp-json/myplugin/v1/search?q=${ encodeURIComponent( state.searchQuery ) }`
                );
                const results = await response.json();
                state.searchResults = results;
            } catch ( error ) {
                console.error( 'Search failed:', error );
            } finally {
                state.isSearching = false;
            }
        },

        // Throttled scroll (limits execution frequency)
        handleScroll( event ) {
            const { state, actions } = store( 'myPlugin' );

            // Only execute if enough time has passed
            if ( state.scrollThrottleTimeout ) {
                return;
            }

            state.scrollPosition = event.target.scrollTop;

            // Set throttle timeout
            state.scrollThrottleTimeout = setTimeout( () => {
                state.scrollThrottleTimeout = null;
            }, 100 ); // Execute at most once per 100ms
        }
    }
} );
<div data-wp-interactive="myPlugin">
    <!-- Debounced search input -->
    <input
        type="search"
        data-wp-bind--value="state.searchQuery"
        data-wp-on--input="actions.handleSearchInput"
        placeholder="Search..."
    />

    <div data-wp-class--visible="state.isSearching">
        Searching...
    </div>

    <!-- Throttled scroll handler -->
    <div
        data-wp-on--scroll="actions.handleScroll"
        class="scrollable-container"
    >
        <p>Scroll position: <span data-wp-text="state.scrollPosition"></span></p>
    </div>
</div>

Performance Tip: Debouncing is ideal for actions triggered by user input (search, validation), while throttling works better for continuous events (scroll, resize, mouse move).

Real-World Implementation: Interactive To-do List

Let’s combine event handling patterns into a complete to-do list with keyboard shortcuts, form validation, and item management:

<?php
// render.php
$initial_todos = array(
    array( 'id' => 1, 'title' => 'Learn Interactivity API', 'completed' => true ),
    array( 'id' => 2, 'title' => 'Build interactive block', 'completed' => false ),
);

wp_interactivity_state( 'myPlugin', array(
    'todos' => $initial_todos,
    'newTodoTitle' => '',
    'filter' => 'all', // all, active, completed
    'editingId' => null,
    'editingTitle' => '',
) );
?>

<div
    data-wp-interactive="myPlugin"
    class="todo-app"
    data-wp-on--keydown="actions.handleGlobalKeyDown"
>
    <!-- Add Todo Form -->
    <form data-wp-on--submit="actions.handleAddTodo">
        <input
            type="text"
            data-wp-bind--value="state.newTodoTitle"
            data-wp-on--input="actions.handleNewTodoInput"
            data-wp-on--keydown="actions.handleNewTodoKeyDown"
            placeholder="What needs to be done?"
            class="new-todo-input"
        />
        <button type="submit">Add</button>
    </form>

    <!-- Filter Buttons -->
    <div class="filters">
        <button
            data-wp-on--click="actions.setFilter"
            data-wp-bind--data-filter="all"
            data-wp-class--active="state.filter === 'all'"
        >
            All (<span data-wp-text="state.todos.length"></span>)
        </button>
        <button
            data-wp-on--click="actions.setFilter"
            data-wp-bind--data-filter="active"
            data-wp-class--active="state.filter === 'active'"
        >
            Active (<span data-wp-text="state.activeTodosCount"></span>)
        </button>
        <button
            data-wp-on--click="actions.setFilter"
            data-wp-bind--data-filter="completed"
            data-wp-class--active="state.filter === 'completed'"
        >
            Completed (<span data-wp-text="state.completedTodosCount"></span>)
        </button>
    </div>

    <!-- Todo List -->
    <ul class="todo-list">
        <template data-wp-each="state.filteredTodos">
            <li
                data-wp-class--completed="context.item.completed"
                data-wp-class--editing="state.editingId === context.item.id"
            >
                <div class="view">
                    <!-- Toggle Completion -->
                    <input
                        type="checkbox"
                        data-wp-bind--checked="context.item.completed"
                        data-wp-on--change="actions.toggleTodo"
                        data-wp-bind--data-todo-id="context.item.id"
                    />

                    <!-- Todo Title (double-click to edit) -->
                    <label
                        data-wp-text="context.item.title"
                        data-wp-on--dblclick="actions.startEditing"
                        data-wp-bind--data-todo-id="context.item.id"
                    ></label>

                    <!-- Delete Button -->
                    <button
                        data-wp-on--click="actions.deleteTodo"
                        data-wp-bind--data-todo-id="context.item.id"
                        class="delete"
                    >
                        ×
                    </button>
                </div>

                <!-- Edit Input (shown when editing) -->
                <input
                    type="text"
                    data-wp-bind--value="state.editingTitle"
                    data-wp-on--input="actions.handleEditInput"
                    data-wp-on--blur="actions.finishEditing"
                    data-wp-on--keydown="actions.handleEditKeyDown"
                    class="edit-input"
                />
            </li>
        </template>
    </ul>

    <!-- Bulk Actions -->
    <div class="bulk-actions">
        <button
            data-wp-on--click="actions.markAllComplete"
            data-wp-bind--disabled="state.activeTodosCount === 0"
        >
            Mark All Complete
        </button>
        <button
            data-wp-on--click="actions.clearCompleted"
            data-wp-bind--disabled="state.completedTodosCount === 0"
        >
            Clear Completed
        </button>
    </div>
</div>
// view.js
import { store, getElement, withSyncEvent } from '@wordpress/interactivity';

store( 'myPlugin', {
    state: {
        todos: [],
        newTodoTitle: '',
        filter: 'all',
        editingId: null,
        editingTitle: '',

        // Derived state for filtering
        get filteredTodos() {
            switch ( this.filter ) {
                case 'active':
                    return this.todos.filter( todo => ! todo.completed );
                case 'completed':
                    return this.todos.filter( todo => todo.completed );
                default:
                    return this.todos;
            }
        },

        get activeTodosCount() {
            return this.todos.filter( todo => ! todo.completed ).length;
        },

        get completedTodosCount() {
            return this.todos.filter( todo => todo.completed ).length;
        }
    },

    actions: {
        // Add new todo
        handleAddTodo: withSyncEvent( ( event ) => {
            event.preventDefault();

            const { state } = store( 'myPlugin' );
            const title = state.newTodoTitle.trim();

            if ( ! title ) {
                return;
            }

            state.todos = [
                ...state.todos,
                {
                    id: Date.now(),
                    title,
                    completed: false
                }
            ];

            state.newTodoTitle = '';
        } ),

        handleNewTodoInput( event ) {
            const { state } = store( 'myPlugin' );
            state.newTodoTitle = event.target.value;
        },

        // Handle Enter key in new todo input
        handleNewTodoKeyDown: withSyncEvent( ( event ) => {
            if ( event.key === 'Enter' ) {
                event.preventDefault();
                const { actions } = store( 'myPlugin' );
                actions.handleAddTodo( event );
            }
        } ),

        // Toggle todo completion
        toggleTodo( event ) {
            const { state } = store( 'myPlugin' );
            const todoId = parseInt( event.target.dataset.todoId, 10 );

            state.todos = state.todos.map( todo => {
                if ( todo.id !== todoId ) {
                    return todo;
                }
                return { ...todo, completed: ! todo.completed };
            } );
        },

        // Delete todo
        deleteTodo( event ) {
            const { state } = store( 'myPlugin' );
            const todoId = parseInt( event.target.dataset.todoId, 10 );

            state.todos = state.todos.filter( todo => todo.id !== todoId );
        },

        // Start editing (double-click)
        startEditing( event ) {
            const { state } = store( 'myPlugin' );
            const todoId = parseInt( event.target.dataset.todoId, 10 );
            const todo = state.todos.find( t => t.id === todoId );

            if ( todo ) {
                state.editingId = todoId;
                state.editingTitle = todo.title;

                // Focus input after render
                setTimeout( () => {
                    const input = event.target.closest( 'li' ).querySelector( '.edit-input' );
                    if ( input ) {
                        input.focus();
                    }
                }, 0 );
            }
        },

        handleEditInput( event ) {
            const { state } = store( 'myPlugin' );
            state.editingTitle = event.target.value;
        },

        // Finish editing (blur or Enter)
        finishEditing() {
            const { state } = store( 'myPlugin' );

            if ( state.editingId === null ) {
                return;
            }

            const newTitle = state.editingTitle.trim();

            if ( newTitle ) {
                state.todos = state.todos.map( todo => {
                    if ( todo.id !== state.editingId ) {
                        return todo;
                    }
                    return { ...todo, title: newTitle };
                } );
            }

            state.editingId = null;
            state.editingTitle = '';
        },

        // Handle keyboard in edit mode
        handleEditKeyDown: withSyncEvent( ( event ) => {
            const { state, actions } = store( 'myPlugin' );

            if ( event.key === 'Enter' ) {
                event.preventDefault();
                actions.finishEditing();
            } else if ( event.key === 'Escape' ) {
                event.preventDefault();
                state.editingId = null;
                state.editingTitle = '';
            }
        } ),

        // Filter actions
        setFilter( event ) {
            const { state } = store( 'myPlugin' );
            const filter = event.target.dataset.filter;
            state.filter = filter;
        },

        // Bulk actions
        markAllComplete() {
            const { state } = store( 'myPlugin' );
            state.todos = state.todos.map( todo => ( {
                ...todo,
                completed: true
            } ) );
        },

        clearCompleted() {
            const { state } = store( 'myPlugin' );
            state.todos = state.todos.filter( todo => ! todo.completed );
        },

        // Global keyboard shortcuts
        handleGlobalKeyDown: withSyncEvent( ( event ) => {
            const { actions } = store( 'myPlugin' );

            // Ctrl/Cmd + K: Focus new todo input
            if ( ( event.ctrlKey || event.metaKey ) && event.key === 'k' ) {
                event.preventDefault();
                const input = document.querySelector( '.new-todo-input' );
                if ( input ) {
                    input.focus();
                }
            }

            // Ctrl/Cmd + Shift + C: Clear completed
            if ( ( event.ctrlKey || event.metaKey ) && event.shiftKey && event.key === 'C' ) {
                event.preventDefault();
                actions.clearCompleted();
            }
        } )
    }
} );

This implementation demonstrates:

  • Form submission with validation
  • Keyboard shortcuts (Enter to submit, Escape to cancel, Ctrl+K to focus)
  • Inline editing with double-click activation
  • Event delegation for dynamic list items
  • Derived state for filtering and counts
  • Immutable updates for all state modifications
  • Accessibility through proper form elements and keyboard navigation

Common Pitfalls and Best Practices

Event Handler Naming Conventions

Use descriptive action names that indicate what happens, not just the event:

// ❌ Generic naming
actions: {
    click() { /* ... */ },
    input() { /* ... */ }
}

// ✅ Descriptive naming
actions: {
    addItemToCart() { /* ... */ },
    updateSearchQuery() { /* ... */ }
}

When to Use withSyncEvent

Only use withSyncEvent() when you need synchronous event access:

// ✅ Use withSyncEvent for preventDefault/stopPropagation
handleFormSubmit: withSyncEvent( ( event ) => {
    event.preventDefault();
    // Handle submission
} )

// ✅ Use withSyncEvent for event properties needed immediately
handleKeyPress: withSyncEvent( ( event ) => {
    if ( event.key === 'Enter' ) {
        // Must check key synchronously
    }
} )

// ❌ Don't use withSyncEvent unnecessarily
handleClick() {
    // No event properties needed, regular action works fine
    const { state } = store( 'myPlugin' );
    state.count += 1;
}

Memory Leaks and Clean up

The Interactivity API handles clean up automatically, but be careful with timers:

actions: {
    startPolling() {
        const { state } = store( 'myPlugin' );

        // Store timer reference in state
        state.pollingInterval = setInterval( () => {
            // Polling logic
        }, 5000 );
    },

    stopPolling() {
        const { state } = store( 'myPlugin' );

        if ( state.pollingInterval ) {
            clearInterval( state.pollingInterval );
            state.pollingInterval = null;
        }
    }
}

Handling Rapid Events

For events that fire rapidly (scroll, mousemove, input), implement rate limiting:

// Use debouncing for user input
handleSearchInput( event ) {
    const { state, actions } = store( 'myPlugin' );

    state.searchQuery = event.target.value;

    clearTimeout( state.searchTimeout );
    state.searchTimeout = setTimeout( () => {
        actions.performSearch();
    }, 300 );
}

// Use throttling for scroll/resize
handleScroll( event ) {
    const { state } = store( 'myPlugin' );

    if ( state.scrollThrottled ) {
        return;
    }

    state.scrollThrottled = true;
    state.scrollPosition = event.target.scrollTop;

    setTimeout( () => {
        state.scrollThrottled = false;
    }, 100 );
}

Conclusion

The data-wp-on directive provides a declarative, maintainable approach to event handling in WordPress blocks. By connecting DOM events directly to store actions, you create reactive interfaces that respond naturally to user interactions, while keeping your code organized and testable.

Key Takeaways:

  • Use data-wp-on--[eventName] to connect events to actions
  • Access event objects, context, and DOM elements within actions
  • Use withSyncEvent() for preventDefault()stopPropagation(), and synchronous event properties
  • Implement debouncing for user input and throttling for continuous events
  • Event delegation is automatic—no manual listener management needed
  • Combine event handling with derived state for complex interactive features
Wp block editor book nobg

A comprehensive guide


Master WordPress Block Development

Everything you need to master blocks in one place, from the first block to enterprise architecture.

60-Day 100% Money-Back Guarantee. Zero risk.