Handling side effects in a useReducer
setup challenged everything I thought I knew about React architecture. Initially, I believed reducers were meant only for synchronous logic, and side effects belonged to components. But once I started building apps where actions triggered loading states, fetched data, and required retries or conditional dispatches, that separation became messy fast. I needed a structured way to manage those effects—one that didn’t turn components into spaghetti.
The growing pain
The first time I tried adding API calls directly in my components while using a reducer, the logic got scattered. For every dispatch, I had a chain of async functions. That made the reducer lean and pure, sure—but the component bloated with responsibility. Whenever state logic changed, I had to modify the reducer and the component using it. It felt like I was duplicating effort. More importantly, it introduced bugs where actions fired without proper state sync.
Rethinking reducer purity
I initially held tight to the idea that reducers must be pure. But I realized that purity doesn’t mean rigidity. In larger applications, the concept of middleware gave me a new layer—a place where side effects could live, without leaking into the UI or the reducer itself. Middleware became the middleman, taking dispatched actions and, based on type, deciding whether to perform something extra—like fetching data—before allowing the reducer to do its job.
Creating a dispatch enhancer
The breakthrough came when I wrote a custom dispatch function. Instead of calling dispatch
directly from my components, I wrapped it with a function that intercepted certain action types and ran additional logic. For example, in a user-auth flow:
const middlewareDispatch = (dispatch) => async (action) => {
if (action.type === 'LOGIN') {
dispatch({ type: 'LOGIN_START' });
try {
const user = await loginAPI(action.payload);
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (err) {
dispatch({ type: 'LOGIN_ERROR', error: err });
}
} else {
dispatch(action);
}
};
This simple wrapper brought control back. I could handle asynchronous actions in one centralized place and keep my reducer strictly about state transitions. It was like giving my app a primitive version of Redux middleware—only tailored to exactly what I needed.
Managing multiple actions
As I scaled up, I didn’t want to keep adding more if/else
blocks to a single function. So I structured my middleware like a lookup map:
const actionHandlers = {
LOGIN: async (dispatch, payload) => {
dispatch({ type: 'LOGIN_START' });
try {
const user = await loginAPI(payload);
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (err) {
dispatch({ type: 'LOGIN_ERROR', error: err });
}
},
LOGOUT: async (dispatch) => {
await logoutAPI();
dispatch({ type: 'LOGOUT' });
},
};
const middlewareDispatch = (dispatch) => async (action) => {
const handler = actionHandlers[action.type];
if (handler) {
await handler(dispatch, action.payload);
} else {
dispatch(action);
}
};
This change was subtle, but powerful. Suddenly, the middleware wasn’t just a workaround—it was a real abstraction. Each async action had its own logic file, and my components became dumb again—in the best possible way.
Using with context
When I wrapped this middleware-enhanced dispatch in a context provider, it truly clicked. Now every part of my app—whether authentication, notifications, or theme handling—could use useReducer
with side effects, and they all stayed in sync. I structured my provider like this:
const AppContext = createContext();
const AppProvider = ({ children }) => {
const [state, dispatch] = useReducer(appReducer, initialState);
const enhancedDispatch = middlewareDispatch(dispatch);
return (
<AppContext.Provider value={{ state, dispatch: enhancedDispatch }}>
{children}
</AppContext.Provider>
);
};
This pattern became a key part of my architecture. Whether I was managing data tables like the ones I built while exploring React table styling, or handling quiz states in a React-based test app, it helped me separate intent from effect.
Benefits I didn’t expect
One unexpected benefit was testability. Middleware could now be tested in isolation—without needing to simulate UI events. I could pass in mock dispatchers and assert the exact sequence of dispatches for any given input.
Another was readability. As soon as I explained this middleware concept to my teammates, onboarding sped up. They didn’t need to understand React’s internals—just read the action handlers like a command list. This led me to treat middleware like an instruction set that describes how my app behaves behind the scenes.
Handling race conditions
Eventually, as side effects grew more complex, I ran into race conditions. Multiple dispatches would compete—like clicking “Save” twice in a row or navigating while loading. So I began adding internal loading checks inside the middleware itself. In some cases, I even cached results to avoid duplicate requests. Without middleware, all of this would have been a nightmare to scatter across components.
Final reflection
Introducing middleware into my useReducer
workflow gave me a new layer of freedom without losing the clarity I loved about React. It reminded me that abstraction isn’t about adding complexity—it’s about placing logic where it belongs. By offloading side effects from components and reducers to middleware, I found the right balance between control and simplicity.
And every time I revisit this pattern, I’m reminded that scalable architecture is less about tools, and more about discipline in where logic lives. This approach keeps my UI declarative, my reducers pure, and my side effects in check—even when the app grows beyond what I originally imagined.