Building a “repeater” field for the Gutenberg block editor

Updated on

After giving my talk on Building Gutenberg Blocks in Greenville this year, I was asked a pretty interesting question. It was along the lines of building a field structure similar to Advanced Custom Fields’ Repeater Field inside a Gutenberg block. At the time I wasn’t sure something like that was really necessary in a Gutenberg world. I said “Instead of building a ‘Repeater Field’ you would probably build a block that had the functionality you would put in an individual row of a repeater field, then you stack those ‘rows’ as many times as you needed, effectively giving yourself the same content types in the same format repeating down the page.” I took it one step further and theorized that you could build two blocks, a container for your repeater field that would act as your if statement (if this section had a heading or different styling), and a block for the row of the repeater field that you could add as many times as you needed acting as your while loop. You certainly wouldn’t need an if statement in a situation like this; your blocks could be preceded by a heading block or styles applied directly to the blocks themselves, but I was trying to make a case for why someone might need to do something like this.

It wasn’t until my latest project that I ended up doing just that. It was not necessarily a block that needed to be repeatable; I still believe the answer I gave would work for all the ways I have used repeater fields in the past. But my most recent project has been building an extremely versatile Google Maps block, and in building it I decided I wanted to give the user the ability to add multiple markers to their map. This was certainly a use case that fell outside of the answer I gave that day. This one block would need the ability to accommodate a variable number of locations, the ability to add and remove locations, and I could not just build another block as I had previously mentioned. It needed to be repeatable options on this single block. Heres what I came up with.

I started as I usually do by running create-guten-block. If you have never done that I have a small write up on a live demo I did as well as step by step instructions I wrote and followed during that demo.

From a planning standpoint, the data would likely have to be stored in an array, so I set up an array in the blocks attributes. This will be used to track an indefinite number of separate locations.

registerBlockType( 'grf/gutenberg-repeater-field', {
	title: __( 'Gutenberg Repeater Field' ),
	icon: 'shield',
	category: 'common',
	attributes: {
		locations: {
			type: 'array',
			default: [],
		},
	},
	keywords: [
		__( 'Gutenberg Repeater Field' ),
		__( 'Repeatable' ),
		__( 'ACF' ),
	],
	...

Since this was for a map block, I wanted to display the map in the main content area of the editor and have the locations available in the sidebar. Inside of my edit function, we need to return a couple things. First, we need an InspectorControls component and a PanelBody component to house our existing locations and a Button component to add new ones. I imported the necessary components…

const {
	Button,
	PanelBody,
} = wp.components;
const {
	InspectorControls,
} = wp.editor;

…and returned a new panel in the edit function.

return [
	<InspectorControls key="1">
		<PanelBody title={ __( 'Locations' ) }>
			<Button
				isDefault
				onClick={ () => {} }
			>
				{ __( 'Add Location' ) }
			</Button>
		</PanelBody>
	</InspectorControls>,
	<div key="2" className={ props.className }>
	</div>,
];

So we have a very rough outline of what our sidebar will look like in the editor for our block. We need to make sure that any existing locations will end up being displayed and while we’re at it they should really be able to be edited or deleted. Before we return the InspectorControl, we’ll need to get the existing locations and wrap them in a TextControl component and add a button to allow them each to be deleted. For the button I used an IconButton component. I imported the necessary components…

// new components along with previously imported components
const {
	Button,
	IconButton,
	PanelBody,
	TextControl,
} = wp.components;

…and if there were existing locations I gave each a TextControl and IconButton.

let locationFields,
	locationDisplay;

if ( props.attributes.locations.length ) {
	locationFields = props.attributes.locations.map( ( location, index ) => {
		return <Fragment key={ index }>
			<TextControl
				className="grf__location-address"
				placeholder="350 Fifth Avenue New York NY"
				value={ props.attributes.locations[ index ].address }
				onChange={ ( address ) => {} }
			/>
			<IconButton
				className="grf__remove-location-address"
				icon="no-alt"
				label="Delete location"
				onClick={ () => {} }
			/>
		</Fragment>;
	} );

	locationDisplay = props.attributes.locations.map( ( location, index ) => {
		return <p key={ index }>{ location.address }</p>;
	} );
}

In the code snippet above I’m setting up two variables. One holds the locations in editable format with buttons that will delete the “row”, and another that will create a paragraph tag to display each string. This is an impractical use but will allow us to see the repeater functionality working. We’ll output the two variables created in the edit functions return we already created.

return [
	<InspectorControls key="1">
		<PanelBody title={ __( 'Locations' ) }>
			{ locationFields }
			<Button
				isDefault
				onClick={ () => {} }
			>
				{ __( 'Add Location' ) }
			</Button>
		</PanelBody>
	</InspectorControls>,
	<div key="2" className={ props.className }>
		<h2>Block</h2>
		{ locationDisplay }
	</div>,
];

All thats left to do now is tie it all together. To do so we’ll need three separate functions. One to handle adding a new location by adding an empty value to our “locations” array. That creates a new empty field in the sidebar and a new empty paragraph tag in the main editor.

const handleAddLocation = () => {
	const locations = [ ...props.attributes.locations ];
	locations.push( {
		address: '',
	} );
	props.setAttributes( { locations } );
};

Since we added a location, now theres a button to delete said location. We need a function to handle deleting the location when the button is pressed.

const handleRemoveLocation = ( index ) => {
	const locations = [ ...props.attributes.locations ];
	locations.splice( index, 1 );
	props.setAttributes( { locations } );
};

And finally a function to handle text changes in the TextControl components. Every time a location is changed the array containing all the locations will be updated and you will see the change in the main editor as well.

const handleLocationChange = ( address, index ) => {
	const locations = [ ...props.attributes.locations ];
	locations[ index ].address = address;
	props.setAttributes( { locations } );
};

The last thing we need to do is set the functions to run when we need them to. We’ll run handleAddLocation when the user clicks the “Add Location” button, handleRemoveLocation when the “Delete Location” button is pressed, and handleLocationChange any time any of the location fields are changed. The entire edit function for our block is below so you can see all that functionality together.

edit: ( props ) => {
    const handleAddLocation = () => {
        const locations = [ ...props.attributes.locations ];
        locations.push( {
            address: '',
        } );
        props.setAttributes( { locations } );
    };

    const handleRemoveLocation = ( index ) => {
        const locations = [ ...props.attributes.locations ];
        locations.splice( index, 1 );
        props.setAttributes( { locations } );
    };

    const handleLocationChange = ( address, index ) => {
        const locations = [ ...props.attributes.locations ];
        locations[ index ].address = address;
        props.setAttributes( { locations } );
    };

    let locationFields,
        locationDisplay;

    if ( props.attributes.locations.length ) {
        locationFields = props.attributes.locations.map( ( location, index ) => {
            return <Fragment key={ index }>
                <TextControl
                    className="grf__location-address"
                    placeholder="350 Fifth Avenue New York NY"
                    value={ props.attributes.locations[ index ].address }
                    onChange={ ( address ) => handleLocationChange( address, index ) }
                />
                <IconButton
                    className="grf__remove-location-address"
                    icon="no-alt"
                    label="Delete location"
                    onClick={ () => handleRemoveLocation( index ) }
                />
            </Fragment>;
        } );

        locationDisplay = props.attributes.locations.map( ( location, index ) => {
            return <p key={ index }>{ location.address }</p>;
        } );
    }

    return [
        <InspectorControls key="1">
            <PanelBody title={ __( 'Locations' ) }>
                { locationFields }
                <Button
                    isDefault
                    onClick={ handleAddLocation.bind( this ) }
                >
                    { __( 'Add Location' ) }
                </Button>
            </PanelBody>
        </InspectorControls>,
        <div key="2" className={ props.className }>
            <h2>Block</h2>
            { locationDisplay }
        </div>,
    ];
},

I ended up rebuilding to include just this functionality in a single repo so anyone could use it if they needed to. Check all the code in my Gutenberg ‘Repeater Field’ repo on Github. You can find this file in the main block JavaScript.

I hope you found this useful and if you find another use case for something like this I’d love to hear about it!

Leave me a message and I’ll get back to you as soon as possible. Thanks!