The WordPress Interactivity API introduces data-wp-each, a powerful directive for rendering dynamic lists that adapt to state changes in real time. Whether you’re building a product catalogue, task list, or social media feed, data-wp-each provides a declarative approach to list rendering that eliminates manual DOM manipulation.
In this article, we’ll explore how to leverage data-wp-each for efficient list rendering, understand its performance characteristics, and implement advanced patterns like filtering, sorting, and pagination—all while maintaining WordPress integration best practices.
Understanding data-wp-each: The Fundamentals
The data-wp-each directive iterates over arrays in your state, rendering a template for each item. Unlike traditional PHP loops, data-wp-each creates reactive lists that automatically update when the underlying data changes.
Basic Syntax and Structure
Here’s the essential pattern for rendering lists with data-wp-each:
<ul data-wp-interactive="myPlugin" data-wp-context='{ "items": [] }'>
<template data-wp-each="context.items">
<li data-wp-text="context.item.name"></li>
</template>
</ul>Key Concepts:
- The
<template>tag serves as the rendering blueprint data-wp-eachreferences the array to iterate over- Inside the template,
context.itemprovides access to the current element - The template itself is not rendered—only its content is repeated
Context Hierarchy and Item Access
When data-wp-each renders items, it creates a nested context structure:
// view.js
import { store } from '@wordpress/interactivity';
store( 'myPlugin', {
state: {
products: [
{ id: 1, name: 'WordPress Mug', price: 15.99 },
{ id: 2, name: 'Theme T-Shirt', price: 24.99 },
{ id: 3, name: 'Plugin Sticker Pack', price: 5.99 }
]
}
} );<!-- Block template -->
<div data-wp-interactive="myPlugin">
<ul data-wp-context='{ "products": [] }'>
<template data-wp-each="context.products">
<li>
<span data-wp-text="context.item.name"></span>
<span data-wp-text="context.item.price"></span>
</li>
</template>
</ul>
</div>The context.item provides access to each array element during iteration. You can also access the current index using context.index.
Setting Up List Rendering in WordPress Blocks
To implement list rendering in a WordPress block, you need to coordinate PHP initialization with JavaScript interactivity.
PHP: Server-Side Rendering
In your block’s render.php, initialize the list data:
<?php
/**
* Render callback for the product list block
*
* @param array $attributes Block attributes
* @return string Rendered HTML
*/
// Fetch products (from database, API, or static data)
$products = array(
array( 'id' => 1, 'name' => 'WordPress Mug', 'price' => 15.99, 'inStock' => true ),
array( 'id' => 2, 'name' => 'Theme T-Shirt', 'price' => 24.99, 'inStock' => true ),
array( 'id' => 3, 'name' => 'Plugin Sticker Pack', 'price' => 5.99, 'inStock' => false ),
);
// Initialize state for client-side hydration
wp_interactivity_state( 'myPlugin', array(
'products' => $products,
'sortOrder' => 'asc',
'filterInStock' => false,
) );
// Encode products for initial context
$products_json = wp_json_encode( $products );
?>
<div
data-wp-interactive="myPlugin"
data-wp-context='<?php echo $products_json; ?>'
class="product-list"
>
<div class="product-controls">
<button data-wp-on--click="actions.toggleSort">
Sort by Price
</button>
<label>
<input
type="checkbox"
data-wp-bind--checked="state.filterInStock"
data-wp-on--change="actions.toggleStockFilter"
/>
In Stock Only
</label>
</div>
<ul class="products">
<template data-wp-each="state.filteredProducts">
<li class="product-item">
<h3 data-wp-text="context.item.name"></h3>
<p class="price" data-wp-text="context.item.price"></p>
<span
data-wp-class--out-of-stock="!context.item.inStock"
data-wp-text="context.item.inStock ? 'In Stock' : 'Out of Stock'"
></span>
</li>
</template>
</ul>
</div>JavaScript: Client-Side Interactivity
Define the store with actions and derived state:
// view.js
import { store } from '@wordpress/interactivity';
store( 'myPlugin', {
state: {
products: [],
sortOrder: 'asc',
filterInStock: false,
// Derived state: filtered and sorted products
get filteredProducts() {
let products = [ ...this.products ];
// Apply stock filter
if ( this.filterInStock ) {
products = products.filter( product => product.inStock );
}
// Apply sorting
products.sort( ( a, b ) => {
const order = this.sortOrder === 'asc' ? 1 : -1;
return ( a.price - b.price ) * order;
} );
return products;
}
},
actions: {
toggleSort() {
const { state } = store( 'myPlugin' );
state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc';
},
toggleStockFilter() {
const { state } = store( 'myPlugin' );
state.filterInStock = !state.filterInStock;
}
}
} );What’s Happening Here:
- PHP renders the initial HTML with products encoded in
data-wp-context - JavaScript creates a derived getter
filteredProductsthat applies filtering and sorting data-wp-eachiterates over the derived state, not the raw array- User interactions trigger actions that update state, automatically re-rendering the list
This pattern demonstrates a key advantage of the Interactivity API: business logic lives in derived state, keeping your actions simple and your UI reactive. See [Article 2: Complex State Logic with Getters](Beyond the Basics_ Implementing Complex State Logic with Interactivity API Getters.md) for more on derived state patterns.
Advanced List Patterns
Adding and Removing Items
Modifying lists requires immutable updates to ensure the Interactivity API detects changes:
store( 'myPlugin', {
state: {
tasks: [],
newTaskTitle: ''
},
actions: {
addTask() {
const { state } = store( 'myPlugin' );
if ( ! state.newTaskTitle.trim() ) {
return;
}
// Create new array with added item (immutable pattern)
state.tasks = [
...state.tasks,
{
id: Date.now(),
title: state.newTaskTitle,
completed: false
}
];
// Clear input
state.newTaskTitle = '';
},
removeTask( taskId ) {
const { state } = store( 'myPlugin' );
// Create new array without the removed item
state.tasks = state.tasks.filter( task => task.id !== taskId );
},
updateTaskTitle( taskId, newTitle ) {
const { state } = store( 'myPlugin' );
// Create new array with updated item
state.tasks = state.tasks.map( task => {
if ( task.id !== taskId ) {
return task;
}
return { ...task, title: newTitle };
} );
}
}
} );<div data-wp-interactive="myPlugin">
<div class="task-input">
<input
type="text"
data-wp-bind--value="state.newTaskTitle"
data-wp-on--input="actions.updateNewTaskTitle"
placeholder="New task title"
/>
<button data-wp-on--click="actions.addTask">Add Task</button>
</div>
<ul class="task-list">
<template data-wp-each="state.tasks">
<li class="task-item">
<span data-wp-text="context.item.title"></span>
<button
data-wp-on--click="actions.removeTask"
data-wp-bind--data-task-id="context.item.id"
>
Delete
</button>
</li>
</template>
</ul>
</div>Important: Always create new arrays when modifying lists. Direct mutations like state.tasks.push() or state.tasks[0] = newItem won’t trigger re-renders. See [Article 4: Managing Independent Block Instances](Managing Independent Block Instances with Local Context and Nested Data in WordPress.md) for more on immutability patterns.
Accessing Index and Parent Context
The context.index property provides the current iteration index:
<template data-wp-each="state.items">
<li>
<span data-wp-text="context.index"></span>.
<span data-wp-text="context.item.name"></span>
<!-- Move up/down buttons -->
<button
data-wp-on--click="actions.moveUp"
data-wp-bind--disabled="context.index === 0"
data-wp-bind--data-index="context.index"
>
↑
</button>
<button
data-wp-on--click="actions.moveDown"
data-wp-bind--data-index="context.index"
>
↓
</button>
</li>
</template>actions: {
moveUp( event ) {
const { state } = store( 'myPlugin' );
const index = parseInt( event.target.dataset.index, 10 );
if ( index === 0 ) {
return;
}
const newItems = [ ...state.items ];
[ newItems[ index - 1 ], newItems[ index ] ] =
[ newItems[ index ], newItems[ index - 1 ] ];
state.items = newItems;
},
moveDown( event ) {
const { state } = store( 'myPlugin' );
const index = parseInt( event.target.dataset.index, 10 );
if ( index >= state.items.length - 1 ) {
return;
}
const newItems = [ ...state.items ];
[ newItems[ index ], newItems[ index + 1 ] ] =
[ newItems[ index + 1 ], newItems[ index ] ];
state.items = newItems;
}
}Nested Lists and Hierarchical Data
For hierarchical data structures like categories with products, nest data-wp-each directives:
state: {
categories: [
{
id: 1,
name: 'Apparel',
products: [
{ id: 101, name: 'T-Shirt', price: 24.99 },
{ id: 102, name: 'Hoodie', price: 49.99 }
]
},
{
id: 2,
name: 'Accessories',
products: [
{ id: 201, name: 'Mug', price: 15.99 },
{ id: 202, name: 'Stickers', price: 5.99 }
]
}
]
}<div data-wp-interactive="myPlugin">
<template data-wp-each="state.categories">
<div class="category">
<h2 data-wp-text="context.item.name"></h2>
<ul class="products">
<template data-wp-each="context.item.products">
<li class="product">
<span data-wp-text="context.item.name"></span>
<span data-wp-text="context.item.price"></span>
</li>
</template>
</ul>
</div>
</template>
</div>Context Scoping: Within nested loops, context.item refers to the innermost iteration. To access parent context, store references in local variables or use data attributes.
Performance Optimization for Large Lists
Virtual Scrolling and Pagination
For lists with hundreds or thousands of items, render only visible items:
store( 'myPlugin', {
state: {
allItems: [], // Full dataset
itemsPerPage: 20,
currentPage: 1,
get visibleItems() {
const start = ( this.currentPage - 1 ) * this.itemsPerPage;
const end = start + this.itemsPerPage;
return this.allItems.slice( start, end );
},
get totalPages() {
return Math.ceil( this.allItems.length / this.itemsPerPage );
},
get hasPreviousPage() {
return this.currentPage > 1;
},
get hasNextPage() {
return this.currentPage < this.totalPages;
}
},
actions: {
nextPage() {
const { state } = store( 'myPlugin' );
if ( state.hasNextPage ) {
state.currentPage += 1;
}
},
previousPage() {
const { state } = store( 'myPlugin' );
if ( state.hasPreviousPage ) {
state.currentPage -= 1;
}
},
goToPage( pageNumber ) {
const { state } = store( 'myPlugin' );
if ( pageNumber >= 1 && pageNumber <= state.totalPages ) {
state.currentPage = pageNumber;
}
}
}
} );<div data-wp-interactive="myPlugin">
<ul>
<template data-wp-each="state.visibleItems">
<li data-wp-text="context.item.name"></li>
</template>
</ul>
<div class="pagination">
<button
data-wp-on--click="actions.previousPage"
data-wp-bind--disabled="!state.hasPreviousPage"
>
Previous
</button>
<span>
Page <span data-wp-text="state.currentPage"></span>
of <span data-wp-text="state.totalPages"></span>
</span>
<button
data-wp-on--click="actions.nextPage"
data-wp-bind--disabled="!state.hasNextPage"
>
Next
</button>
</div>
</div>Lazy Loading and Infinite Scroll
For dynamic data fetching, combine pagination with asynchronous actions:
import { store, getElement } from '@wordpress/interactivity';
store( 'myPlugin', {
state: {
items: [],
page: 1,
hasMore: true,
isLoading: false
},
actions: {
async loadMore() {
const { state } = store( 'myPlugin' );
if ( state.isLoading || ! state.hasMore ) {
return;
}
state.isLoading = true;
try {
const response = await fetch(
`/wp-json/myplugin/v1/items?page=${ state.page }`
);
const data = await response.json();
// Append new items to existing list
state.items = [ ...state.items, ...data.items ];
state.page += 1;
state.hasMore = data.hasMore;
} catch ( error ) {
console.error( 'Failed to load items:', error );
} finally {
state.isLoading = false;
}
},
// Intersection Observer pattern for infinite scroll
handleIntersection() {
const { state, actions } = store( 'myPlugin' );
const element = getElement();
// Check if sentinel element is visible
if ( element.ref && ! state.isLoading ) {
actions.loadMore();
}
}
}
} );<div data-wp-interactive="myPlugin">
<ul>
<template data-wp-each="state.items">
<li data-wp-text="context.item.name"></li>
</template>
</ul>
<!-- Sentinel element for intersection observer -->
<div
data-wp-init="callbacks.observeIntersection"
data-wp-class--hidden="!state.hasMore"
>
<span data-wp-text="state.isLoading ? 'Loading...' : 'Load More'"></span>
</div>
</div>Performance Note: The Interactivity API efficiently updates only the DOM elements that changed. When you add items to a list, existing items remain untouched, minimizing reflows and repaints.
Real-World Implementation: Filterable Product Grid
Let’s combine everything into a complete example—a product grid with search, filtering, and sorting:
<?php
// render.php
$products = get_posts( array(
'post_type' => 'product',
'posts_per_page' => -1,
) );
$products_data = array_map( function( $post ) {
return array(
'id' => $post->ID,
'title' => $post->post_title,
'price' => get_post_meta( $post->ID, 'price', true ),
'category' => get_the_terms( $post->ID, 'product_category' )[0]->name ?? '',
'inStock' => (bool) get_post_meta( $post->ID, 'in_stock', true ),
'image' => get_the_post_thumbnail_url( $post->ID, 'medium' ),
);
}, $products );
$categories = array_unique( array_column( $products_data, 'category' ) );
wp_interactivity_state( 'myPlugin', array(
'products' => $products_data,
'categories' => $categories,
'searchQuery' => '',
'selectedCategory' => '',
'sortBy' => 'title',
'sortOrder' => 'asc',
) );
?>
<div data-wp-interactive="myPlugin" class="product-grid-block">
<div class="filters">
<input
type="search"
placeholder="Search products..."
data-wp-bind--value="state.searchQuery"
data-wp-on--input="actions.updateSearch"
/>
<select
data-wp-bind--value="state.selectedCategory"
data-wp-on--change="actions.updateCategory"
>
<option value="">All Categories</option>
<?php foreach ( $categories as $category ) : ?>
<option value="<?php echo esc_attr( $category ); ?>">
<?php echo esc_html( $category ); ?>
</option>
<?php endforeach; ?>
</select>
<select
data-wp-bind--value="state.sortBy"
data-wp-on--change="actions.updateSort"
>
<option value="title">Name</option>
<option value="price">Price</option>
</select>
</div>
<div class="product-count">
Showing <span data-wp-text="state.filteredProducts.length"></span> products
</div>
<div class="products">
<template data-wp-each="state.filteredProducts">
<article class="product-card">
<img
data-wp-bind--src="context.item.image"
data-wp-bind--alt="context.item.title"
/>
<h3 data-wp-text="context.item.title"></h3>
<p class="price" data-wp-text="context.item.price"></p>
<span
data-wp-class--out-of-stock="!context.item.inStock"
data-wp-text="context.item.inStock ? 'In Stock' : 'Out of Stock'"
></span>
</article>
</template>
</div>
</div>// view.js
import { store } from '@wordpress/interactivity';
store( 'myPlugin', {
state: {
products: [],
categories: [],
searchQuery: '',
selectedCategory: '',
sortBy: 'title',
sortOrder: 'asc',
get filteredProducts() {
let products = [ ...this.products ];
// Apply search filter
if ( this.searchQuery.trim() ) {
const query = this.searchQuery.toLowerCase();
products = products.filter( product =>
product.title.toLowerCase().includes( query )
);
}
// Apply category filter
if ( this.selectedCategory ) {
products = products.filter(
product => product.category === this.selectedCategory
);
}
// Apply sorting
products.sort( ( a, b ) => {
let comparison = 0;
if ( this.sortBy === 'price' ) {
comparison = a.price - b.price;
} else {
comparison = a.title.localeCompare( b.title );
}
return this.sortOrder === 'asc' ? comparison : -comparison;
} );
return products;
}
},
actions: {
updateSearch( event ) {
const { state } = store( 'myPlugin' );
state.searchQuery = event.target.value;
},
updateCategory( event ) {
const { state } = store( 'myPlugin' );
state.selectedCategory = event.target.value;
},
updateSort( event ) {
const { state } = store( 'myPlugin' );
state.sortBy = event.target.value;
}
}
} );This implementation showcases:
- Derived state for complex filtering logic (see [Article 2](Beyond the Basics_ Implementing Complex State Logic with Interactivity API Getters.md))
- Multiple filter criteria working in harmony
- Immutable patterns for state updates
- Server-side rendering with PHP providing initial data
- Reactive UI that updates automatically as filters change
Common Pitfalls and Troubleshooting
Lists Not Updating
Problem: You modify the array, but the UI doesn’t update.
Solution: Ensure you’re creating a new array reference:
// ❌ Wrong: Direct mutation
state.items.push( newItem );
state.items[ 0 ].title = 'Updated';
// ✅ Correct: Create new array
state.items = [ ...state.items, newItem ];
state.items = state.items.map( ( item, i ) =>
i === 0 ? { ...item, title: 'Updated' } : item
);Performance Issues with Large Lists
Problem: Rendering thousands of items causes lag.
Solution: Implement pagination or virtualization:
- Render only visible items (20-50 at a time)
- Use derived state to slice the array:
get visibleItems() { return this.items.slice(0, 50); } - Implement “Load More” or infinite scroll patterns
Context Scope Confusion
Problem: context.item refers to the wrong data in nested loops.
Solution: Use descriptive context names or data attributes:
<template data-wp-each="state.categories">
<div data-wp-bind--data-category-id="context.item.id">
<h2 data-wp-text="context.item.name"></h2>
<template data-wp-each="context.item.products">
<div>
<!-- context.item now refers to product, not category -->
<span data-wp-text="context.item.name"></span>
</div>
</template>
</div>
</template>Server-Side vs Client-Side State Mismatch
Problem: List appears different on initial load vs. after interaction.
Solution: Ensure consistency between PHP rendering and JavaScript state:
<?php
// In render.php
$initial_items = array( /* ... */ );
wp_interactivity_state( 'myPlugin', array( 'items' => $initial_items ) );
?>
<div data-wp-context='<?php echo wp_json_encode( array( 'items' => $initial_items ) ); ?>'>
<!-- Use same data source for both SSR and client-side -->
</div>See [Article 3: SSR and Hydration](Server-Side Rendering (SSR) and Hydration_ A Deep Dive into Interactivity API Performance in WordPress.md) for more on maintaining SSR/client consistency.
Conclusion
The data-wp-each directive transforms static WordPress blocks into dynamic, reactive interfaces. By combining declarative list rendering with derived state patterns, you can build sophisticated UIs—product grids, task lists, data tables—without complex DOM manipulation code.
Key Takeaways:
data-wp-eachrenders templates for each item in an array, with automatic reactivity- Use
context.itemandcontext.indexto access current iteration data - Always use immutable array updates (
map,filter,slice) to trigger re-renders - Leverage derived state getters for filtering, sorting, and transforming lists
- Optimize large lists with pagination, virtualization, or lazy loading
- Nest
data-wp-eachdirectives for hierarchical data structures
