Skip to content

Instantly share code, notes, and snippets.

@juanmaguitar
Last active March 5, 2025 12:42
Show Gist options
  • Save juanmaguitar/47f56aa5ad1af691e70a183e7550e10a to your computer and use it in GitHub Desktop.
Save juanmaguitar/47f56aa5ad1af691e70a183e7550e10a to your computer and use it in GitHub Desktop.

Table of Contents

  1. Pre-requisites
  2. Big Ideas
  3. Data package
  4. Core-data package

Pre-requisites

This doc assumes you are familiar with Redux concepts such as actions, reducers, and selectors, as well as certain Gutenberg concepts such as Thunks. You may still be able to get the gist of the ideas here without these pre-requisites, but you are highly encouraged to get familiar with them first.

Big Ideas

Gutenberg is part of WordPress core and frequently acts on the same data. There are posts, pages, taxonomies, widgets, navigation items and so on. The obvious way of using the data, would be to just request it from API whenever a given React component needs it. This would have a serious drawback though. Any other component wanting to use the same data, would have to request it as well. So now there is more than one request. Then, what if the data changes? Would all components re-request it? Would they even know to do so?

The data layer provides answers to all of these questions and more. It handles data synchronization for you, so you can focus on your component. For example, when you need to do something with a widget, you can access it with:

function MyComponent({ widgetId }) {
	const widget = useSelect(
		select => select( coreStore ).getWidget( widgetId )
	);

	// ...
}

Moreover, you can be sure that it’s the most recent version, and that no unnecessary HTTP requests were performed. But how does it all work? There are a few big concepts to discuss:

  • Data package (@wordpress/data)
    • Selectors and resolvers
    • React hooks
  • Core-data package (@wordpress/core-data)
    • Entities
    • Entity Records
    • Data flow
      • Reading Entity Records
      • Editing Entity Records
      • Saving Entity Records
      • Deleting Entity Records

Data package

Selectors and resolvers

Selectors are simple functions that return a piece of data from the store. Resolvers are used to load the data when there is none available yet. Let’s see how they work in tandem in this minimal store:

const store = wp.data.createReduxStore( 'thermostat', {
	// Essential functions
	selectors: {
		getTemperatureCelsius: ( state ) => state.temperature,
		getTemperatureFarenheit: ( state ) => state.temperature * 1.8 + 32
	},
	resolvers: {
		getTemperatureCelsius: () => ( { dispatch } ) => {
			dispatch.receiveTemperature( 10 );
		}
	},

	// Utility functions
	__experimentalUseThunks: true,
	actions: {
		receiveTemperature: ( temperature ) => ({
			type: 'RECEIVE_TEMPERATURE',
			temperature
		})
	},
	reducer( state={}, action ) {
		const newState = {
			...state
		}
		if ( action.type === 'RECEIVE_TEMPERATURE' ) {
			newState.temperature = action.temperature;
		} else if ( action.type === '@@INIT' ) {
			newState.temperature = 0;
		}
		return newState;
	}
} );
wp.data.register(store)

The @@INIT action is dispatched when the store is instantiated, and so the initial state says temperature: 0.

Simple selectors

The getTemperatureCelsius is a simple selector, it predictably returns 0 once the store was instantiated:

> wp.data.select('my-store').getTemperatureCelsius()
0

Note we didn’t provide the state as an argument. The Gutenberg data layer handles that for us.

Memoized selectors

dependency for other selectors

import createSelector from 'rememo';

// This selector will only calculate the return value once,
// as long as `state.temperature` remains the same.
getTemperatureFarenheit: createSelector(
	// The selector
	( state ) => state.temperature * 1.8 + 32,

	// The reference(s) upon which the computation depends:
	( state ) => [ state.temperature ]
)

Read more about memoized selectors in rememo package documentation.

Resolved selectors

getTemperatureCelsius is more special as there is a resolver registered under the same name. When getTemperatureCelsius is called for the first time, it will receive the current state and return 0, but the data layer will also call the related resolver. Since our resolver populates the state with the temperature, the second call will return the actual data:

> wp.data.select('thermostat').getTemperatureCelsius()
0
> wp.data.select('thermostat').getTemperatureCelsius()
10

Once the data is loaded, getTemperatureFarenheit can do something with it:

> wp.data.select('thermostat').getTemperatureFarenheit()
50

As we’re about to learn, the resolvers may be asynchronous. How do you know when the data becomes available? The easiest way is to use the resolveSelect utility instead of select:

> wp.data
.resolveSelect('thermostat')
.getTemperatureCelsius()
.then(( temperature ) => console.log( temperature ))
10

resolveSelect returns a promise that waits until the resolver finishes, runs the selector, then yields the final value.

Resolvers

Let’s zoom into our resolver:

getTemperatureCelsius: () => ( { dispatch } ) => {
	dispatch.receiveTemperature( 10 );
}

It is a thunk that populates the state. Note that it does not return anything, nor are there any assumptions on how the data is loaded. The sole goal of this function is to populate the state, and it does so by dispatching the receiveTemperature action when the data is ready.

In real world, data is often stored in APIs and needs to be loaded asynchronously. Fortunately, resolvers can be async too. Here’s a different way of loading the temperature:

getTemperatureCelsius: () => async ( { dispatch } ) => {
    const response = await window.fetch( '/temperature' );
    const result = await response.json();
    dispatch.receiveCurrentTemperature( result.temperature );
}

An attentive reader may ask at this point Is this going to send a request every time I use the getTemperatureCelsius() selector? Great question! The answer is no, thanks to the resolvers cache.

Resolvers cache

Resolvers are cached by the data layer. Subsequent calls to the same selectors will not trigger additional HTTP requests.

Let’s take a closer look at the thermostat  store. Once it is registered with wp.data.register(store), the actual state looks as follows:

{
  metadata: {},
  root: {
    temperature: 0
  }
}

The state managed by the developer lives in root, and the state managed by the @wordpress/data package lives in metadata. Let’s take a closer look at the latter.

Firstly we call the getTemperatureCelsius selector for the first time:

> wp.data.select('thermostat').getTemperatureCelsius()
null

First, getTemperatureCelsius does not refer to the same function as we originally registered with the store (( state ) => state.temperature). Instead, the data package replaced it with a „resolved” version using the mapResolvers utility. The function we’re actually calling is selectorResolver . It does two things:

  1. It runs the underlying selector and returns the result.
  2. It runs the underlying resolver, but only if it isn’t already running and wasn’t already fulfilled.

Note that the selector runs first, which means the resolver can’t affect its return value.

resolversCache

When the resolver runs for the first time, selectorResolver acquires a lock through resolversCache.markAsRunning(), and when it finishes, it releases it through resolversCache.clear(). That’s how we’re sure the same resolver never runs multiple times in parallel.

As a store developer, you never need to worry about the resolversCache API. It is internal, and resolves to resolve the unique timing challenges of the data module. Outside of the data module you may lean on resolvers metadata.

Metadata cache

The resolver call is surrounded by two special actions: START_RESOLUTION and FINISH_RESOLUTION. If we peeked at the dispatch history after getTemperatureCelsius() is initially called, it would look like this:

{
  type: 'START_RESOLUTION',
  selectorName: 'getTemperatureCelsius',
  args: []
}

{
  type: 'RECEIVE_TEMPERATURE',
  temperature: 10
}

{
  type: 'FINISH_RESOLUTION',
  selectorName: 'getTemperatureCelsius',
  args: []
}

The second, third, and any other call would not call any additional dispatch calls thanks to resolversCache().

This is how the Redux state looks like after the FINISH_RESOLUTION:

{
  metadata: {
    getTemperatureCelsius: /*
		A mapping with one entry:
		[] => false
	*/
  },
  root: {
    temperature: 10
  }
}

It means that the resolution of getTemperatureCelsius with an empty arguments list ([]) is not running at the time (false).

As you may notice, the resolution is cached per arguments list. If we called the selector with a bogus argument:

> wp.data.select('thermostat').getTemperatureCelsius(2)
10

It would run the resolver again and create a new metadata entry like this:

{
  metadata: {
    getTemperatureCelsius: /*
		A mapping with two entries:
		[] => false
		[2] => false
	*/
  },
  root: {
    temperature: 10
  }
}

How is this useful? It allows you to check the resolution state of your data via metadata selectors.

Metadata selectors

The data module adds a few special selectors to every store registered with resolvers:

  • hasStartedResolution(selectorName, args)
  • isResolving(selectorName, args)
  • hasFinishedResolution(selectorName, args)

The names say it all. Here’s an example:

> (register a new store)
> wp.data.select('thermostat').hasStartedResolution('getTemperatureCelsius', [])
false
> wp.data.select('thermostat').isResolving('getTemperatureCelsius’)
false

> wp.data.select('thermostat').getTemperatureCelsius()
0

> wp.data.select('thermostat').hasStartedResolution('getTemperatureCelsius', [])
true
> wp.data.select('thermostat').isResolving('getTemperatureCelsius’)
false
// Not resolving yet, the resolver called asynchronously after the selector runs

> setTimeout(() => {
console.log(wp.data.select('thermostat').isResolving('getTemperatureCelsius’))
});
true

There are also two low-level selectors used to reason about the low-level details of the metadata mapping. They are listed for completeness, but this document does not cover them in details:

  • getCachedResolvers()
  • getIsResolving(selectorName, args)

Metadata cache invalidation

Let’s imagine the temperature reading changes every minute. We will simulate this behavior like:

getTemperatureCelsius: () => ( { dispatch } ) => {
	const temperature = (new Date()).getMinutes();
    dispatch.receiveCurrentTemperature( temperature );
}

The selector will only trigger the resolver the first time it runs, and then it will keep returning the same data. How can you get a fresh reading? You need to invalidate the resolver cache.

The data module adds a few special actions to every store with resolvers. We’ve already discussed startResolution and finishResolution, although indirectly. Now let’s talk about invalidateResolution( selectorName, args ).

invalidateResolution removes the specified entry from the metadata cache. Here’s how it works:

> wp.data.select('thermostat').getTemperatureCelsius()
0   // Initial value
> wp.data.select('thermostat').getTemperatureCelsius()
10  // Resolved reading value
// ... a few minutes pass ...
> wp.data.select('thermostat').getTemperatureCelsius()
10  // Redux state is still the same

> wp.data.dispatch('thermostat').invalidateResolution('getTemperatureCelsius', [])
Promise {<fulfilled>: {…}}  // The resolution was invalidated

> wp.data.select('thermostat').getTemperatureCelsius()
10  // Remember, selector returns the current value before resolving
    // The resolver runs again only now.
> wp.data.select('thermostat').getTemperatureCelsius()
15

Core-data package

As the name core-data says, this package connects WordPress core and the data package. To explain how it can be useful in everyday development, we need to discuss a few key concepts first.

Entities

An entity is a basic unit of information in core-data. Entities are conceptually similar to REST API resources, database entries, and class objects. A Post is an entity, so is a Taxonomy and a Widget. We will use the latter as our running example. Default entities are declared in entities.js, and a minimal definition looks like this:

const defaultEntities = [
	// ...
	{
		name: 'widget',
		label: __( 'Widgets' ),
		baseURL: '/wp/v2/widgets',
		kind: 'root',
	},
	// ...
}

name: widget is the Entity name, no surprises there.

label: __( 'Widgets' ) is a human-readable name. It may be in any user interface elements that have to refer to this Entity.

baseURL: '/wp/v2/widgets' tells the data layer where to find an API Endpoint that can be used to interact with data of type widget. This URL will be requested to retrieve records, perform searches, create new ones, as well as update and delete the existing records. Later on, you will see how the data layer retrieves the data through HTTP on your behalf.

kind: 'root' is a namespace. It’s needed, because apart of the Entities that are statically declared in entities.js, there are also dynamically registered Entities. For example, certain custom post types may be exposed as Entities. If one of them was called widget, it would overwrite the actual widget entity. Kind exists to avoid these conflicts. loadPostTypeEntities() registers custom post types in a conflict-free way by providing kind: 'postType' .

Entity Records

While Entity refers to a data type, Entity Record refers to the actual data. A post with ID 15 would be an Entity Record, just like a specific text widget instance. Entity Records don’t have to define any specific fields, aside of the primary key, which is id by default. GET requests to the baseURL endpoint must return a list of Entity Records. For example, requesting /wp/v2/widgets would return a response similar to:

[
	{ id: "block", sidebar: "header", ...},
	{ id: "block-2", sidebar: "header", ...},
]

But you said I don’t need to request the data, I may just select it instead. That’s correct! The data layer happily handles all the heavy lifting for you.

Data flow

The Redux state used by core-data resembles this one structure:

{
	entities: {
		data: {
			root: {
				widgets: {
					queriedData: null
				}
			}
		}
	}
}

Reading Entity Records

Selectors

The core-data store provides a named selector for each of the default entities. There is getTaxonomies(), getWidgets(), and so on. These selectors are not actually implemented from scratch: getWidgets() is merely a shorthand for getEntityRecords( 'root', 'widget' ).

getEntityRecords( kind, name, query ) returns the queried items if they exist in the store. The arguments are:

  • kind – points to the correct Entity
  • name – points to the correct Entity
  • query – an optional HTTP query that can help with things like filtering and pagination

As with any selector, the first call returns null because the store is still empty:

> wp.data.select('core').getEntityRecords( 'root', 'widget' )
null

Only then the data is loaded by the related resolver.

Resolution

The getEntityRecords resolver requests the data from the API using the baseURL defined in the Entity config earlier on. For widgets, it’s /wp/v2/widgets. Once the request is finished, the resolver dispatches the receiveEntityRecords() action to store the retrieved records:

{
  type: 'RECEIVE_ITEMS',
  items: [
	{ id: "block", sidebar: "header", ...},
	{ id: "block-2", sidebar: "header", ...},
  ],
  query: {},
  kind: 'root',
  name: 'widget',
  invalidateCache: false
}

Redux state

The RECEIVE_ITEMS reducer creates the new Redux state :

{
	root: {
		entities: {
			records: {
				root: {
					widget: {
						queriedData: {
							items: {
								default: {
									"block-2": {
										id: "block-2",
										...
									},
									"block-3": {
										id: "block-3",
										...
									},
									{
			                        // Similarly, add "block-4", "block-5", "block-6" following the same structure
			                        }
								}
		                    },
							itemIsComplete: {
								default: {
									"block-2": true,
									"block-3": true,
									"block-4": true,
									"block-5": true,
									"block-6": true
								}
							},
		                    queries: {
			                    default : {
				                    "": {
						                itemIds: ["block-2", "block-3", "block-4", "block-5", "block-6"],
						                meta: {
						                    totalItems: 5,
						                    totalPages: 1
						                }
						            }
			                    }
					        }
						},
	                }
	            }
	        },
	        
	    }
	};                        
}

Let’s discuss each of the keys under entities.records.root.widget.queriedData

items

items: {
	default: {
		"block": { id: "block", sidebar: "header", ...},
		"block-2": { id: "block-2", sidebar: "header", ...}
	}
}

items stores the records returned by the baseURL API Endpoint. The data is keyed by items IDs for faster lookups.

The default wrapper denotes the context query parameter. For example, calling getEntityRecords( 'root', 'widget', { context: 'edit' } ) would turn the items state to:

items: {
	default: {
		"block": { id: "block", sidebar: "header", ...},
		"block-2": { id: "block-2", sidebar: "header", ...}
	},
	edit: {
		"block": { id: "block", sidebar: "header", ...},
		"block-2": { id: "block-2", sidebar: "header", ...}
	}
}

The distinction is useful, because the REST API may return different fields for different contexts.

itemIsComplete

itemIsComplete: {
	default: {
		"block": true,
		"block-2": true
	}
}

Stores the information about which records finished loading already. For now, it’s only used internally in core-data as a dependency for the memoized selectors.

queries

queries: {
	default: {
		"": ["block", "block-2]
	}
}

Stores the ID of items the API returned in response to each query. The widgets endpoint does not support pagination, but if it did then we could call:

wp.data.select('core').getEntityRecords( 'root', 'widget', { per_page: 1 } )

And get a new state like

queries: {
	default: {
		"": ["block", "block-2"],
		"per_page=1": ["block"]
	}
}

The resolver also supports default query parameters. They may be configured via baseURLParams entity configuration key:

const defaultEntities = [
	// ...
	{
		kind: 'root',
		name: 'widget',
		label: __( 'Widgets' ),
		baseURL: '/wp/v2/widgets',
		baseURLParams: { per_page: 1 },
	},
	// ...
}
// ...
wp.data.select('core').getEntityRecords( 'root', 'widget' )
// Request sent to /wp/v2/widgets?per_page=1

How it all ties together

Going back to getEntityRecords() , we are now ready to move from the simplified definition to the actual one.

export function getEntityRecords( state, kind, name, query ) {
	// Queried data state is prepopulated for all known entities. If this is not
	// assigned for the given parameters, then it is known to not exist.
	const queriedState =
		state.entities.records?.[ kind ]?.[ name ]?.queriedData;
	if ( ! queriedState ) {
		return null;
	}
	return getQueriedItems( queriedState, query );
}

This selector uses getQueriedItems to find the list of relevant IDs in queries based on a query, and then picks them from items as a list.

As a result, the developer gets the following experience:

> wp.data.select('core').getEntityRecords( 'root', 'widget' )
null  // ...resolvers running...

> wp.data.select('core').getEntityRecords( 'root', 'widget' )
[
	{ id: "block", sidebar: "header", ...},
	{ id: "block-2", sidebar: "header", ...},
]

getEntityRecord()

Just like there core-data provides a getWidgets() shortcut, it also provides getWidget( key, query ) one. It is a shorthand for getEntityRecord( 'root', 'widget', key, query ):

> wp.data.select('core').getEntityRecord( 'root', 'widget', 'block-2')
{id: 'block-2', ...}

Note that the record was returned immediately, without waiting for the resolver. This ie because getEntityRecord( kind, type, key, query ) sources the data from the same Redux state as getEntityRecords(). If the Entity Record is already stored there, it can be used immediately without re-requesting. If it’s not, we still need to wait for the resolver:

> wp.data.select('core').getEntityRecord( 'root', 'widget', 'block-10')
null  // ...resolution in progress...

> wp.data.select('core').getEntityRecord( 'root', 'widget', 'block-10')
{id: 'block-10', ...}

The query arguments works in the same way as it does for getEntityRecords().

getRawEntityRecord()

Some Entity Record fields contain Gutenberg blocks, one such field is post.content. The API could simply return the raw block markup:

{
	"id": 90,
	"content": '<!-- wp:site-title /-->'
}

But this reveals a problem: not all blocks can be rendered by JavaScript, so this information wouldn’t be enough to provide used the visual preview. Returning only the rendered block wouldn’t suffice either – the raw markup is required for editing.

To resolve this pickle, the API returns both raw and rendered block markup for some fields:

{
	"id": 90,
	"content": {
		raw: '<!-- wp:site-title /-->',
		rendered: '<h1 class="wp-block-site-title"><a href="/" rel="home" >WordPress site</a></h1>'
	}
}

Sometimes handling an object instead of a string is not handy, so getRawEntityRecord collapses the content object into the raw string like this:

> wp.data.select('core').getEntityRecord('postType', 'post', 90)
{
	"id": 90,
	"content": {
		raw: '<!-- wp:site-title /-->',
		rendered: '<h1 class="wp-block-site-title"><a href="/" rel="home" >WordPress site</a></h1>'
	}
}

> wp.data.select('core').getRawEntityRecord('postType', 'post', 90).content
{
	"id": 90,
	"content": '<!-- wp:site-title /-->'
}

How does it know which attributes to collapse? It reads the rawAttributes property from the Entity config:

{
	label: __( 'Post Type' ),
	name: 'postType',
	kind: 'root',
	key: 'slug',
	baseURL: '/wp/v2/types',
	baseURLParams: { context: 'edit' },
	rawAttributes: [ 'title', 'excerpt', 'content' ],
}

Editing Entity Records

editEntityRecord()

Suppose you want to update the title of a post. You can do that direcly in the post editor, but how could we change the title of a post programatically? How the block editor keep track of these changes?

They're tracked using the editEntityRecord action:

wp.data.dispatch('core').editEntityRecord( 
	'postType', 
	'post', 
	'1', 
	{ title: 'My new post title' }
)

It dispatches the following action:

{
  type: 'EDIT_ENTITY_RECORD',
  kind: 'postType',
  name: 'post',
  recordId: '1',
  edits: {
    title: 'My new post title'
  }
}

And updates the Redux state as follows:

{
  "root": {
    "entities": {
      "records": {
        "postType": {
          "post": {
            "edits": {
              "1": {
                "title": "My new post title"
              }
            }
          }
        }
      }
    }
  }
}

Let’s unpack what just happened! different parts of that new state:

root.entities.records.postType.post.edits

Stores the latest edits per record. If we were to call editEntityRecord again,

wp.data.dispatch('core').editEntityRecord( 'postType', 'post', '1', { title: 'Another post title' })

the edits branch would reflect the latest value.

{
  "root": {
    "entities": {
      "records": {
        "postType": {
          "post": {
            "edits": {
              "1": {
                "title": "Another post title"
              }
            }
          }
        }
      }
    }
  }
}

To access the edits, use the getEntityRecordEdits() selector:

export function getEntityRecordEdits( state, kind, name, recordId ) {
	return get( state.entities.data, [ kind, name, 'edits', recordId ] );
}

root.entities.records.postType.post.queriedData and getEditedEntityRecord

Note that the queriedData didn’t change. What happens if we call getEntityRecord now?

> wp.data.select('core').getEntityRecord( 'postType', 'post', 1 )
{ 
	id: 1,
	...,
	title: {raw'Hello world!', rendered'Hello world!'}	
}

The title is still the original one Hello world!. This is expected, as getEntityRecord tells us about the most recent API data. To access the edited data, that only lives in the browser, we must use getEditedEntityRecord() instead:

> wp.data.select('core').getEditedEntityRecord( 'postType', 'post', 1 )
{ 
	id: 1,
	...,
	title: "Another post title"
}

getEditedEntityRecord has a very simple implementation. It takes the output of getRawEntityRecord(), and applies any edits on top of it:

const raw = getRawEntityRecord( state, kind, name, recordId );
const edited = getEntityRecordEdits( state, kind, name, recordId );
if ( ! raw && ! edited ) { return false; }
return {
	...raw,
	...edited,
};

The undo stack

The undo & redo functionality is managed by the @wordpress/undo-manager package that exposes a createUndoManager() to create undoManager instances.

The undoManager reducer initalizes root.undoManager with an instance of undoManager

import { createUndoManager } from '@wordpress/undo-manager';
...
export function undoManager( state = createUndoManager() ) {
	return state;
}

The undoManager instance has the following API

  "undoManager":{
	  // to add records to the internal HistoryRecord
      "addRecord": () => {}, 
      
      // to undo last change in the internal HistoryRecord
      "undo": () => {}, 
      
      // to redo last undone change
      "redo": () => {}, 
      
      // are there changes that can be undone in the  HistoryRecord?
      "hasUndo": () => {}, 
      
      // are there undone changes that can be redone?
      "hasRedo":() => {} 
    },

This undoManager instance maintains an internal HistoryRecord that can only be accesed through the above APIs

Every change dispatched with editEntityRecord action is tracked via the addRecord method and added to the undoManager's internal history.

export const editEntityRecord = (...) => {
	...
	select.getUndoManager().addRecord(...)
	...
}

When changes are tracked they can be undone by dispatch the undo action

export const undo =
	() =>
	( { select, dispatch } ) => {
		const undoRecord = select.getUndoManager().undo();
		if ( ! undoRecord ) {
			return;
		}
		dispatch( {
			type: 'UNDO',
			record: undoRecord,
		} );
	};

This action (defined using Thunks) uses internally select.getUndoManager().undo() to access the undo instance method and return an action with the data to undo

{
  type: 'UNDO',
  record: [
    {
      id: {
        kind: 'postType',
        name: 'post',
        recordId: '1'
      },
      changes: {
        title: {
          from: 'Hello world!',
          to: 'My new post title'
        }
      }
    }
  ]
}

This action is captured by the reducer that internally triggers a EDIT_ENTITY_RECORD to update the state with the change.

Note

The withMultiEntityRecordEdits HOR (defined in core-data/src/reducer.js), that adds the that undo/redo reducer functionality, is applied to the entity reducer, which manages the state for all entity types (e.g., posts, users, terms).

Here’s a practical demonstration:

> wp.data.select('core').getEntityRecord('postType','post',1).title
{raw: 'Hello world!', rendered: 'Hello world!'}

> wp.data.dispatch('core').editEntityRecord( 'postType', 'post', '1', { title: 'My new post title' })
Promise {...}

> wp.data.select('core').getEntityRecord('postType','post',1).title
{raw: 'Hello world!', rendered: 'Hello world!'}

> wp.data.select('core').getEditedEntityRecord('postType','post',1).title
'My new post title'

> wp.data.dispatch('core').undo()
Promise {...}

> wp.data.select('core').getEditedEntityRecord('postType','post',1).title
'Hello world!'

> wp.data.dispatch('core').redo()
Promise {...}

> wp.data.select('core').getEditedEntityRecord('postType','post',1).title
'My new post title'
Undo technical details

In terms of Redux state transitions, undo is not a separate action but a variant of EDIT_ENTITY_RECORD that says isUndo: true:

{
	type: 'EDIT_ENTITY_RECORD',
	kind: 'root',
	name: 'widget',
	recordId: 'block-2',
	edits: {
		sidebar: 'wp_inactive_widgets'
	},
	meta: {
		isUndo: true
	}
}

Dispatching that action does two things:

  1. Updates the offset to offset - 1
  2. Re-computes data.root.widget.edits using undo entries up to the offset

Here’s how the Redux state evolves as we do undo and redo:

> const peek = () => ({
	edits: state.entities.data.root.widget.edits['block-2'],
	undoOffset: state.undo.offset,
	undoLength: state.undo.length
})
> peek()
{ edits: { sidebar: 'footer' }, undoOffset: 0, undoLength: 2 }

> wp.data.dispatch('core').undo()
{ edits: { sidebar: 'header' }, undoOffset: -1, undoLength: 2 }

> wp.data.dispatch('core').redo()
{ edits: { sidebar: 'footer' }, undoOffset: 0, undoLength: 2 }

Note the undo stack did not change, we merely moved the offset. Why? Because removing the undone entries from Redux state would make redo impossible. In fact, sometimes that’s the desired behavior.

undoIgnore

As a developer, you might want to update Entity Records in a way that doesn’t interfere with the undo feature. This is what the undoIgnore option is for. Let’s see how it works in practice:

> wp.data.select('core').getEntityRecord('postType','post',2).title
{raw: 'Hello World!', rendered: 'Hello World!'}

> wp.data.dispatch('core').editEntityRecord( 'postType', 'post', '2', { title: 'My new post title' }, { undoIgnore: true  })
Promise {...}

> wp.data.select('core').getEntityRecord('postType','post',2).title
{raw: 'Hello World!', rendered: 'Hello World!'}

> wp.data.select('core').getEditedEntityRecord('postType','post',2).title
'My new post title'

> wp.data.dispatch('core').undo()
Promise {...}

> wp.data.select('core').getEditedEntityRecord('postType','post',2).title
'My new post title'

In this scenario, the undo stack remains untouched.

Keep in mind, that using undo() and redo() will work just as if this update never happened.

Selectors related to edited data

If you need direct access to information related to edits, the following selectors will help:

wp.data.select('core').hasUndo()
wp.data.select('core').hasRedo()
wp.data.select('core').getEditedEntityRecord()
wp.data.select('core').hasEditsForEntityRecord()
wp.data.select('core').getEntityRecordNonTransientEdits()

The names say it all. If you would like to learn even more about their inner working, check the core-data reference documentation.

Saving Entity Records

saveEditedEntityRecord()

Now that the user updated the title, it is time to save changes. The easiest way to do it, is by using the saveEditedEntityRecord() action:

wp.data.dispatch('core').saveEditedEntityRecord( 'root', 'widget', 'block-2' )

It collects all the edits for the specified Entity Record, applies them to the last queried state, and calls the API to perform the actual save operation.

After saveEditedEntityRecord the value persisted will be by getEntityRecord

> wp.data.select('core').getEntityRecord('postType','post',1).title
{raw: 'Hello world!', rendered: 'Hello world!'}

> wp.data.dispatch('core').editEntityRecord( 'postType', 'post', '1', { title: 'My new post title' })
Promise {...}

> wp.data.select('core').getEntityRecord('postType','post',1).title
{raw: 'Hello world!', rendered: 'Hello world!'}

> wp.data.select('core').getEditedEntityRecord('postType','post',1).title
'My new post title'

> wp.data.dispatch('core').saveEditedEntityRecord( 'postType', 'post', 1 )
Promise {<pending>}

> wp.data.select('core').getEntityRecord('postType','post',1).title
{raw: 'My new post title', rendered: 'My new post title'}

> wp.data.select('core').getEditedEntityRecord('postType','post',1).title
'My new post title'

The hasEditsForEntityRecord inform us if there edited values for an entity record

> wp.data.select('core').getEntityRecord('postType','post',1).title
{raw: 'Hello world!', rendered: 'Hello world!'}

> wp.data.dispatch('core').editEntityRecord( 'postType', 'post', '1', { title: 'My new post title' })
Promise {...}

> wp.data.select('core').hasEditsForEntityRecord( 'postType', 'post', 1)
true

> wp.data.dispatch('core').saveEditedEntityRecord( 'postType', 'post', 1 )
Promise {...}

> wp.data.select('core').getEntityRecord('postType','post',1).title
{raw: 'My new post title', rendered: 'My new post title'}

> wp.data.select('core').hasEditsForEntityRecord( 'postType', 'post', 1)
false

Under the hood saveEditedEntityRecord() calls saveEntityRecord() with all the edits related to the specified record. The following two variants are equivalent:

> wp.data.dispatch('core').editEntityRecord( 'postType', 'post', '1', { title: 'My new post title' })
> wp.data.dispatch('core').saveEditedEntityRecord( 'postType', 'post', 1 )

// The above is the same as
> wp.data.dispatch('core').saveEntityRecord( 'postType', 'post', { id: 1, title: 'My new post title' } )

saveEntityRecord(kind, name, record, options)

saveEntityRecord requests the API to update the existing record or create a new one if now ID is provided

For example, saving a post without an ID will trigger a POST request to /wp/v2/posts creating a new post:

> > wp.data.dispatch('core').saveEntityRecord( 'postType', 'post', { title: 'Bohemian Rhapsody' } ).then(console.log)
Promise {...}
{
	...
	id: 16,
	title: {raw'Bohemian Rhapsody', rendered'Bohemian Rhapsody'}
	...
}

While passing 1 as an ID will trigger a POST request to /wp/v2/posts/1 updatinf the referenced post:

> wp.data.dispatch('core').saveEntityRecord( 'postType', 'post', { id: 1, title: 'Radio Ga Ga' } ).then(console.log)
> 
{
	...
	id: 1, 
	title: {raw'Radio Ga Ga', rendered'Radio Ga Ga'}
	...
}

Once the record is saved and the API response with a new version is available, saveEntityRecord dispatches the receiveEntityRecord action with the new record. This way, the queriedItems part of the Redux state is updated, and selecting the same record again reflects the saved changes:

> wp.data.select('core').getEntityRecord('postType','post',1).title
{raw: 'Radio Ga Ga', rendered: 'Radio Ga Ga'}
Redux state

In Redux terms, saveEntityRecord dispatches the following Redux actions:

{
  type: 'SAVE_ENTITY_RECORD_START',
  kind: 'postType',
  name: 'post',
  recordId: 1,
  isAutosave: false
}

// ----------------------------------------

{
  type: 'RECEIVE_ITEMS',
  items: [
    {
      id: 1,
      title: { raw: 'Innuendo', rendered: 'Innuendo' },
      content: {
        raw: '<!-- wp:paragraph -->\n<p>Welcome to WordPress. This is your first post. Edit or delete it, then start writing!</p>\n<!-- /wp:paragraph -->',
        rendered: '\n<p>Welcome to WordPress. This is your first post. Edit or delete it, then start writing!</p>\n',
        'protected': false,
        block_version: 1
      },
	  ... 
    }
  ],
  persistedEdits: {
    id: 1,
    title: 'Innuendo'
  },
  meta: undefined,
  kind: 'postType',
  name: 'post',
  invalidateCache: true
}

// ----------------------------------------

{
  type: 'SAVE_ENTITY_RECORD_FINISH',
  kind: 'postType',
  name: 'post',
  recordId: 1,
  error: undefined,
  isAutosave: false
}

After SAVE_ENTITY_RECORD_START , root.entities.records.postType.post.saving.1.pending is set to true

And after SAVE_ENTITY_RECORD_FINISH, root.entities.records.postType.post.saving.1.pending is set to false.

Saving state

You may check if a record is currently being saved by calling isSavingEntityRecord:

> wp.data.select('core').isSavingEntityRecord( 'postType','post',1 )
false

It returns the pending value from the redux state.

Detecting errors

If the API request fails, the final SAVE_ENTITY_RECORD_FINISH will have an error property like below:

{
  type: 'SAVE_ENTITY_RECORD_FINISH',
  kind: 'postType',
  name: 'post',
  recordId: 23,
  error: {
    code: 'rest_post_invalid_id',
    message: 'Invalid post ID.',
    data: {
      status: 404
    }
  },
  isAutosave: false
}

This property is stored in the redux state alongside pending and may be accessed via the getLastEntitySaveError selector.

Imagine we try to update a post with a non-existing ID:

> wp.data.dispatch('core').saveEntityRecord( 'postType', 'post', { id: 23, title: 'Village' } ).then(console.log)
Promise {...}

> wp.data.select('core').getLastEntitySaveError( 'postType', 'post', 23 )
{code: 'rest_post_invalid_id', message: 'Invalid post ID.', data: {...}}

Deleting Entity Records

The deletion logic is analogous to the saving logic, and there even are corresponding actions and selectors:

  • saveEntityRecord() -> deleteEntityRecord()
  • isSavingEntityRecord() -> isDeletingEntityRecord()
  • getLastEntitySaveError() -> getLastEntityDeleteError()
@juanmaguitar
Copy link
Author

juanmaguitar commented Mar 5, 2025

This page's content come from a hidden gem doc page about the Gutenberg data layer belonging to an open PR #37615. The document is very technical and explains in detail a lot of interesting things about WordPress Data layer that are not covered in the docs

I'm currently in the process of reviewing and updating the content. What I've updated so far over the original content:

  • Replace some widget-related examples with post-related examples as widgets are not really a thing in block themes
  • Updating the info about undo-redo stack as this has completely changed since the original draft was written

I'm not opening a PR yet because I think this content could be part of a reshaped Data Layer Reference:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment