Create your own
Lesson illustration

Zustand & RxJS: `switchMap` for Form Submission Side Effects

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.

This diagram shows how different operators handle inner streams. Notice how for `switchMap`, the emission of `B` from the source stream immediately stops the stream started by `A`, and `C` stops the stream started by `B`. Only the values from the most recent inner stream are passed through.

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:

  1. ofType('SUBMIT_FORM'): The epic only triggers when a SUBMIT_FORM action is dispatched.
  2. withLatestFrom(state$): Before processing, it pairs the action with the most recent state from the state$ stream. This gives us access to the form data without needing to pass it in the action payload.
  3. switchMap(...): This is the crucial part. If a new SUBMIT_FORM action arrives while the mockApiSubmit is still running, switchMap will automatically unsubscribe from the pending submission and start a new one with the latest form data.
  4. map and catchError: Inside the switchMap, 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:

  • switchMap for "Latest-Wins": switchMap is 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 submitFormEpic clearly 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 equivalent async/await implementation 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