Contents
Introduction — middleware, controls and side effects in WordPress data stores
When building complex Gutenberg blocks or administrative UI in WordPress, you will often need to perform asynchronous side effects (HTTP requests, local cache writes, background tasks) while keeping your data layer testable and predictable. The @wordpress/data package uses the concept of controls (middleware-like handlers) paired with actions, reducers, selectors and resolvers to handle side effects in a controlled way.
This article explains the architecture, patterns and practical implementation details to create middlewares/controls for effects in JavaScript data stores for WordPress. You will find clear, production-ready examples (with fully runnable code snippets), techniques for error handling, testing tips, performance considerations and examples of building a small custom controls engine if you prefer to implement your own middleware behavior.
Core concepts and architecture
- Actions — describe an intent (e.g., FETCH_POSTS). Action creators are functions that return plain objects.
- Reducers — pure functions that update store state when a synchronous action is received.
- Selectors — read access to state. Selectors can be synchronous or rely on asynchronous resolution.
- Controls — middleware-oriented handlers that perform side effects when specific action types are dispatched. They should return a value or a promise. The data store’s controls middleware inspects dispatched actions and runs any mapped control for that action type.
- Resolvers — logic run automatically to resolve a selector when data is missing. Resolvers typically dispatch actions that are handled by controls.
- Registry — the store registry used by controls/resolvers to call other selectors/dispatches allows coordination between stores.
Why use controls instead of putting side effects in action creators or components?
- Separation of concerns — components just dispatch intents, controls handle effects.
- Testability — controls can be tested in isolation or mocked in tests, leaving reducers/selectors pure.
- Composability — controls can call registry.select/dispatch to coordinate multiple stores.
- Determinism — reducers remain pure and synchronous.
Registering a store with controls (@wordpress/data)
In WordPress’s data package you register a store with registerStore. The store definition can include actions, reducers, selectors, resolvers and controls. The controls object maps action type constants to handler functions that perform side effects and return a value or a promise.
Step-by-step simple example — fetching posts
The following example demonstrates a small store that fetches posts from the REST API using the apiFetch utility and handles the side effect in a control.
import { registerStore } from @wordpress/data import apiFetch from @wordpress/api-fetch // Action type constants const FETCH_POSTS = FETCH_POSTS const RECEIVE_POSTS = RECEIVE_POSTS const RECEIVE_ERROR = RECEIVE_ERROR // Action creators const actions = { fetchPosts: () => ( { type: FETCH_POSTS } ), receivePosts: ( posts ) => ( { type: RECEIVE_POSTS, posts } ), receiveError: ( error ) => ( { type: RECEIVE_ERROR, error } ), } // Reducer const initialState = { posts: [], isLoading: false, error: null, } function reducer( state = initialState, action ) { switch ( action.type ) { case FETCH_POSTS: return { ...state, isLoading: true, error: null } case RECEIVE_POSTS: return { ...state, isLoading: false, posts: action.posts } case RECEIVE_ERROR: return { ...state, isLoading: false, error: action.error } default: return state } } // Controls map: handles side effects for given action types const controls = { [ FETCH_POSTS ]: async ( action, registry ) => { try { // apiFetch returns a Promise resolving to JSON const posts = await apiFetch( { path: /wp/v2/posts } ) // Return an action object to be dispatched by the controls middleware return actions.receivePosts( posts ) } catch ( error ) { // Returning an action describing the error return actions.receiveError( error ) } }, } // Simple selectors const selectors = { getPosts( state ) { return state.posts }, isLoading( state ) { return state.isLoading }, getError( state ) { return state.error }, } // Register the store registerStore( my-plugin/posts, { reducer, actions, selectors, controls, } )
Usage in your component or script:
const { dispatch, select } = window.wp.data // dispatch an intent — control will run and the returned action will be dispatched dispatch( my-plugin/posts ).fetchPosts() // read once the store updates const posts = select( my-plugin/posts ).getPosts()
Notes and important behavioral details
- What a control receives: controls are invoked with the dispatched action as the first argument. Some implementations also provide the registry (to call registry.select/dispatch) as a second argument — use it to coordinate work across stores.
- Return value: controls should return either a plain action object or a promise resolving to an action object (or any value your middleware expects). The data controls middleware will dispatch the returned action for you (so you typically return the follow-up action like receivePosts).
- Error handling: capture errors inside the control and return an error action so reducers can keep state consistent.
- Avoid direct dispatch in controls unless necessary: returning an action is preferred (the control layer will dispatch it). If you dispatch inside the control, be careful with ordering and double-dispatching.
Using resolvers together with controls (pattern)
Resolvers are triggered when a selector is called and the data to satisfy the selector is not present or is stale. Resolvers typically dispatch an action such as fetchPosts that action is then handled by a control which performs the async work.
A common pattern:
- Component calls select( my-store ).getPosts().
- getPosts selector finds data missing and returns undefined.
- The store’s resolver for getPosts runs and dispatches fetchPosts (the control executes the side effect).
- When fetchPosts completes the store updates and the selector will now return data.
Simplified resolver example (high level)
const resolvers = { getPosts() { // note: resolve API can be generator-based in some implementations // if posts missing, dispatch fetchPosts (control handles async) yield actions.fetchPosts() // after fetchPosts has finished and returned, selector can be re-evaluated }, }
(Exact resolver function form can be environment-specific — the key point is that resolvers coordinate the dispatch of actions that controls handle.)
Advanced control patterns
1) Optimistic updates
Optimistic updates update the UI before the server confirms the change. Use an action to update local state immediately, then a control to perform the network request and return a confirm or rollback action.
// Example optimistic pattern const OPTIMISTIC_CREATE = OPTIMISTIC_CREATE const CONFIRM_CREATE = CONFIRM_CREATE const REVERT_CREATE = REVERT_CREATE const actions = { optimisticCreate: ( tempItem ) => ( { type: OPTIMISTIC_CREATE, tempItem } ), confirmCreate: ( serverItem ) => ( { type: CONFIRM_CREATE, serverItem } ), revertCreate: ( tempId, error ) => ( { type: REVERT_CREATE, tempId, error } ), createItem: ( payload ) => ( { type: CREATE_ITEM, payload } ), // triggers control } const controls = { CREATE_ITEM: async ( action ) => { try { const serverItem = await apiFetch( { path: /my-endpoint, method: POST, body: action.payload } ) return actions.confirmCreate( serverItem ) } catch ( err ) { return actions.revertCreate( action.payload._tempId, err ) } } }
2) Cancellation, debouncing and request coalescing
Controls can implement cancellation tokens or debounce logic to avoid duplicate requests and to cancel irrelevant requests (e.g., searching as the user types). You can employ AbortController to cancel fetch requests. Use a small in-control cache keyed by query to coalesce duplicate requests.
const searchCache = new Map() const controls = { SEARCH: async ( action ) => { const query = action.query.trim() if ( ! query ) { return { type: SEARCH_RESULTS, results: [] } } // Coalesce identical concurrent requests if ( searchCache.has( query ) ) { return searchCache.get( query ) // could be a promise or action adapt to your middleware } const controller = new AbortController() const signal = controller.signal const promise = ( async () => { try { const results = await fetch( /wp-json/search?q={ encodeURIComponent( query ) }, { signal } ) .then( r => r.json() ) return { type: SEARCH_RESULTS, results } } catch ( e ) { return { type: SEARCH_ERROR, error: e } } finally { searchCache.delete( query ) } } )() searchCache.set( query, promise ) return promise } }
3) Retrying strategies and exponential backoff
Wrap network calls in a retry helper inside controls. Be mindful to keep retry logic scoped to the control (so reducers remain simple).
async function retry( fn, attempts = 3, delay = 300 ) { let lastError for ( let i = 0 i < attempts i ) { try { return await fn() } catch ( e ) { lastError = e await new Promise( res => setTimeout( res, delay Math.pow( 2, i ) ) ) } } throw lastError } const controls = { FETCH_WITH_RETRY: async ( action ) => { try { const data = await retry( () => apiFetch( { path: action.path } ), 4, 200 ) return { type: FETCH_OK, data } } catch ( error ) { return { type: FETCH_FAILED, error } } } }
Testing controls
Because controls encapsulate side effects, they are natural units for isolated tests. Use Jest to mock apiFetch or fetch and assert that your control returns the expected follow-up action(s).
// Example Jest test for a control import apiFetch from @wordpress/api-fetch jest.mock( @wordpress/api-fetch ) import { controls } from ./store test( FETCH_POSTS control returns RECEIVE_POSTS action on success, async () => { const fakePosts = [ { id: 1, title: A } ] apiFetch.mockResolvedValue( fakePosts ) const result = await controls.FETCH_POSTS( { type: FETCH_POSTS } ) expect( result ).toEqual( { type: RECEIVE_POSTS, posts: fakePosts } ) } ) test( FETCH_POSTS control returns RECEIVE_ERROR action on failure, async () => { const error = new Error( network ) apiFetch.mockRejectedValue( error ) const result = await controls.FETCH_POSTS( { type: FETCH_POSTS } ) expect( result.type ).toBe( RECEIVE_ERROR ) expect( result.error ).toBe( error ) } )
Debugging and observability
- Log action flow: instrument the controls to console.debug the action and returned result while developing.
- Time metrics: measure control execution time to detect slow third-party APIs.
- Error reporting: capture critical control errors to Sentry or another aggregator.
- Testing environment: replace apiFetch with a mock/stub in unit tests so controls are deterministic.
Implementing a small custom middleware engine (generic JavaScript)
If you need the exact control semantics in a plain Redux-like store or want to experiment before wiring to @wordpress/data, the following shows a minimal middleware runner that maps action types to handlers, supports async handlers and automatically dispatches returned follow-up actions.
// Minimal controls middleware for Redux-like store function createControlsMiddleware( controlsMap ) { return ( { dispatch, getState } ) => next => action => { // First forward to reducers (so optimistic updates can be synchronous) const result = next( action ) // If there is a control for this action type, run it const control = controlsMap[ action.type ] if ( typeof control === function ) { try { // call with action and a small registry API const registry = { dispatch, getState, select: getState } // adapt select if needed const maybePromise = control( action, registry ) if ( maybePromise typeof maybePromise.then === function ) { // async control: dispatch follow-up action when resolved maybePromise.then( ( followUp ) => { if ( followUp ) { // dispatch action object or do nothing if control returned void dispatch( followUp ) } } ).catch( ( err ) => { // optionally dispatch a global error action dispatch( { type: CONTROL_THREW_ERROR, error: err, originalAction: action } ) } ) } else if ( maybePromise ) { // synchronous follow-up value (action), dispatch it dispatch( maybePromise ) } } catch ( err ) { dispatch( { type: CONTROL_THROWN, error: err, originalAction: action } ) } } return result } }
You can add this middleware via Redux applyMiddleware when creating a store. In WordPress, @wordpress/data already wires a controls middleware, so use the store registration API rather than re-creating the wheel unless you need full control.
Best practices and checklist
- Keep reducers pure and limited to synchronous state transitions.
- Keep controls focused: one control per action type, single responsibility.
- Return clear follow-up actions from controls so state changes remain explicit and traceable.
- Use the registry to coordinate between multiple stores (read with select, write with dispatch).
- Handle errors inside the control and return or dispatch error actions — do not throw unhandled exceptions.
- Test controls in isolation by mocking network and time-dependent functions.
- Favor returning actions (so middleware can dispatch them) rather than dispatching directly in the control — avoids race conditions and double dispatch.
Common pitfalls and how to avoid them
- Double dispatching: If a control both returns an action and calls dispatch on that same action, you may dispatch twice. Choose a single pattern and be consistent.
- Leaking AbortController across calls: Create per-request AbortController instances or manage them in a map keyed by request id.
- Blocking UI with long-running controls: Always make long work async and consider optimistic UI updates.
- Selector-resolver loops: Ensure resolvers check whether data is already being fetched to avoid recursive dispatch cycles.
Real-world orchestration examples
Batching multiple requests into a single control
If many UI components request the same data in a short window, a control can aggregate requests and fire a single network call, then return results to all callers. Implementation commonly uses a request queue and stored promises.
// simplified batching example let pendingBatch = null const controls = { FETCH_USERS_BATCH: ( action ) => { if ( ! pendingBatch ) { let queued = [] pendingBatch = { queued, promise: ( async () => { const ids = queued.map( q => q.id ) const data = await apiFetch( { path: /my-api/users?ids={ ids.join(,) } } ) queued.forEach( q => q.resolve( data.find( u => u.id === q.id ) ) ) pendingBatch = null } )(), } } return new Promise( ( resolve, reject ) => { pendingBatch.queued.push( { id: action.id, resolve, reject } ) } ).then( user => ( { type: RECEIVE_USER, user } ) ) } }
Resources
- WordPress @wordpress/data package documentation
- WordPress data guides and patterns
- MDN Fetch API and AbortController
Summary
Controls (middleware) are a powerful, testable way to encapsulate side effects in WordPress data stores. Use a disciplined approach: dispatch intents from UI, keep reducers pure, implement side effects in controls, and coordinate retrieval with resolvers as needed. The patterns and examples above give you a full toolkit to build robust effects handling in your WordPress plugins and editor extensions.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |