Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Reactive Form Validation with Inter-Field Dependencies

Hello! Welcome back to our course on advanced RxJS.

In our last lesson, we explored derived state, using combineLatest to create new streams that reactively combine values from multiple sources. We saw how this declarative pattern simplifies state synchronization by defining what the state is, not how to keep it updated.

Today, we'll apply that powerful concept to one of the most common and complex areas of front-end development: form validation.

This lesson addresses the learning outcome: Handle complex form validation with inter-field dependencies by combining value streams. You'll learn to model a form's state as a collection of streams and use RxJS operators to create sophisticated, real-time validation logic that is both efficient and easy to maintain.

1. The Reactive Approach to Form Validation

In a traditional approach, you might write imperative code inside event handlers (onChange, onBlur) to check field values, trigger validation, and update the UI. When validation rules depend on multiple fields (e.g., "confirm password" must match "password"), this logic can quickly become a tangled mess of conditional statements.

The reactive paradigm offers a cleaner alternative. We can think of a form as a system of streams:

  • Each input field is a source stream, emitting its value whenever it changes.
  • Validation rules are derived streams, created by combining one or more field streams.
  • The overall validity of the form is also a derived stream, composed from the individual validation streams.

This approach transforms validation from a series of manual checks into a self-updating data flow graph.

2. A Simple Case: Password Confirmation

Let's start with a classic example: ensuring a password and its confirmation match. This is a perfect illustration of an inter-field dependency.

Building on what we learned about combineLatest, we can model this as follows:

  1. Create two BehaviorSubjects to represent the value streams for the password and confirmation fields.
  2. Use combineLatest to listen to the latest values from both streams.
  3. Use the map operator to derive a boolean stream indicating whether the values match.
import { BehaviorSubject, combineLatest, map } from 'rxjs';

// 1. Source streams for each form field
const password$ = new BehaviorSubject<string>('');
const confirmPassword$ = new BehaviorSubject<string>('');

// You would hook these up to your React input components' onChange events
// e.g., onChange={(e) => password$.next(e.target.value)}

// 2. Derived stream for validation logic
const passwordsMatch$ = combineLatest([
  password$,
  confirmPassword$
]).pipe(
  // The map function receives an array of the latest values
  map(([password, confirmPassword]) => {
    // Avoid showing an error when the fields are empty
    if (!password && !confirmPassword) {
      return true;
    }
    return password === confirmPassword;
  })
);

// 3. Subscribe in your component to display an error message
passwordsMatch$.subscribe(doMatch => {
  if (!doMatch) {
    console.log("Error: Passwords do not match!");
    // In React, you'd set state to show an error message
  } else {
    console.log("Passwords match.");
    // Clear the error message
  }
});

// Simulate user typing
password$.next('supersecret');
confirmPassword$.next('supersec'); // Logs "Error: Passwords do not match!"
confirmPassword$.next('supersecret'); // Logs "Passwords match."

This simple pattern is the foundation for all complex form validation in RxJS. You define the relationship, and RxJS handles the execution.

3. Architecting a Complex Real-World Form

While BehaviorSubjects work well for simple cases, integrating with established form libraries like React Hook Form (RHF) is common in large applications. Let's analyze a sophisticated, real-world architecture that combines RHF with RxJS.

The following article presents a comprehensive solution for a large user profile form. It integrates several tools, but we will focus on the core RxJS patterns for state management and validation.

Super React Hook Form: Revolutionizing Form State ...

The article 'Super React Hook Form' by Ahmed Shaheen demonstrates an advanced architecture for managing complex forms. We'll use it to see how to bridge a form library with a central RxJS state stream.

Please read the sections under the headings 'Now, let’s create a custom useFormHook to allow us to subscribe to form changes' and 'Now, let’s create the observable'. Focus on two key pieces of code: the useForm hook that subscribes to form changes and pushes them into a BehaviorSubject, and the creation of the formState$ BehaviorSubject itself. Don't worry about the other custom hooks for now.

The key architectural pattern you just read about is:

  1. Create a Central State Stream: A single BehaviorSubject, named formState$, is created to act as the reactive source of truth for the entire form's state (values, errors, dirty state, etc.).

    // From src\features\forms\user\subscribe.ts
    export const formState$ = new BehaviorSubject<FormState<UserFormInput>>({ ... });
    
  2. Bridge the Library to RxJS: A custom useForm hook subscribes to React Hook Form's internal state changes and uses .next() to push the entire form state object into our formState$ subject.

    // From src\features\forms\shared\useForm.ts
    useEffect(() => {
      const unsubscribe = formControl.subscribe({
        // ... what to watch
        callback: (payload) =>
          formState$.next({ ...formState$.getValue(), ...payload }),
      });
      return () => unsubscribe();
    }, [formControl.subscribe, formState$]);
    

This is a powerful pattern. It effectively creates a "reactive mirror" of the form's state, allowing us to use the full power of RxJS to work with it, independent of the React component lifecycle.

4. Implementing Inter-field Validation on the State Stream

Now that we have the entire form state in a single stream (formState$), we can create derived streams for our validation logic.

Let's imagine a validation rule for the form in the article: The "Job Title" field should be required if and only if "Employment Status" is set to 'employed' or 'selfEmployed'.

This is a classic inter-field dependency. Here's how you would implement it using our formState$ stream:

import { map, distinctUntilChanged, combineLatest } from 'rxjs/operators';

// 1. Create streams for the specific fields we need
const employmentStatus$ = formState$.pipe(
  map(state => state.values.employmentStatus),
  distinctUntilChanged() // Only emit when the status actually changes
);

const jobTitle$ = formState$.pipe(
  map(state => state.values.jobTitle),
  distinctUntilChanged() // Only emit when the job title actually changes
);

// 2. Combine them to create the derived validation stream
const jobTitleError$ = combineLatest([
  employmentStatus$,
  jobTitle$
]).pipe(
  map(([status, title]) => {
    const isEmployed = status === 'employed' || status === 'selfEmployed';
    if (isEmployed && (!title || title.trim() === '')) {
      return 'Job title is required for your employment status.';
    }
    return null; // No error
  })
);

// 3. In a React component, you would subscribe to jobTitleError$
// and display the error message when it's not null.

This code declaratively defines the validation logic. It will automatically re-evaluate whenever employmentStatus or jobTitle changes, providing the correct error state at all times.

5. Practical Refinements for Better UX

While the above logic is correct, running validation on every single keystroke can be inefficient and lead to a poor user experience (e.g., showing an error message while the user is still typing). The RxJS community has developed standard patterns to handle this.

The resource below is a blog post followed by a rich comment section where developers share their best practices.

Graceful Form Error Handling with RxJS Best Practices

Let's look at some community-driven best practices from the article 'Graceful Form Error Handling with RxJS Best Practices'. The comments section is particularly insightful.

Please scan through the Comments section. Look for the comments by racquel gostowski on debounceTime, linnea mcmillan on switchMap for async validation, and Eilene E. on using combineLatest to handle multiple errors.

Let's integrate these ideas.

Debouncing User Input

As racquel gostowski mentioned, debounceTime is perfect for delaying validation until the user has stopped typing for a moment.

We can add it to our jobTitle$ stream:

import { debounceTime } from 'rxjs/operators';

const jobTitle$ = formState$.pipe(
  map(state => state.values.jobTitle),
  debounceTime(400), // Wait for 400ms of inactivity
  distinctUntilChanged()
);

Now, our jobTitleError$ stream will only update 400ms after the user stops typing in the job title field, which feels much more natural.

Asynchronous Validation

What if you need to validate a username against the server? This is an async operation. As linnea mcmillan pointed out, switchMap is the ideal operator. It cancels previous, in-flight requests if a new value arrives, preventing race conditions.

import { switchMap, catchError } from 'rxjs/operators';
import { of } from 'rxjs';

// Assume username$ is a stream of the username field's value
const usernameError$ = username$.pipe(
  debounceTime(500),
  distinctUntilChanged(),
  switchMap(username => {
    if (!username) {
      return of(null); // No username, no error
    }
    // api.checkUsername returns an Observable
    return api.checkUsername(username).pipe(
      map(isAvailable => isAvailable ? null : 'Username is already taken.'),
      catchError(() => of('Could not validate username. Please try again.'))
    );
  })
);

Aggregating All Form Errors

Finally, as Eilene E. noted, you can use combineLatest again to aggregate all your individual error streams into a single error object for the entire form.

const formErrors$ = combineLatest({
  jobTitle: jobTitleError$,
  username: usernameError$,
  // ...other validation streams
}).pipe(
  map(errors => {
    // Filter out nulls to get a clean error object
    const activeErrors = Object.entries(errors)
      .filter(([key, value]) => value !== null)
      .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
    return activeErrors;
  })
);

// formErrors$ will now emit an object like:
// { username: 'Username is already taken.' }
// or {} if there are no errors.

This final stream gives your application a complete, real-time picture of the form's validation state.

Conclusion

Today we've seen how to build a robust, scalable, and maintainable form validation system using RxJS. By treating form inputs as streams and validation rules as derived state, we can manage even highly complex inter-field dependencies with clear, declarative code.

Key Takeaways:

  • A form can be modeled as a system of streams, with each field as a source and validation rules as derived streams.
  • combineLatest is the core operator for implementing validation logic that depends on multiple fields.
  • You can create a central formState$ stream to act as a single source of truth, especially when integrating with form libraries like React Hook Form.
  • Operators like debounceTime and distinctUntilChanged are essential for improving the performance and user experience of real-time validation.
  • switchMap is the go-to operator for handling asynchronous validation (e.g., API calls) while avoiding race conditions.
  • You can aggregate multiple individual error streams into a single, comprehensive form error object using combineLatest.

Preview of the Next Lesson:

We have focused on creating complex read models of our state (deriving validation from values). In our next lesson, we will turn our attention to managing complex writes. We will create a Redux-like pattern with action streams and a state reducer function powered by the scan operator. This will give us a structured and predictable way to manage state transitions in response to user actions or other events.

Can't find a good explanation? Sign up and we'll make it for you

Sign up