Skip to content

Instantly share code, notes, and snippets.

@adamziel
Last active December 15, 2021 14:27
Show Gist options
  • Save adamziel/2ee2a22b417825e9324f9dad26c17e73 to your computer and use it in GitHub Desktop.
Save adamziel/2ee2a22b417825e9324f9dad26c17e73 to your computer and use it in GitHub Desktop.

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.";
};

Why are thunks useful?

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.

Thunks have access to the store helpers

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.

Thunks may be async

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.

Thunks API

A thunk receives a single object argument with the following keys:

select

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

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();
}

dispatch

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

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 );
  /* ... */
}
@gziolo
Copy link

gziolo commented Oct 24, 2021

Thank you for working on this tutorial. It’s very important to make it visible in the Block Editor Handbook once the new API becomes stable. I guess it always remains complex to reason about use cases where a simple action is not enough. While the idea of controls seems great, in practice it isn’t always so straightforward where to draw a line for a side-effect. I run into that recently when working on changes to the block registration in WordPress/gutenberg#34299. The interesting observation is that I started with controls but finally landed on the solution almost identical to what thunks offer based on the feedback from review. The most important part here is also that controls are very difficult to use. You need to split code between two or more places, and be able to reason about complex data flow that is enforced by that. All that gets simplified with thunks only at the cost of using a magic function syntax that exposes the registry 😃

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