Gutenberg 11.6 added supports for thunks. You can think of thunks as of functions that can be dispatched:
// actions.js
export const myThunkAction = () => ( { select, dispatch } ) => {
return "I'm a thunk! I can be dispatched, use selectors, and even dispatch other actions.";
};
Thunks expand the meaning of what a Redux action is. Before thunks, actions were purely functional and could only return and yield data. Common use-cases such as interacting with the store or requesting API data from an action required using a separate control. You would often see code like:
export function* saveRecordAction( id ) {
const record = yield controls.select( 'current-store', 'getRecord', id );
yield { type: 'BEFORE_SAVE', id, record };
const results = yield controls.fetch({ url: 'https://...', method: 'POST', data: record });
yield { type: 'AFTER_SAVE', id, results };
return results;
}
const controls = {
select: // ...,
fetch: // ...,
};
Side-effects like store operations and fetch functions would be implemented outside of the action. Thunks provide alternative to this approach. They allow you to use side-effects inline, like this:
export const saveRecordAction = ( id ) => async ({ select, dispatch }) => {
const record = select( 'current-store', 'getRecord', id );
dispatch({ type: 'BEFORE_SAVE', id, record });
const response = await fetch({ url: 'https://...', method: 'POST', data: record });
const results = await response.json();
dispatch({ type: 'AFTER_SAVE', id, results });
return results;
}
This removes the need to implement separate controls.
Let's take a look at an example from Gutenberg core. Prior to thunks, the toggleFeature
action from the @wordpress/interface
package was implemented like this:
export function* toggleFeature( scope, featureName ) {
const currentValue = yield controls.select(
interfaceStoreName,
'isFeatureActive',
scope,
featureName
);
yield controls.dispatch(
interfaceStoreName,
'setFeatureValue',
scope,
featureName,
! currentValue
);
}
Controls were the only way to dispatch
actions and select
data from the store.
With thunks, there is a cleaner way. This is how toggleFeature
is implemented now:
export function toggleFeature( scope, featureName ) {
return function ( { select, dispatch } ) {
const currentValue = select.isFeatureActive( scope, featureName );
dispatch.setFeatureValue( scope, featureName, ! currentValue );
};
}
Thanks to the select
and dispatch
arguments, thunks may use the store directly without the need for generators and controls.
Imagine a simple React app that allows you to set the temperature on a thermostat. It only has one input and one button. Clicking the button dispatches a saveTemperatureToAPI
action with the value from the input.
If we used controls to save the temperature, the store definition would look like below:
const store = wp.data.createReduxStore( 'my-store', {
actions: {
saveTemperatureToAPI: function*( temperature ) {
const result = yield { type: 'FETCH_JSON', url: 'https://...', method: 'POST', data: { temperature } };
return result;
}
},
controls: {
async FETCH_JSON( action ) {
const response = await window.fetch( action.url, {
method: action.method,
body: JSON.stringify( action.data ),
} );
return response.json();
}
},
// reducers, selectors, ...
} );
While the code is reasonably straightforward, there is a level of indirection. The saveTemperatureToAPI
action does not talk directly to the API, but has to go through the FETCH_JSON
control.
Let's see how this indirection can be removed with thunks:
const store = wp.data.createReduxStore( 'my-store', {
__experimentalUseThunks: true,
actions: {
saveTemperatureToAPI: ( temperature ) => async () => {
const response = await window.fetch( 'https://...', {
method: 'POST',
body: JSON.stringify( { temperature } ),
} );
return await response.json();
}
},
// reducers, selectors, ...
} );
That's pretty cool! What's even better is that resolvers are supported as well:
const store = wp.data.createReduxStore( 'my-store', {
// ...
selectors: {
getTemperature: ( state ) => state.temperature
},
resolvers: {
getTemperature: () => async ( { dispatch } ) => {
const response = await window.fetch( 'https://...' );
const result = await response.json();
dispatch.receiveCurrentTemperature( result.temperature );
}
},
// ...
} );
Thunks' support is experimental for now. You can enable it by setting __experimentalUseThunks: true
when registering your store.
A thunk receives a single object argument with the following keys:
An object containing the store’s selectors pre-bound to state, which means you don't need to provide the state, only the additional arguments. select
triggers the related resolvers, if any, but does not wait for them to finish. It just returns the current value even if it's null.
If a selector is part of the public API, it's available as a method on the select object:
const thunk = () => ( { select } ) => {
// select is an object of the store’s selectors, pre-bound to current state:
const temperature = select.getTemperature();
}
Since not all selectors are exposed on the store, select
doubles as a function that supports passing selector as an argument:
const thunk = () => ( { select } ) => {
// select supports private selectors:
const doubleTemperature = select( ( temperature ) => temperature * 2 );
}
resolveSelect
is the same as select
, except it returns a promise that resolves with the value provided by the related resolver.
const thunk = () => ( { resolveSelect } ) => {
const temperature = await resolveSelect.getTemperature();
}
An object containing the store’s actions
If an action is part of the public API, it's available as a method on the dispatch
object:
const thunk = () => ( { dispatch } ) => {
// dispatch is an object of the store’s actions:
const temperature = await dispatch.retrieveTemperature();
}
Since not all actions are exposed on the store, dispatch
doubles as a function that supports passing a redux action as an argument:
const thunk = () => async ( { dispatch } ) => {
// dispatch is also a function accepting inline actions:
dispatch({ type: 'SET_TEMPERATURE', temperature: result.value });
// thunks are interchangeable with actions
dispatch( updateTemperature( 100 ) );
// Thunks may be async, too. When they are, dispatch returns a promise
await dispatch( ( ) => window.fetch( /* ... */ ) );
}
Registry provides access to other stores through dispatch
, select
, and resolveSelect
methods.
They are very similar to the ones described above, with a slight twist. Calling registry.select( storeName )
returns a function returning an object of selectors from storeName
. This comes handy when you need to interact with another store. For example:
const thunk = () => ( { registry } ) => {
const error = registry.select( 'core' ).getLastEntitySaveError( 'root', 'menu', menuId );
/* ... */
}
Some comments:
What is a thunk?
First of all, I think the motivation for thunks can be explained in a much simpler language than some very fancy-sounding "asynchronous side-effects". A baseline store has certain primitives: selectors and actions of the
{ type: FOO }
kind that update the state through the reducer and nothing else. Now the demand for something like thunks comes when we want to compose these primitives into something more complex.We know that JavaScript functions can be composed by calling other functions -- that's a complete no-brainer for everyone:
Now how's that related to thunks? Consider the simplest thunk I've seen in Gutenberg so far, the
toggleFeature
action in@wordpress/interface
:There's nothing asynchronous here, and yet it's a very nice thunk. Its entire job is to compose two primitives together, and be indistinguishable from a primitive action.
Just like we were able to create
complexOp
fromsimpleOp
s and it's still a function, primitive actions and thunks are also both actions:The consumer doesn't know which one is a primitive
{ type: SET, feature: 'gallery', value: true }
object and which is a thunk. Both quack like actions.Now the
( { select, dispatch } )
bit is just a smart way to do dependency injection. ThetoggleFeature
doesn't know which registry and store it will be running on, just like the{ type: SET }
action object doesn't know anything. It will be injected when the store executes the action.What's the main point of
rungen
generators?A generator, executed alone, never actually does anything directly, like sending a request with
window.fetch
. Instead of that, it uses the Command design pattern (btw does anyone still know and read the classic GoF books in 2021?) to generate command objects likeand only then the
rungen
runtime executes a control that has been registered with it, which does the actualwindow.fetch
call.That's an extra level of indirection and abstraction, and it turns out we almost never need that. We'd like to simplify things by calling
window.fetch
directly.With thunks you can indeed call
window.fetch
directly, and removing the indirection should make your code simpler. It's just functions calling other functions, without any runtime that interprets commands.retrieveTemperature
should be a resolverMy final bit of feedback is that the example with
retrieveTemperature
is not very good becauseretrieveTemperature
is a typical resolver, it shouldn't be an action. A more realistic example for an useful async thunk would some mutation action, one thatPOST
s something to the REST server.