WordPress Gutenberg useEffect: Fetch Data, Debounce Inputs, and Clean Up Side Effects in Blocks

The useEffect hook from React (available in Gutenberg as @wordpress/element‘s useEffect) is the standard mechanism for running side effects inside block edit components: fetching data from the REST API when an attribute changes, initialising third-party JavaScript libraries, setting up event listeners that must be cleaned up when the block unmounts, or triggering attribute updates based on external changes. In Gutenberg blocks, useEffect runs after each render inside the editor, and its dependency array determines when the effect re-runs. The most common pitfall is an infinite update loop: calling setAttributes() inside a useEffect without a proper dependency array causes the effect to re-run on every render, which triggers a new render, which re-runs the effect, and so on.

Problem: A custom block shows a live preview of an RSS feed URL entered by the user. When the feedUrl attribute changes, the block should fetch the feed's title from a custom REST endpoint and store it as another attribute. The fetch must not run on every keystroke — only after the user stops typing.

Solution: Use useEffect with feedUrl as the dependency. Debounce the fetch to avoid excessive API calls. Use a cleanup function to abort in-flight requests when the dependency changes before the request completes.

// edit.js
import { useEffect, useState } from '@wordpress/element';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl, Spinner } from '@wordpress/components';
import { __ } from '@wordpress/i18n';

export default function Edit( { attributes, setAttributes } ) {
    const { feedUrl, feedTitle } = attributes;
    const [ isLoading, setIsLoading ] = useState( false );
    const blockProps = useBlockProps();

    // ── Fetch feed title when feedUrl changes ──────────────────────────
    useEffect( () => {
        // Do nothing if no URL
        if ( ! feedUrl ) return;

        // AbortController lets us cancel the fetch if the component
        // re-renders (e.g. user types another character) before it completes
        const controller = new AbortController();
        let debounceTimer;

        // Debounce: wait 500ms after last change before fetching
        debounceTimer = setTimeout( async () => {
            setIsLoading( true );
            try {
                const res = await fetch(
                    '/wp-json/my-plugin/v1/feed-title?url=' + encodeURIComponent( feedUrl ),
                    { signal: controller.signal }
                );
                if ( res.ok ) {
                    const data = await res.json();
                    setAttributes( { feedTitle: data.title } );
                }
            } catch ( err ) {
                if ( err.name !== 'AbortError' ) {
                    console.error( 'Feed fetch failed:', err );
                }
            } finally {
                setIsLoading( false );
            }
        }, 500 );

        // Cleanup: cancel the fetch and clear the debounce timer
        // when feedUrl changes before the 500ms timer fires,
        // or when the component unmounts
        return () => {
            clearTimeout( debounceTimer );
            controller.abort();
        };
    }, [ feedUrl ] ); // ← only re-run when feedUrl changes

    return (
        <>
            <InspectorControls>
                <PanelBody title={ __( 'Feed Settings', 'textdomain' ) }>
                    <TextControl
                        label={ __( 'Feed URL', 'textdomain' ) }
                        value={ feedUrl }
                        onChange={ ( val ) => setAttributes( { feedUrl: val } ) }
                    />
                </PanelBody>
            </InspectorControls>

            <div { ...blockProps }>
                { isLoading && <Spinner /> }
                { feedTitle && <h3>{ feedTitle }</h3> }
                { ! feedTitle && ! isLoading && <p>{ __( 'Enter a feed URL.', 'textdomain' ) }</p> }
            </div>
        </>
    );
}

NOTE: The useEffect cleanup function (the function returned from the effect callback) runs both when the dependency changes and when the component unmounts. Always return a cleanup function when the effect sets up subscriptions, timers, or in-flight fetch requests — failing to do so causes memory leaks and state updates on unmounted components, which React logs as a warning. The dependency array is critical: an empty array [] means the effect runs once on mount only; omitting the array entirely means the effect runs on every render (almost always wrong in Gutenberg); listing specific values means it runs when any of them changes. Use @wordpress/data's useSelect and resolvers system for fetching WordPress-specific data (posts, terms, settings) rather than raw fetch — it handles caching and deduplication automatically.