How to Create a Repeater Field in Gutenberg

spiral concrete staircase
Photo by Tine Ivanič on Unsplash

Creating a repeater field for Gutenberg is tough. I tried following a tutorial on how to create a repeater field in Gutenberg, but it took a nose-dive into React and repeater creation to really get me what I really wanted to achieve. In this tutorial, I’m going to use a sample plugin called Browser Shots Carousel to show you how I created a repeater field in Gutenberg.

Step 1: Setting up the Block

I used Create Guten Block to lay the foundation for setting up the block. You’ll notice in my source code that it creates an SRC folder and a BLOCKS folder within for block initialization.

Here’s how I register my block.

/**
 * BLOCK: browser-shots-carousel
 *
 * Registering a basic block with Gutenberg.
 * Simple block, renders and saves the same content without any interactivity.
 */

//  Import CSS.
import './style.scss';
import './editor.scss';

import edit from './edit';


const { __ } = wp.i18n; // Import __() from wp.i18n
const { registerBlockType } = wp.blocks; // Import registerBlockType() from wp.blocks

/**
 * Register: aa Gutenberg Block.
 *
 * Registers a new block provided a unique name and an object defining its
 * behavior. Once registered, the block is made editor as an option to any
 * editor interface where blocks are implemented.
 *
 * @link https://kunder/mediar_10676/mediar_13554/public.org/gutenberg/handbook/block-api/
 * @param  {string}   name     Block name.
 * @param  {Object}   settings Block settings.
 * @return {?WPBlock}          The block, if it has been successfully
 *                             registered; otherwise `undefined`.
 */
registerBlockType( 'browser-shots/browser-shots-carousel', {
	// Block name. Block names must be string that contains a namespace prefix. Example: my-plugin/my-custom-block.
	title: __( 'Browser Shots Carousel', 'browser-shots-carousel' ), // Block title.
	icon: <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
		<g fill-rule="evenodd">
			<path d="M18,5 L4,5 L4,19 L2,19 L2,5 C2,3.8954305 2.8954305,3 4,3 L18,3 L18,5 Z" />
			<path fill-rule="nonzero" d="M10,9 L11,7 L17,7 L18,9 L20,9 C21.1045695,9 22,9.8954305 22,11 L22,19 C22,20.1045695 21.1045695,21 20,21 L8,21 C6.8954305,21 6,20.1045695 6,19 L6,11 C6,9.8954305 6.8954305,9 8,9 L10,9 Z M12,9 L11,11 L8,11 L8,19 L20,19 L20,11 L17,11 L16,9 L12,9 Z" />
			<path fill-rule="nonzero" d="M14,18 C15.6568542,18 17,16.6568542 17,15 C17,13.3431458 15.6568542,12 14,12 C12.3431458,12 11,13.3431458 11,15 C11,16.6568542 12.3431458,18 14,18 Z M14,16 C13.4477153,16 13,15.5522847 13,15 C13,14.4477153 13.4477153,14 14,14 C14.5522847,14 15,14.4477153 15,15 C15,15.5522847 14.5522847,16 14,16 Z" />
		</g>
	</svg>,
	category: 'embed', // Block category — Group blocks together based on common traits E.g. common, formatting, layout widgets, embed.
	keywords: [
		__( 'Browser Shots', 'browser-shots-carousel' ),
		__( 'website', 'browser-shots-carousel' ),
		__( 'screenshot', 'browser-shots-carousel' )
	],
	supports: {
		align: [ 'left', 'center', 'right' ],
	},
	edit: edit,
	save() {
		return null;
	}
} );
Code language: JavaScript (javascript)

You might have noticed that I place my edit code in another file, which I import. I like it that way to keep my edit argument clean and then use the save function to render my output in PHP via a render callback.

I like setting up my block attributes in PHP, so in the main plugin file, I include a link to my main Gutenberg plugin class. Here’s what the plugin looks like so far for the main file.

<?php
/**
 * Plugin Name: Browser Shots Carousel
 * Plugin URI: https://mediaron.com/downloads/browser-shots-carousel/
 * Description: Show off your website screenshots in a carousel format.
 * Version: 1.0.0
 * Author: Ronald Huereca, Ben Gilbanks
 * Author URI: https://mediaron.com
 * Requires at least: 5.0
 * Contributors: ronalfy
 * Text Domain: browser-shots-carousel
 * Domain Path: /languages
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

define( 'BROWSER_SHOTS_CAROUSEL_PLUGIN_NAME', 'Browser Shots Carousel' );
define( 'BROWSER_SHOTS_CAROUSEL_DIR', plugin_dir_path( __FILE__ ) );
define( 'BROWSER_SHOTS_CAROUSEL_URL', plugins_url( '/', __FILE__ ) );
define( 'BROWSER_SHOTS_CAROUSEL_VERSION', '1.0.2' );
define( 'BROWSER_SHOTS_CAROUSEL_SLUG', plugin_basename( __FILE__ ) );
define( 'BROWSER_SHOTS_CAROUSEL_FILE', __FILE__ );


/**
 * Block Registration and Output.
 */
require_once BROWSER_SHOTS_CAROUSEL_DIR . 'src/block/class-browser-shots-carousel.php';

/**
 * Load the plugin i18n.
 */
function browser_shots_carousel_load_plugin_text_domain() {
	load_plugin_textdomain( 'browser-shots-carousel', false, basename( dirname( __FILE__ ) ) . '/languages' );
}
add_action( 'plugins_loaded', 'browser_shots_carousel_load_plugin_text_domain' );
Code language: PHP (php)

And in class-browser-shots-carousel.php, here’s how I initialize my attributes.

/**
 * Register Gutenberg block on server-side.
 *
 * Register the block on server-side to ensure that the block
 * scripts and styles for both frontend and backend are
 * enqueued when the editor loads.
 *
 * @link https://kunder/mediar_10676/mediar_13554/public.org/gutenberg/handbook/blocks/writing-your-first-block-type#enqueuing-block-scripts
 * @since 1.16.0
 */
register_block_type(
	'browser-shots/browser-shots-carousel',
	array(
		// Enqueue blocks.style.build.css on both frontend & backend.
		'style'           => 'browser_shots_carousel',
		// Enqueue blocks.build.js in the editor only.
		'editor_script'   => array( 'browser_shots_carousel', 'nivo-slider' ),
		// Enqueue blocks.editor.build.css in the editor only.
		'editor_style'    => array( 'browser_shots_carousel_editor', 'nivo-slider', 'nivo-slider-theme-default' ),
		'attributes'      => array(
			'theme'        => array(
				'type'    => 'string',
				'default' => 'default',
			),
			'effect'       => array(
				'type'    => 'string',
				'default' => 'random',
			),
			'directionNav' => array(
				'type'    => 'boolean',
				'default' => true,
			),
			'controlNav'   => array(
				'type'    => 'boolean',
				'default' => true,
			),
			'lightbox'     => array(
				'type'    => 'boolean',
				'default' => true,
			),
			'slides'       => array(
				'type'    => 'array',
				'default' => '',
			),
			'html'         => array(
				'type'    => 'string',
				'default' => '',
			),
			'width'        => array(
				'type'    => 'int',
				'default' => 600,
			),
			'height'       => array(
				'type'    => 'int',
				'default' => 450,
			),
			'alt'          => array(
				'type'    => 'string',
				'default' => '',
			),
			'link'         => array(
				'type'    => 'string',
				'default' => '',
			),
			'target'       => array(
				'type'    => 'string',
				'default' => '',
			),
			'classname'    => array(
				'type'    => 'string',
				'default' => '',
			),
			'rel'          => array(
				'type'    => 'string',
				'default' => '',
			),
			'display_link' => array(
				'type'    => 'boolean',
				'default' => false,
			),
			'image_size'   => array(
				'type'    => 'string',
				'default' => 'medium',
			),
			'content'      => array(
				'type'    => 'string',
				'default' => '',
			),
			'post_links'    => array(
				'type'    => 'boolean',
				'default' => false,
			),
		),
		'render_callback' => array( $this, 'block_frontend' ),
	)
);Code language: PHP (php)

Step 2: The Repeater Foundation

In my edit.js file, I initialize the state for the attributes as the first item of order.

class Browser_Shots_Carousel extends Component {

	constructor() {

		super( ...arguments );

		this.state = {
			slides: this.props.attributes.slides || [''],
			directionNav: this.props.attributes.directionNav,
			controlNav: this.props.attributes.controlNav,
			lightbox: this.props.attributes.lightbox,
			welcome: true,
			version: '1',
			width: this.props.attributes.width,
			height: this.props.attributes.height,
			link: this.props.attributes.link,
			target: this.props.attributes.target,
			rel: this.props.attributes.rel,
			image_class: this.props.attributes.image_class,
			image_size: this.props.attributes.image_size,
			display_link: 'undefined' === typeof this.props.attributes.display_link ? true : this.props.attributes.display_link,
		};
		this.props.attributes.slides = this.state.slides;

	};Code language: JavaScript (javascript)

Notice the slides attribute. We’ll be using that to set up the repeater. If it’s empty, we create an empty array. I then set the slides attribute to whatever is in the state. I’m not sure if that’s necessary, but it works for me at the moment.

In the render method of the block, here is the code I used to show the beginnings of the repeater.

<PanelBody>
	<div className="browsershots-block">
		<div>
			<svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 24 24">
				<g fill="none" fill-rule="evenodd">
					<path fill="#000000" d="M18,5 L4,5 L4,19 C2.8954305,19 2,18.1045695 2,17 L2,5 C2,3.8954305 2.8954305,3 4,3 L16,3 C17.1045695,3 18,3.8954305 18,5 Z" />
					<path stroke="#000000" stroke-width="2" d="M16.6666667,8 L11.3333333,8 L10.5,10 L8,10 C7.44771525,10 7,10.4477153 7,11 L7,19 C7,19.5522847 7.44771525,20 8,20 L20,20 C20.5522847,20 21,19.5522847 21,19 L21,11 C21,10.4477153 20.5522847,10 20,10 L17.5,10 L16.6666667,8 Z" />
					<circle cx="14" cy="15" r="2" stroke="#000000" stroke-width="2" />
				</g>
			</svg>
		</div>
		{this.showSlides()}
		<div>
			<input
				className="button button-secondary"
				type="submit" id="browsershots-input-submit"
				value={__( 'Add Slide', 'browser-shots-carousel' )}
				onClick={
					( e ) => {
						this.addClick();
					}
				}
			/>
			<br />
			<input
				className="button button-primary"
				style={{ marginTop: '25px', width: '100%' }}
				type="submit" id="browsershots-input-preview"
				value={__( 'Preview', 'browser-shots-carousel' )}
				onClick={
					( e ) => {
						this.setState( { welcome: false } );
					}
				}
			/>
		</div>
	</div>
</PanelBody>Code language: JavaScript (javascript)

There’s a lot going on here. First, I have two methods I call. The first one is showSlides, which will show the repeater output. Let’s see what that code looks like.

/**
 * Return all slides in JSX format.
 */
showSlides = () => {
	return ( this.state.slides.map((el, i) =>
		<div key={i}>
			<div className="browser-shots-carousel-input-row">
				<label>{__( 'Enter a URL', 'browser-shots-carousel' )}
					<br />
					<input
						type="text"
						value={ undefined != this.props.attributes.slides[i] ? this.props.attributes.slides[i].title : ''}
						placeholder = "http://"
						onChange={this.handleChange.bind(this, i)}
					/> <input type='button' value='remove' onClick={this.removeClick.bind(this, i)} />
				</label>
				<RichText
					tagName="div"
					className='wp-caption-text'
					placeholder={__( 'Write caption...', 'browser-shots' )}
					value={undefined != this.props.attributes.slides[i] ? this.props.attributes.slides[i].caption : ''}
					onChange={this.handleCaptionChange.bind(this, i)}
				/>
			</div>
		</div>
	) );
}Code language: JavaScript (javascript)

Now there’s even more going on! But let’s skip this for now and just summarize the code that it returns the value of the state object of slides and returns an input box for entering a URL, a remove input button, and a RichText component for entering a caption. Events are also set up, which calls handleChange, removeClick, and handleCaptionChange. Please note that an i variable is sent, which acts as an index.

Let’s go to the addClick method, which will add a new slide.

/**
 * Add a new slide.
 */
addClick = () => {
	this.setState(prevState => ({ slides: [...prevState.slides, '']}));
}Code language: JavaScript (javascript)

The addClick is pretty straightforward. It adds an empty array item on top of any previous slides.

Let’s move to the removeClick method, which will remove an item from our slide output.

/**
 * Remove a Slide.
 */
removeClick(i){
	let slides = [...this.state.slides];
	slides.splice(i,1);
	this.props.setAttributes( { slides: slides } );
	this.setState({ slides });
}Code language: JavaScript (javascript)

The above code gets the current state of the slides, removes them at an index, and sets the attributes and state for the updated slides.

Here’s what we have so far…

Adding and Removing Slides

Step 3: Handling the Changes

React needs an onChange event to handle the updates to a form element, otherwise it’s an uncontrolled component and there’s no way to get the value of the input. Here’s the code again for convenience where we call handleChange and handleCaption change.

/**
 * Return all slides in JSX format.
 */
showSlides = () => {
	return ( this.state.slides.map((el, i) =>
		<div key={i}>
			<div className="browser-shots-carousel-input-row">
				<label>{__( 'Enter a URL', 'browser-shots-carousel' )}
					<br />
					<input
						type="text"
						value={ undefined != this.props.attributes.slides[i] ? this.props.attributes.slides[i].title : ''}
						placeholder = "http://"
						onChange={this.handleChange.bind(this, i)}
					/> <input type='button' value='remove' onClick={this.removeClick.bind(this, i)} />
				</label>
				<RichText
					tagName="div"
					className='wp-caption-text'
					placeholder={__( 'Write caption...', 'browser-shots' )}
					value={undefined != this.props.attributes.slides[i] ? this.props.attributes.slides[i].caption : ''}
					onChange={this.handleCaptionChange.bind(this, i)}
				/>
			</div>
		</div>
	) );
}Code language: JavaScript (javascript)

Here’s the code for handleChange.

/**
 * Update the title when it's changed.
 */
handleChange = (i, event) => {
	if ( undefined == event ) {
		return;
	}
	let slides = [...this.state.slides];
	if ( slides.length == 0 ) {
		return;
	}
	slides[i] = { title: event.target.value || '', caption: slides[i].caption || '' };
	this.props.setAttributes( { slides: slides } );
	this.setState({ slides });
}Code language: JavaScript (javascript)

And finally, the code for handleCaptionChange.

/**
 * Update the caption when it's changed.
 */
handleCaptionChange = (i, event) => {
	if ( undefined == event ) {
		return;
	}
	let slides = [...this.state.slides];
	if ( slides.length == 0 ) {
		return;
	}
	slides[i] = { caption : event || '', title: slides[i].title || '' };
	this.props.setAttributes( { slides: slides } );
	this.setState({ slides });

}Code language: JavaScript (javascript)

You’ll notice I use the passed i parameter to make sure that I can update the URL and Caption in the repeater.

Conclusion

So to set up the repeater, you need:

  • a method for outputting your repeater.
  • a method for handling the removal of your repeater item.
  • a method for handling the changes of your repeater item.
  • a method for adding an item to your repeater.

You can browse the final source code via the GitHub repository for Browser Shots Carousel.

If you have any questions or comments, be sure to leave them below. Thanks for reading.

Ronald Huereca
Ronald Huereca

Ronald Huereca

Ronald has been part of the WordPress community since 2006, starting off writing and eventually diving into WordPress plugin development and writing tutorials and opinionated pieces.

No stranger to controversy and opinionated takes on tough topics, Ronald writes honestly when he covers a topic.

4 thoughts on “How to Create a Repeater Field in Gutenberg”

  1. Why not just use Advanced Custom Fields to achieve this? The new way of creating blocks / fields seems like such overkill for something that can be easily achieved through ACF?

    • I don’t have ACF Pro, and from what I can gather, you can’t export blocks for users on WordPress.org who don’t have ACF Pro installed. Unless I’m missing something, feel free to correct.

  2. Hi Ronald,
    i followed your tutorial and i have the block working well on admin side.

    how can i display the block in the frontend?

    is there a shortcode?

    Thanks.

Comments are closed.

Ronald Huereca
MediaRon - Ronald Huereca

Ronald created MediaRon in 2011 and has more than fifteen years of releasing free and paid WordPress plugins.

Quick Links

Say Hi