My Http request is not firing anymore after I started pulling data in from a reactive form observable stream

78 Views Asked by At

I have an Angular app (v17) in an Nx Monorepo. This component is in a library I created. For some reason, when I submit my form, the Http request does not fire. Everything was working until I tried to use a SwitchMap to get the reactive form's data stream into the request. I've poured over docs and examples and can't seem to figure out where I'm going wrong. I should mention as well that this is an SSR/SSG Angular app.

export class SignupFormComponent {
  http = inject(HttpClient);
  fb = inject(FormBuilder);

  headers = new HttpHeaders({ 'Content-Type': 'application/json' });
  portalId = 'dummy';
  formId = 'dummy';

  signUpForm = this.fb.group({
    firstName: ['', [Validators.required]],
    lastName: ['', [Validators.required]],
    email: ['', [Validators.required, Validators.email]],
    company: ['', [Validators.required]],
  });

  dataObject$ = this.signUpForm.valueChanges.pipe(
    debounceTime(500),
    distinctUntilChanged(),
    map((val) =>
      Object.keys(val).map((key) => ({
        objectTypeId: '0-1',
        name: key.toLowerCase(),
        value: val[key as keyof typeof val],
      })),
    ),
  );

  req = this.dataObject$.pipe(
    switchMap((data) => {
      const url = `https://api.hsforms.com/submissions/v3/integration/submit/${this.portalId}/${this.formId}`;
      return this.http.post(url, JSON.stringify(data), {
        headers: this.headers,
      });
    }),
  );

  submitForm() {
    return this.req.subscribe(console.log);
  }
}
<form [formGroup]="signUpForm" (ngSubmit)="submitForm()">
  <label for="first-name" class="text-white">First Name</label>
  <input id="first-name" type="text" formControlName="firstName" />
  <label for="last-name" class="text-white">Last Name</label>
  <input id="last-name" type="text" formControlName="lastName" />
  <label for="email" class="text-white">Email</label>
  <input id="email" type="text" formControlName="email" />
  <label for="company" class="text-white">Company</label>
  <input id="company" type="text" formControlName="company" />
  <button type="submit">
    Join Now
  </button>
</form>

2

There are 2 best solutions below

7
Naren Murali On

The valueChanges is a good option when we subscribe to it during the form initialization, since we have distinctUntilChanged present and there are no value changes after the form is submitted the POST is never called, to simplify this use case, we can just manually contruct the request object using the same logic you wrote and then call the api!

import { CommonModule } from '@angular/common';
import {
  HttpClient,
  HttpHeaders,
  provideHttpClient,
} from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { bootstrapApplication } from '@angular/platform-browser';
import {
  distinctUntilChanged,
  debounceTime,
  map,
  switchMap,
  BehaviorSubject,
} from 'rxjs';
import 'zone.js';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [ReactiveFormsModule, CommonModule],
  template: `
    <form [formGroup]="signUpForm" (ngSubmit)="submitForm()">
      <label for="first-name" class="text-white">First Name</label>
      <input id="first-name" type="text" formControlName="firstName" />
      <label for="last-name" class="text-white">Last Name</label>
      <input id="last-name" type="text" formControlName="lastName" />
      <label for="email" class="text-white">Email</label>
      <input id="email" type="text" formControlName="email" />
      <label for="company" class="text-white">Company</label>
      <input id="company" type="text" formControlName="company" />
      <button type="submit">
        Join Now
      </button>
    </form>
  `,
})
export class App {
  formObjectSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
  http = inject(HttpClient);
  fb = inject(FormBuilder);

  headers = new HttpHeaders({ 'Content-Type': 'application/json' });
  portalId = 'dummy';
  formId = 'dummy';

  signUpForm = this.fb.group({
    firstName: ['', [Validators.required]],
    lastName: ['', [Validators.required]],
    email: ['', [Validators.required, Validators.email]],
    company: ['', [Validators.required]],
  });

  constructData() {
    return Object.keys(this.signUpForm.controls).map((key) => {
      const val = this.signUpForm.controls;
      return {
        objectTypeId: '0-1',
        name: key.toLowerCase(),
        value: val[key as keyof typeof val].value,
      };
    });
  }

  submitForm() {
    const data = this.constructData();
    console.log(data);
    const url = `https://api.hsforms.com/submissions/v3/integration/submit/${this.portalId}/${this.formId}`;
    this.http.post(url, JSON.stringify(data), {
      headers: this.headers,
    }).subscribe();
  }
}

bootstrapApplication(App, {
  providers: [provideHttpClient()],
});

stackblitz

0
maxime1992 On

As explained in my comment, you have a couple of things that prevent it to work well:

  • Mixing imperative and reactive programming
  • Listening to a stream based of a form valueChanges, which does not emit when subscribed to (you'd need a startWith with the current form value)

That said, your code isn't handling any error as well so I have made you a detailed example that should be fairly robust with error handling and display of the current status in the UI.

I've made a live demo with a mock server but before, let's have a look to the code.

interface HttpCallSuccess<Data> {
  type: 'success';
  data: Data;
}
interface HttpCallError {
  type: 'error';
  error: String;
}
interface HttpCallSending {
  type: 'sending';
}
type HttpCallState<Data> =
  | HttpCallSending
  | HttpCallSuccess<Data>
  | HttpCallError;

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <form [formGroup]="signUpForm" (ngSubmit)="submitButtonClicked$$.next()">
      <label for="first-name" class="text-white">First Name</label>
      <input id="first-name" type="text" formControlName="firstName" /><br />

      <label for="last-name" class="text-white">Last Name</label>
      <input id="last-name" type="text" formControlName="lastName" /><br />

      <button type="submit" [disabled]="submitButtonDisabled$ | async" >Join Now</button>
    </form>

    <p>{{ textSummary$ | async }}</p>
  `,
  imports: [CommonModule, ReactiveFormsModule],
})
export class App {
  private readonly http = inject(HttpClient);
  private readonly fb = inject(FormBuilder);
  private readonly destroyRef = inject(DestroyRef);

  public readonly submitButtonClicked$$ = new Subject<void>();

  public readonly signUpForm = this.fb.group({
    firstName: ['', [Validators.required]],
    lastName: ['', [Validators.required]],
  });

  private readonly formattedValues$ = this.signUpForm.valueChanges.pipe(
    map((val) =>
      (Object.keys(val) as Array<keyof typeof val>).map((key) => ({
        objectTypeId: '0-1',
        name: key.toLowerCase(),
        value: val[key],
      }))
    )
  );

  private readonly sendFormToServerSideEffect$: Observable<
    HttpCallState<void>
  > = this.submitButtonClicked$$.pipe(
    withLatestFrom(this.formattedValues$),
    switchMap(([_, data]) => {
      const httpCall$ = this.http.post<void>(`https://your-api.com`, data).pipe(
        map((res): HttpCallSuccess<void> => ({ type: 'success', data: res })),
        catchError((e: Error): Observable<HttpCallError> => {
          console.error(e); // should be a proper logger instead
          return of({ type: 'error', error: e.message });
        })
      );

      return concat(of({ type: 'sending' as const }), httpCall$);
    }),
    shareReplay({ refCount: true, bufferSize: 1 })
  );

  public readonly submitButtonDisabled$ = this.signUpForm.statusChanges.pipe(
    startWith(this.signUpForm.status),
    switchMap((status) => {
      if (status === 'INVALID') {
        return of(true);
      }
      return concat(
        of(false), // unlock the submit button when form becomes valid
        this.sendFormToServerSideEffect$.pipe(
          map((requestState) => {
            return {
              sending: true,
              success: true,
              error: false, // let the user retry if there's an error
            }[requestState.type];
          })
        )
      );
    })
  );

  public readonly textSummary$: Observable<string> =
    this.sendFormToServerSideEffect$.pipe(
      map((httpCallState) => {
        switch (httpCallState.type) {
          case 'success':
            return 'Thanks for registering';
          case 'error':
            return `Something went wrong: "${httpCallState.error}". You can retry`;
          case 'sending':
            return `Please wait while we're processing your request`;
        }
      })
    );

  constructor() {
    this.sendFormToServerSideEffect$
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe();
  }
}

Key points of the code:

  • No subscribe within methods. When you subscribe, reactivity ends. I only subscribe from the constructor and use a takeUntilDestroyed to make sure it's all cleaned up when the component is destroyed to avoid memory leaks
  • Actions from the view are bound to subjects instead of calling a method. Remember that we're in a reactive world and therefore we want to signal an event that some stream can react to. To make sure that we avoid any confusion, I call this one submitButtonClicked$$ which really suggests it's an event and not an action (like sendForm or something similar)
  • I use shareReplay({ refCount: true, bufferSize: 1 }) on streams that shouldn't be subscribed more than once
  • I try as much as possible to keep it close to a stream that represents a state machine, easier to reason about

In the demo you'll also see that I'm calling for real http.post but I have created a mock version of HttpClient like this:

@Injectable()
class MockHttpClient {
  private count = 0;
  public post() {
    this.count++;

    if (this.count === 1) {
      return timer(2000).pipe(
        switchMap(() =>
          throwError(() => new Error(`Some error from the mock server`))
        )
      );
    }

    return of({ status: 'ok' }).pipe(delay(2000));
  }
}

To be able to throw an error the first time, and succeed after. So do try it out in Stackblitz and you'll see the text changing and the submit button being enabled/disabled based on the status.

Here's the Stackblitz live demo.