How to Have A Hook Before a Redux Store Notify its Subscriber with TypeScript
Posted on: 2017-11-22
This is a long title but it says all. Redux is well known to function calling other functions with the capability of getting into the pipeline easily. For example, it's a defined pattern to create a middleware if you want to inject some code in the pipeline between the time an action is dispatched and that the store consumes the action. But, let say you want to hook some code between the store and the time it notifies everyone listening to the store, how would you do it?
Before getting into details, let's just summarize the difference between a middleware and what we try to accomplish. The middleware pattern is an upfront hook and is a type of store enhancer that Redux support by default. It's useful to handle a specific scenario where the middleware looks for specific data in the action and do something on expected type or payload. It's useful as well when we want to do a general action. In both cases, it's before the data is intercepted by reducers. The other scenario to hook before the notification is also done with Redux Enhancer. This time, a reducer modify the Redux store (main state) and the store is ready to notify all its subscriber to have the UI to get refreshed. With Redux, all subscribers are the one who used "connect" for example or manually used the "subscribe" function. If you used the "connect" approaches, the function "mapStateToProp" will be called and the React component that is connected will receive the new value. The Redux Enhancer allows you to do custom logic around the store like between the notification and the mapping occurs.
You may still think about why would you like to have something that far in the pipeline. One scenario may be that you have a middleware that was normalizing all the data and dispatching different part. Multiple reducers may require storing the data in different places. Since many reducers may be called, multiple notifications may also be triggered with the consequence of having many mapping to be called and many UI refreshes. While the UI portion may be handled with React logic (shouldUpdateComponent) to reduce potential performance penalty, the mapping code is still invoked. The issue around the mapping is that if you are normalizing your data, it would be the place to denormalize and hence not yet having all the data in the store to denormalized -- you want to denormalize only once your normalizer middleware is done dispatching ALL the data to ALL reducers. This is where a Redux enhancer can be helpful. The solution is to have a normalizerMiddleware and a Redux enhancer that will only allow the store to trigger notifications if not in a batch of dispatch. The normalizer middleware will be the one scoping the all the dispatches functions and will tell to the Redux's enhancer to notify. As you will see, the pattern will not block any notification if the middleware is not explicitly mentioning that we enter a phase of batching. This is important for all other scenarios that don't require batching notification.
import {
StoreEnhancer,
StoreEnhancerStoreCreator,
Reducer,
Store,
Dispatch,
Unsubscribe,
} from "redux";
export function batchedSubscribeEnhancer<S>(
hook: (notify: () => void, store: Store<S>) => void
): StoreEnhancer<S> {
let currentListeners: (() => void)[] = [];
let nextListeners: (() => void)[] = currentListeners;
function ensureCanMutateNextListeners(): void {
if (nextListeners === currentListeners) {
nextListeners = currentListeners.slice();
}
}
function subscribe(listener: () => void): Unsubscribe {
let isSubscribed = true;
ensureCanMutateNextListeners();
nextListeners.push(listener);
return function unsubscribe(): void {
if (!isSubscribed) {
return;
}
isSubscribed = false;
ensureCanMutateNextListeners();
const index = nextListeners.indexOf(listener);
nextListeners.splice(index, 1);
};
}
function notifyListeners(): void {
const listeners = (currentListeners = nextListeners);
for (let i = 0; i < listeners.length; i++) {
listeners[i]();
}
}
return (next: StoreEnhancerStoreCreator<S>): StoreEnhancerStoreCreator<S> =>
(reducer: Reducer<S>, preloadedState?: S): Store<S> => {
const store = next(reducer);
function dispatch(dispatchArgs: Dispatch<S>): Dispatch<S> {
const dispatchResult: Dispatch<S> = store.dispatch(dispatchArgs);
hook(notifyListeners, store);
return dispatchResult;
}
// Store<S>
return { ...store, dispatch, subscribe };
};
}
The code is the enhancer that will be injected in the configuration. It was inspired by the Redux Batch Subscribe. Some differences are that this is in TypeScript and that it is built to work with access to the store to take a decision about when to notify.
The following code shows how to setup the Redux's enhancer. Normally, you'll have many middlewares, some reducers and when it's time to setup your store you need to call for your enhancer. As you can see in the example below, we use the store to get a particular state we stored during the middleware phase to ensure we are in "batch mode". When we are, we do not notify, we wait. Since changing the "batch mode" state will trigger a reducer to set the mode we will come back in this function to notify.
const appliedMiddleware = applyMiddleware( routesMiddleware, ..., ..., normalizerMiddleware);
const reducersTyped = { app: appReducer, ..., ..., router: routerReducer }; const reducers = combineReducers(reducersTyped);
const store: Store<AppReduxState> = createStore(reducers, composeEnhancers(appliedMiddleware,
batchedSubscribeEnhancer((notify: () => void, storeSnapshot: Store<AppReduxState>) => {
if (!storeSnapshot.getState().app.isStoreInBatchMode) {
notify();
}
})
));
Here is an example of a normalizer based on the scenario discussed previously. Two different actions that are called at a different time of the software. Probably different API calls with a different response. When it receives the data, it calls an action. The middleware listens and act. In the example below, the "ACTION_A" receives 3 different entities that are handled by 3 different reducers. This would normally do 3 stores notification. But, since we using "batchNotification" function, who underneath wrap the 3 actions between 2 actions. One to set the "batch mode" to true and one to false. The resulting is the enhancer will only call one notification that will execute once the mapping and in consequence one render.
export const normalizerMiddleware: ExtendedMiddleware<AppReduxState> =
<S>(api: MiddlewareAPI<S>) =>
(next: Dispatch<S>) =>
<A extends Actions<any>>(action: A): A => {
if (typeof action.payload === "object") {
if (action.type === ACTION_A) {
const normalizedResponse = normalize(action.payload, statusRoot);
console.log(normalizedResponse);
batchNotifications(
next,
actionOrgsNormalized(normalizedResponse.entities.org),
actionSitesNormalized(normalizedResponse.entities.site),
actionAppliancesNormalized(normalizedResponse.entities.cache)
);
} else if (action.type === ACTION_B) {
const normalizedSiteForASingleOrg = normalize(action.payload, org);
next(actionSitesNormalized(normalizedSiteForASingleOrg.entities.site));
}
}
return next(action);
};
I won't provide the batchNotifications
code in here, but it's literally a call to next(on) followed by a loop through the collection of action and next(off)
. You can do different pattern as well, nothing lock you to what I am demonstrating. Since you have access to the whole store, you could stop notification if the data is not done being normalized by looking at the data as well.