Gutenberg's core data model shares a lot of the API and ideology of the state-management library Redux, with a number of differences. Reading up on Redux can provide a lot of insight into how Gutenberg's data management functions.
This lecture will be on the final exam.
Application State Architecture
Block Editor application state is split up between a number of different "data stores" responsible for managing a specific scope. See the Data Module Reference in the Block Editor Handbook for an overview of each store.
Each contains a private, externally inaccessible object representing it's scope of application state. I addition to the state object, each store is also composed of a number of three foundational components (among other things):
- Action Creators are functions which take in arguments, perform necessary work to shape those arguments into an event with a payload (an Action), and trigger that event on the data store ("dispatch the Action"). Action Creators can leverage external side effects in the process of creating their respective Actions - this includes things like querying the REST API for data. Action Creators are exposed externally, so we may call upon them to affect desired changes in application state.
- Reducers listen for actions dispatched on their store and process them to update the store's state data (if necessary). They are externally inaccessible - so once a store has been constructed, we can no longer add to or modify the way in which a data store responds to dispatched actions. Reducers are also "pure functions," or functions which do not depend on external side-effects. Given the same arguments, a pure function will always return the same return value; or in our case, given the same action, a Gutenberg Reducer will always modify the store's state through the same, predictable logic.
- Selectors are functions which retrieve data from the store's state, possibly shaping it into something more useful than the raw values in the process. In Gutenberg, selectors may call on Action Creators to populate missing state - that's an important point that we'll get into later.
To summarize, Action Creators effectively enable us to write changes to application state, and Selectors enable us to read it. Actions dispatched by Action Creator functions are consumed by Reducers which actually modify the state object - but both Actions and Reducers are generally externally inaccessible on the core data stores, and only really relevant if you are creating your own data store to manage the application state of a theme or plugin.
Developer Tools
The Redux DevTools Chrome extension is an invaluable tool for understanding application state in a Redux-powered application (or Redux-compatible application as it were). It will allow you to inspect the state object stored in Gutenberg stores as well as the sequence of dispatched actions and how they modify state over time, among many other useful features.
Data Store Interface
Action Creators
In Gutenberg, we can acquire an object containing all of a store's public Action Creators by passing the name (or definition object) of a store to @wordpress/data
's dispatch()
function. This is essentially our means to "write" to that store's state.
Selectors
Similar to Action Creators, we can acquire an object containing all of the functions we can use to retrieve state data from the store by passing it's name/definition to select()
:
import { select } from '@wordpress/data';
console.log( select( 'core/block-editor' ).isTyping() );
Some of Gutenberg's selectors will actually dispatch an Action to retrieve data that's missing from the store when called, creating a notable situation where the first time you call the selector it returns undefined
(or whatever the property's initial value in state may be), but another call moments later will return the expected data:
import { select } from '@wordpress/data';
const logPublicPostTypes = () => console.log( select( 'core' ).getPostTypes( { public: true } ) );
logPublicPostTypes() // Logs `null`.
setTimeout( logPublicPostTypes, 1000 ); // Logs an array of post type objects.
The take-away here is that Selectors provide synchronous access to data in state - even if they dispatch an Action that will shortly alter the very state which they just returned.
Subscription
As demonstrated above, we can't always predict whether a Selector will return the data that we immediately need (such as the isTyping()
Selector above), or if it might dispatch an Action to retrieve that data and update the store's state after the fact (as might anything that depends on data retrieved from the REST API such as available post types). Fortunately, data stores provide a subscribe()
function in order to attach callbacks which will be executed whenever an action is dispatched on the store. Better yet, in Gutenberg they're only executed when the state actually changes.
@wordpress/data
exposes a global subscribe()
function which will execute it's callbacks whenever any store's state changes. So we can use it to watch for asynchronously loaded data, or specific state changes.
subscribe()
also returns a function which can be called to remove the subscription, allowing us to detach functionality for which state updates are no longer relevant:
import { subscribe, select } from '@wordpress/data';
const { getPostTypes } = select( 'core' );
const unsub = subscribe(
() => {
const public_post_types = getPostTypes( { public: true } );
if( ! public_post_types )
return;
doCoolThingsWithPostTypes( public_post_types );
unsub();
}
);
function doCoolThingsWithPostTypes( post_types ) {
// ...
}
React Component Integration
As detailed above, the Selectors used to access state data in stores only provide an immediate and synchronous snapshot of the stored value. So using a Selector to retrieve data in a component only gives you the current value in state whenever the component renders. What if you want the React component to re-render with the most recent value whenever the value changes in state?
Both the useSelect()
hook and the withSelect()
"Higher-Order-Component" utility are means to the same end - they provide a mechanism to expose state data from stores to a component and simultaneously create a subscription to re-render that component when that state data changes.
Which one you choose is largely a matter of personal preference, with the caveat that useSelect()
can only be used inside of a functional component. For the most part however, hooks like useSelect()
are widely considered to be a more elegant interface which solves a lot of the annoyances inherent to the more traditional "Higher-Order Component" pattern (see also this elaboration on hooks from the co-creator of Redux).
In my opinion, it's easier to understand the inner mechanism of HOCs, but hooks are easier to learn to use, produce more navigable and maintainable code, and mitigate a lot of "wrapper hell" pollution in the component hierarchy.
Counter-intuitively, "Higher Order Components" are not actually components, but rather functions which receive a component as an argument and wrap additional components and functionality around it.
Sometimes the name is also applied to a sort of "curried HOC", where the function does not directly take in a component as an argument and return a wrapped version of it, but rather returns a new function which satisfies this definition. This describes @wordpress/data
's withSelect()
and well as Redux's connect()
; both take in a number of arguments which are not components, then return a function which satisfies the definition of a HOC - the returned functions take in a component as an argument and wrap additional functionality around it before returning the wrapped component.
Basic Usage
withSelect()
receives a single argument - a callback function which receives @wordpress/data
's select()
function and a component's provided props as arguments, and is expected to return either an object containing component props derived from selectors or undefined
. Internally, withSelect()
subscribes to the relevant stores - so the callback function is executed to re-calculate the mapping whenever state changes.
The HOC which withSelect()
returns can then be used to add those selector-derived props to a component. When the values in the mapping change due to the internal store subscription, the component's props change and trigger a re-render with the new data.
So we can use withSelect()
in order to create a custom HOC which will populate a postTypes
prop for a component:
const withPublicPostTypes = withSelect(
( select ) => ( {
postTypes: select( 'core' ).getPostTypes( { public: true } ),
} )
);
const PostTypeList = ( { title = 'Post Types', postTypes = [] } ) => (
<div>
<h3>{title}</h3>
<ul>
{postTypes.map( type => (<li key={type.slug}>{type.name}</li>) )}
</ul>
</div>
);
const PublicPostTypeList = withPublicPostTypes( PostTypeList );
Using Props
In addition to re-calculating the property map on on state changes, it will also recalculate when the props received by the HOC's wrapper component change. So we can even expose a new postTypeArgs
prop and translate it into a postTypes
prop for the wrapped component (using the same PostTypeList
component from above):
const withPostTypes = withSelect(
( select, ownProps ) => {
const { getPostTypes } = select( 'core' );
const { postTypeArgs = {} } = ownProps;
return {
postTypes: getPostTypes( postTypeArgs ),
};
}
);
const QueryablePostTypeList = withPostTypes( PostTypeList );
<QueryablePostTypeList
title="Public Post Types"
postTypeArgs={{ public: true }}
/>
React Hooks (not be confused with WordPress's PHP action/filter hooks) are a relatively new addition to the React API, and serve as a pattern to more elegantly expose various functionality for use inside of functional components (components written as functions rather than classes). Prior to hooks, there was not an easy method to use state or attach functionality concerned with a component's lifecycle when using the more succinct functional component syntax.
Basic Usage
useSelect()
takes in a callback function which is given the select()
function and expected to return a value derived from selectors, similar to the callback which is passed to withSelect()
. We might implement the <PublicPostTypeList>
component from the last section using the useSelect()
hook as follows:
const PublicPostTypeList = ( props ) => {
const postTypes = useSelect( ( select ) => select( 'core' ).getPostTypes( { public: true } ) );
return <PostTypeList postTypes={postTypes} ...props />;
}
Now, postTypes
will always have the latest value of the public post type list from the store. If that list changes (like if that state was not populated before this component's first render), useSelect()
's internal subscription will trigger a re-render automatically.
Dependency Argument & Memoization
The second argument to useSelect()
is an array of dependency values. If specified, then after the first render, useSelect()
will only execute our mapping callback if one or more of the values in that array change, and will otherwise provide the memoized (cached) value from the previous run. This is a useful optimization for cases where the values derived from selectors factor in props or other external values, and you are not concerned with changes in state unless those values have changed, too:
const QueryablePostTypeList = ( { postTypeArgs = {}, ...props } ) => {
const postTypes = useSelect(
( select ) => select( 'core' ).getPostTypes( postTypeArgs ),
[ postTypeArgs ]
);
return <PostTypeList postTypes={postTypes} ...props />;
}
Custom Hooks
Like all other hooks, useSelect()
can also be used in composing a custom hook in order to create reusable functionality from multiple hooks or to provide a simplified interface:
const usePostTypes = ( args = {} ) => useSelect(
( select ) => select( 'core' ).getPostTypes( args ),
[ args ]
);
const PublicPostTypeList = ( props ) => {
const postTypes = usePostTypes( { public: true } );
return <PostTypeList postTypes={postTypes} ...props />;
}
// TODO: improve question, add links, verify example code is functional and illustrates the correct stuff