The redux-thunk package is by far the most widely used middleware in Redux, and now our own @wordpress/data package also supports its own flavor of thunks. Yet the concept of thunks is often poorly understood, the motivation for them is unclear, and they are thought of as something magical.
In this section I will show how even in a very simple Redux store, without any middlewares, we can run into serious limitations when trying to implement seemingly trivial operations. And how these limitations can be overcome with thunks. We won’t need any asynchronous operations or side effects (i.e., code reaching outside the store) to run into these issues.
So, look at this @wordpress/data store that has a reducer composed from two sub-reducers with combineReducers, one selector and two actions:
function defaults( state = {}, action ) {
if ( action.type === 'SET_DEFAULT' ) {
return { ...state, [ action.feature ]: action.value };
} else {
return state;
}
}
function flags( state = {}, action ) {
if ( action.type === 'SET_FEATURE' ) {
return { ...state, [ action.feature ]: action.value };
} else
return state;
}
}
const isFeatureActive = ( state, feature ) => (
state.flags[ feature ] ??
state.defaults[ feature ] ??
false
);
function setDefault( feature, value ) {
return { type: 'SET_DEFAULT', feature, value };
}
function setFeature( feature, value ) {
return { type: 'SET_FEATURE', feature, value };
}
register( createReduxStore( 'features', {
reducer: combineReducers( { flags, defaults } ),
selectors: { isFeatureActive },
actions: { setDefault, setFeature }
} ) );
This store acts as a key-value map for feature flags. I can set a flag value:
dispatch( 'features' ).setFeature( 'gallery', true );
and then read the flag value with the selector:
select( 'features' ).isFeatureActive( 'gallery' );
If a feature was not explicitly set with setFeature, it defaults either to false or to a default I previously set with setDefault:
dispatch( 'features' ).setDefault( 'likes', true );
Now, isFeatureActive( 'likes' ) will return true if I never set it before with setFeature.
I could also easily implement a resetFeature action that resets a feature flag value back to the default, by adding a new branch to the flags reducer that removes a key from the state map, forcing the selector back to using a default.
So far, this looks like a textbook example of a Redux store, doesn’t it? A reducer nicely composed from two sub-reducers, a selector that looks at two places in the state tree, several actions with some reducers reacting to them and some ignoring them.
Our task now will be to add a new action to the store, one that allows us to toggle a feature flag value, i.e., change it to false if it was true and vice versa:
dispatch( 'features' ).toggleFeature( 'gallery' );
You might be tempted to add a new case statement to the flags reducer:
if ( action.type === 'TOGGLE_FEATURE' ) {
return {
...state,
[ action.feature ]: ! state[ action.feature ],
};
}
But this is not going to work correctly because the reducer doesn’t know what the old value of the flag really is. When the state (which is state.flags in the combined reducer) doesn’t have a record for the feature flag, we need to look at state.defaults but the flags reducer doesn’t have access to that. It’s not possible to make the following test pass:
dispatch( 'features' ).setDefault( 'likes', true );
dispatch( 'features' ).toggleFeature( 'likes' );
expect( select( 'features' ).isFeatureActive( 'likes' ).toBeFalse();
Wow! The fact that our reducer is nicely decomposed into sub-reducers makes it impossible to implement something as trivial as toggleFeature! That’s quite a serious limitation.
On the other hand, it’s quite straightforward to implement toggleFeature as a little helper function:
function toggleFeature( feature ) {
const active = select( 'features' ).isFeatureActive( feature );
dispatch( 'features' ).setFeature( feature, ! active );
}
See, the isActiveSelector can look at both state.flags and state.defaults and we can implement the desired behavior in just two lines of JavaScript code.
But we can’t package toggleFeature as yet another action on the store, on par with setFeature or resetFeature, because toggleFeature can’t be implemented as an action object processed by a reducer. And that’s a bit silly.
Here, thunks come to the rescue. What thunks do is that they expand the meaning of what is a Redux action. In addition to treating plain objects with a type field as actions:
function toggleFeature( feature ) {
return { type: 'TOGGLE_FEATURE', feature };
}
a store with thunk support treats functions as actions, too!
function toggleFeature( feature ) {
return () => {
const active = select( 'features' ).isFeatureActive( feature );
dispatch( 'features' ).setFeature( feature, ! active );
};
}
Now this toggleFeature function still has one serious problem and that’s the fact that it uses external identifiers select and dispatch. Where these come from? Do we need to import them from some module and how? We need to define them somehow before the thunk function is really executable. Our solution is to inject them as thunk parameters:
function toggleFeature( feature ) {
return ( { select, dispatch } ) => {
const active = select.isFeatureActive( feature );
dispatch.setFeature( feature, ! active );
};
}
The engine that executes the thunks (i.e., the thunk middleware in our store) provides these parameters, binding select and dispatch to the current store, calling the thunk function something like this:
thunkAction( {
select: select( 'features' ),
dispatch: dispatch( 'features' ),
} );
This latest version of toggleFeature will actually work in practice and can be registered as an action with our store:
const store = createReduxStore( 'features', {
/* ... */
actions: {
setDefaults,
setFeature,
resetFeature,
toggleFeature,
}
} );
Some of these action creators return objects with a type field and some return thunk functions, but the store user doesn’t need to care. It’s an implementation detail that’s completely invisible.
So, we’ve seen that the motivation for thunks is something as banal as being able to write JavaScript code and call functions from other functions: we’re using the isFeatureActive and setFeature functions to write a new function, toggleFeature.
A thunk doesn’t need to do anything asynchronous to be a useful thunk. While it’s true that we often write thunks to communicate with a REST API:
function fetchFeatures() {
return async ( { dispatch } ) => {
const body = await window.fetch( '/features' );
dispatch.receiveFeatures( body.features );
};
}
the fact that the function is async doesn’t matter that much. It’s a piece of code that is able to select and dispatch things from/to the store, and can be exposed as an action on the store, that’s all.
The fact that the window.fetch call reaches out of the store and does a network request is also not fundamental. Yes, you’d better be aware that your store talks to the network, and yes, this is a side-effect in the functional programming terminology, but so what, there’s nothing magical about it, is it?
In the next post in this series we will compare thunks to the classic @wordpress/data generators and controls, which in turn are very similar to the redux-saga middleware in classic Redux.
Leave a comment