Hello! Welcome back.
In our last lesson, we built a powerful foundation by creating a custom epicMiddleware for Zustand. This allowed us to manage complex asynchronous side effects using the "actions in, actions out" pattern with RxJS, cleanly separating our async logic from our state management. We ended with a brief look at switchMap and its cancellation capabilities.
Today, we'll put that pattern into practice to solve a very common and often tricky real-world problem. Our learning outcome is to apply RxJS switchMap within Zustand middleware to handle form submission side effects while using the store for form state.
We will explore why standard form submissions can be prone to race conditions, see why switchMap is the ideal operator to solve this, and then build a complete example from the store to the React component.
1. The Form Submission Race Condition
Let's consider a typical form submission flow: a user fills out a form, clicks "Submit," and we make an API call. While the request is in flight, we usually disable the submit button to prevent duplicates.
However, what if the user is impatient and manages to click the button twice before it's disabled? Or what if it's an "auto-save" form that triggers a submission on every change (after some debouncing)?
In these scenarios, if we use an operator like mergeMap (which we used for our simple fetch last lesson), we could have multiple requests running concurrently. This introduces a race condition:
- User submits Form A. Request A is sent.
- User quickly changes data and submits Form B. Request B is sent.
- Request B is fast and returns successfully. The UI updates to show "Success for B".
- Request A was slow, but it finally returns. The UI incorrectly updates to show "Success for A", overwriting the more recent state.
This is where we need a "latest-wins" strategy. When a new submission is triggered, we must cancel any pending submission and only care about the result of the newest one. This is precisely what switchMap is designed for.
Let's look at a resource that explains this concept. While it uses a search-as-you-type example, the underlying principle of canceling stale requests is identical to our form submission problem.
Reactive + Functional UI Patterns in TypeScript and F#: RxJS ...
This article, 'Reactive + Functional UI Patterns', explains how switchMap restores determinism and prevents race conditions in asynchronous operations.
Please read section '4.3 When signals can introduce subtle races... and how RxJS switchMap restores determinism'. Focus on the 'Incorrect' vs. 'Corrected with switchMap' examples. This clearly illustrates the problem and the solution.
2. Visualizing switchMap
The key behavior of switchMap is that it unsubscribes from the previous inner Observable as soon as it receives a new value from the outer Observable. This effectively cancels the work being done by that inner Observable (like an in-flight API request).
This marble diagram provides an excellent visual comparison of switchMap against other flattening operators.

The "Operator selection matrix" in the resource we just read provides a great rule of thumb. It recommends switchMap for "Navigation / search suggestions," which fits our "latest-wins" requirement perfectly.
3. Implementing a Form with switchMap
Now, let's build a solution using the epicMiddleware from our previous lesson. We'll create a simple contact form where the state is managed by Zustand and the submission logic is handled by an epic using switchMap.
Step 1: Define the Zustand Store
First, we'll set up our store. It needs to hold the form's data and the submission status.
// store.ts
import create from 'zustand';
import { epicMiddleware, Action } from './epicMiddleware'; // From previous lesson
import { rootEpic } from './epics';
export type SubmissionStatus = 'idle' | 'pending' | 'success' | 'error';
export interface FormState {
name: string;
email: string;
}
interface MyState {
form: FormState;
status: SubmissionStatus;
error: string | null;
}
const initialState: MyState = {
form: { name: '', email: '' },
status: 'idle',
error: null,
};
// Our reducer logic that will be used by the middleware
const reducer = (state: MyState, action: Action): MyState => {
switch (action.type) {
case 'UPDATE_FORM_FIELD':
return {
...state,
form: { ...state.form, ...action.payload },
};
case 'SUBMIT_FORM':
return { ...state, status: 'pending', error: null };
case 'SUBMIT_FORM_SUCCESS':
return { ...state, status: 'success' };
case 'SUBMIT_FORM_FAILURE':
return { ...state, status: 'error', error: action.payload };
default:
return state;
}
};
// A slightly improved middleware that properly integrates the reducer
// (This would be a refactor of last lesson's middleware)
/*
const epicMiddleware = (rootEpic, reducer) => (config) => (set, get, api) => {
// ... setup action$ and dispatch ...
rootEpic(action$, state$).subscribe(action => {
set(state => reducer(state, action));
});
// The initial store creator now also acts as the reducer for non-epic actions
const enhancedDispatch = (action) => {
set(state => reducer(state, action)); // Apply reducer for sync actions
action$.next(action); // Push to epic stream
}
api.dispatch = enhancedDispatch;
return config(set, get, api);
}
*/
// For today, we'll assume the middleware from the last lesson is set up
// and correctly dispatches epic outputs to a reducer function.
export const useStore = create<MyState>(/* ... setup with middleware ... */);
Step 2: Write the submitFormEpic
This is the core of our lesson. This epic will listen for the SUBMIT_FORM action and use switchMap to handle the API call. We'll use withLatestFrom to get the current form state.
// epics.ts
import { combineEpics, ofType } from 'redux-observable';
import { switchMap, map, catchError, delay, withLatestFrom } from 'rxjs/operators';
import { of, Observable } from 'rxjs';
import { Epic, Action } from './epicMiddleware';
import { MyState, FormState } from './store';
// A mock API call that can succeed or fail
const mockApiSubmit = (formData: FormState): Observable<{ success: boolean }> => {
console.log('Submitting:', formData);
return of({ success: true }).pipe(
delay(1500) // Simulate network latency
);
};
const submitFormEpic: Epic<MyState> = (action$, state$) =>
action$.pipe(
ofType('SUBMIT_FORM'),
withLatestFrom(state$), // Get the latest state when an action comes through
switchMap(([action, state]) => {
// Now we have the form data from the state
const formData = state.form;
return mockApiSubmit(formData).pipe(
map(response => {
if (response.success) {
return { type: 'SUBMIT_FORM_SUCCESS' };
}
// This path would be taken if the API itself indicates a failure
return { type: 'SUBMIT_FORM_FAILURE', payload: 'Submission failed!' };
}),
catchError(error => {
// This catches network errors or errors thrown in the mockApiSubmit observable
return of({ type: 'SUBMIT_FORM_FAILURE', payload: error.message });
})
);
})
);
export const rootEpic = combineEpics(submitFormEpic);
Analysis of the Epic:
ofType('SUBMIT_FORM'): The epic only triggers when aSUBMIT_FORMaction is dispatched.withLatestFrom(state$): Before processing, it pairs the action with the most recent state from thestate$stream. This gives us access to the form data without needing to pass it in the action payload.switchMap(...): This is the crucial part. If a newSUBMIT_FORMaction arrives while themockApiSubmitis still running,switchMapwill automatically unsubscribe from the pending submission and start a new one with the latest form data.mapandcatchError: Inside theswitchMap, we handle the success and failure cases of our API call, mapping the results to the appropriate "outcome" actions.
Step 3: The React Component
The component becomes quite simple. Its responsibility is to render the UI based on the store's state and dispatch actions to signal user intent.
// ContactForm.tsx
import React from 'react';
import { useStore } from './store';
import shallow from 'zustand/shallow';
export const ContactForm = () => {
// Select multiple state slices. `shallow` prevents re-renders if other parts of the state change.
const { form, status, error } = useStore(
(state) => ({ form: state.form, status: state.status, error: state.error }),
shallow
);
// Assuming `dispatch` is attached to the store's API by our middleware
const dispatch = (useStore.getState() as any).dispatch;
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({
type: 'UPDATE_FORM_FIELD',
payload: { [e.target.name]: e.target.value },
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: 'SUBMIT_FORM' });
};
const isPending = status === 'pending';
return (
<form onSubmit={handleSubmit}>
<h2>Contact Us</h2>
<div>
<label htmlFor="name">Name:</label>
<input
type="text"
id="name"
name="name"
value={form.name}
onChange={handleInputChange}
disabled={isPending}
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={form.email}
onChange={handleInputChange}
disabled={isPending}
/>
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
{status === 'success' && <p style={{ color: 'green' }}>Form submitted successfully!</p>}
{status === 'error' && <p style={{ color: 'red' }}>Error: {error}</p>}
</form>
);
};
If you were to run this code and rapidly click the "Submit" button, you would see multiple "Submitting: ..." logs in the console, but only the last one would complete its 1.5-second delay and trigger the SUBMIT_FORM_SUCCESS action. The previous ones would be cancelled.
Conclusion
Today, we implemented a robust, real-world pattern for handling form submissions. By combining Zustand for state management with an RxJS epic, we achieved a clean separation of concerns and solved the tricky problem of race conditions.
Key Takeaways:
switchMapfor "Latest-Wins":switchMapis the ideal operator for scenarios where only the result of the most recent action matters, such as form submissions or type-ahead searches. It prevents race conditions by canceling previous, stale operations.- State-Driven Epics: Using
withLatestFrom(state$)allows our epics to access the current application state declaratively, making them powerful and self-contained. - Declarative Side Effects: The
submitFormEpicclearly describes the entire submission workflow—trigger, data fetching, success, and failure—in a single, composable pipeline. This is significantly cleaner and more maintainable than an equivalentasync/awaitimplementation with manual cancellation logic.
Next Lesson Preview:
We have now mastered how to trigger and manage side effects from our store's actions. In the next lesson, we will explore the other side of the integration: creating derived RxJS streams from Zustand selectors to combine store state with other asynchronous sources. This will enable us to build UI that reactively combines data from our store with data from external streams, like WebSockets or other browser events.
Can't find a good explanation? Sign up and we'll make it for you
Sign up