Asynchronous forms with Django and vanilla JS

Darryl Buswell - Aug 25, 2021

Let's build an asynchronous form handler using Django and vanilla JS. No page refreshing needed here.

The stable release of Bootstrap 5 recently dropped. And we just went through an effort to update our own site from Bootstrap 4.5. The process was fairly painless to be honest, with only a few new and redundant classes to handle. But we did take the opportunity to drop the small amount of jQuery we were leveraging across the site, considering jQuery was removed as a dependency for BS5. I mean, what's the point of importing jQuery with Bootstrap 5 if you only need to handle some basic form validation?

There will no doubt be other developers considering the same switch. So we thought we would drop some code here for handling some basic form validation and ajax data transfer using Django and vanilla JS.

We like to follow a process of model > form > view > template. That is, 1) define the model to hold the data you are interested in, 2) create a form to collect that data, 3) create a view to render and receive that data, and then finally, 4) create the template to mark-up that form for the user. Let's step through this process using a simple form which will collect a users first name, last name, and email address.

To get started, create an extended user model, which includes fields for the users first name, last name, and email.

models.py



from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext_lazy as _

class User(AbstractUser):
    first_name = models.CharField(_('first name'), max_length=30, null=True, blank=True)
    last_name = models.CharField(_('last name'), max_length=30, null=True, blank=True)
    email = models.EmailField(_('email address'), max_length=75, blank=True)

    class Meta:
        verbose_name = "User"
        verbose_name_plural = "Users"

    def __str__(self):
        return self.email

Notice our use of the AbstractUser class here. We need this as we are looking to extend Django's default user model for this example.

Next, let's create a simple form to collect our user data.

forms.py



from django import forms
from django.contrib.auth import get_user_model

class UserForm(forms.ModelForm):
    first_name = forms.CharField(max_length=30, required=True)
    last_name = forms.CharField(max_length=30, required=True)
    email = forms.CharField(max_length=75, required=True)

    class Meta:
        model = get_user_model()
        fields = ('first_name', 'last_name', 'email',)

Now, let's create a basic view to handle server-side processing and rendering of our form. Nothing fancy here. But note how we are checking the request.POST headers to make sure the request type is an XMLHttpRequest. And likewise, we are returning responses to the client as a JsonResponse. We need these, as we are going to be using Ajax to handle data transfer asynchronously. That is, without the whole page needing to reload.

views.py



from django.views.generic import FormView
from apps.order.forms import UserForm
from django.shortcuts import render
from django.http.response import JsonResponse
from django.urls import reverse_lazy

class UserFormView(FormView):
    template_name = "form.html"
    success_url = reverse_lazy('index')
    form_class = UserForm

    def get(self, request, *args, **kwargs):
        ctx = self.get_context_data(**kwargs)

        return render(request, self.template_name, ctx)

    def post(self, request, *args, **kwargs):

        if request.POST.get('X-Requested-With') == 'XMLHttpRequest':
            user_form = self.form_class(request.POST, instance=request.user if self.request.user.is_authenticated else None)

            if user_form.is_valid():
                user = user_form.save()

                return JsonResponse({'status': 200, 'url': self.success_url})

            return JsonResponse({'status': 500, 'message': user_form.errors})

        messages.warning(request, 'Invalid data received')
        return redirect('order:summary')
        
    def get_context_data(self, **kwargs):
        ctx = super(UserFormView, self).get_context_data(**kwargs)

        ctx['user_form'] = UserForm(
            self.request.POST or None,
            instance=self.request.user if self.request.user.is_authenticated else None
        )

        return ctx

Keep in mind that you can handle your responses in any way you please. But we prefer to include a 'status' parameter, with a response of 200 for success and 500 for failure. Our client side JS will be able to pick up these values, and handle the response appropriately. Alongside that, we have also included a 'url' parameter with our success url lookup. So, if we process our form data correctly, we will return a success response and redirect the user to our success url. Likewise, we have included our form error dictionary for cases where we aren't able to validate the form. So, rather than redirecting the user to our success url, we will instead show any errors related to their form submission (without reloading the page).

Ok, now comes the HTML and JS. Since we created a model form via Django, we could simply let Django render out our form. But we prefer to have a little more control in our mark-up. So we are going to use our user_form context to style out each of the form fields, with specific labels and validation strings.

form.html



<html>

<body>
<form id="user-form"
    name="user-form"
    method="POST"
    action="{{ request.path }}"
    class="needs-validation" novalidate>

{% csrf_token %}

<div class="message"></div>

<div class="row">
  <div class="col">
    <div class="form-outline my-3">
      <input id="{{ form.first_name.auto_id }}"
             name="{{ form.first_name.html_name }}"
             type="text" maxlength="30" class="form-control" required>
      <label for="{{ form.first_name.auto_id }}" class="form-label">First name</label>
      <div class="valid-feedback">Looks good!</div>
      <div class="invalid-feedback">Please provide your first name.</div>
    </div>
  </div>
</div>

<div class="row">
  <div class="col">
    <div class="form-outline my-3">
      <input id="{{ form.last_name.auto_id }}"
             name="{{ form.last_name.html_name }}"
             type="text" maxlength="30" class="form-control" required>
      <label for="{{ form.last_name.auto_id }}" class="form-label">Last name</label>
      <div class="valid-feedback">Looks good!</div>
      <div class="invalid-feedback">Please provide your last name.</div>
    </div>
  </div>
</div>

<div class="row">
  <div class="col">
    <div class="form-outline my-3">
      <input id="{{ form.email.auto_id }}"
             name="{{ form.email.html_name }}"
             type="email" maxlength="75" class="form-control" required>
      <label for="{{ form.email.auto_id }}" class="form-label">email</label>
      <div class="valid-feedback">Looks good!</div>
      <div class="invalid-feedback">Please provide an email.</div>
    </div>
  </div>
</div>

<button type="submit"
        class="btn btn-outline-success btn-rounded my-4 px-5 py-2"
        data-mdb-ripple-color="success">Sign-up</button>

</form>
</body>
</html>

A few additional points to make here. First, you'll see that we are including a 'novalidate' tag on the form. This will prevent any default form validation from triggering when the user actually submits the form. But we need to replace that with some custom validation handler. That's where the 'needs-validation' class comes in. When the page loads, we will have our javascript execute and look for any containers which have this tag, and then listen for any submit button clicks within that container. That way, our javascript routine will be triggered and will allow us to handle any custom validation as well as package the Ajax call with our form data. Simple enough? One final note, you will also see that we have included an empty div with a 'message' class. We will use this div to write back our form validation errors from our Django view.

So, finally. Let's look at the javascript routine for this page.

form.html



<script>
(function () {
  'use strict';

  const forms = document.querySelectorAll('.needs-validation');

  Array.prototype.slice.call(forms).forEach(function (form) {

    form.addEventListener('submit', function (event) {
      event.preventDefault();
      event.stopPropagation();

      if (!form.checkValidity()) {
        form.classList.add('was-validated');
        return false;
      }

      var data = new FormData(form);

      var xhr = new XMLHttpRequest();

      xhr.open(form.method, form.action, true);

      data.append('X-Requested-With', 'XMLHttpRequest');
      data.append('X-CSRFToken', form.csrfmiddlewaretoken);

      xhr.onreadystatechange = function () {
        if (this.readyState === 4) {

          if (this.status === 200) {
            var response = JSON.parse(this.responseText);

            if (response.message) {

              const formMessage = form.querySelector('.message');
    
              if (response.status === 200) {
                formMessage.innerHTML = response.message;
    
              } else {
                for (let error in response.message) {
                  formMessage.innerHTML = formMessage.innerHTML + '<div class="alert alert-warning alert-dismissible fade show" role="alert" data-mdb-color="warning">' + response.message[error] + '<button type="button" class="btn-close" data-mdb-dismiss="alert" aria-label="Close"></button></div>';
                }
    
              formMessage.focus();

              }

            }

            if (response.url) {
              window.location.replace(response.url);
            }

          } else {

            if (!alert('There was an error with your request, please try again.')) {
              window.location.reload();
            }

          }
        }

      };

      xhr.send(data);

    }, false);
  });

})();
</script>

Ok, so there is a fair bit to unpick here. First, you'll see that we are looping through all the containers on our page with the 'needs-validation' class and adding an event listener for submit button clicks. If we do capture a submit button click, then we then do a check of whether the form passes our front-end validation to make sure that character limits aren't exceeded, the email field includes an appropriately formatted email address, and all that good stuff. If this validation doesn't pass, then our valid or invalid-feedback div will fire to give the user some early warning that they haven't filled out the form correctly well before we call anything on the server-side.

If we do get past that first form of validation, then our javascript routine will open the Ajax request, send our form data, and listen for a response from the server. That response can come back in a number of ways according to our JsonResponse handler. If we pass a 500 status via our JsonResponse with a message, we will use our message div to show the error to the user. That way, the user will be able to correct any server-side validation issues without needing to reload the entire page. If we pass a url via our JsonResponse, we will use that address to redirect the page.

So there you have it. An asynchronous form handler using Django and vanilla Javascript. Completely jQuery free.



Share this post: