Building Dynamic Lists and Collections with data-wp-each

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-each references the array to iterate over
  • Inside the template, context.item provides 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:

  1. PHP renders the initial HTML with products encoded in data-wp-context
  2. JavaScript creates a derived getter filteredProducts that applies filtering and sorting
  3. data-wp-each iterates over the derived state, not the raw array
  4. 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-each renders templates for each item in an array, with automatic reactivity
  • Use context.item and context.index to access current iteration data
  • Always use immutable array updates (mapfilterslice) 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-each directives for hierarchical data structures
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.