AJAX Form Submit: The Lazy Way
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeI'm feeling pretty awesome about all our new skills. So let's turn to a new goal and some new challenges. Below the RepLog table, we have a very traditional form. When we fill it out, it submits to the server: no AJAX, no fanciness.
And no fun! Let's update this to submit via AJAX. Of course, that comes with a few other challenges, like needing to dynamically add a new row to the table afterwards.
AJAXify the Form
In general, there are two ways to AJAXify this form submit. First, there's the simple, traditional, easy, and lazy way! That is, we submit the form via AJAX and the server returns HTML. For example, if we forget to select an item to lift, the AJAX would return the form HTML with the error in it so we can render it on the page. Or, if it's successful, it would probably return the new <tr>
HTML so we can put it into the table. This is easier... because you don't need to do all that much in JavaScript. But, this approach is also a bit outdated.
The second approach, the more modern approach, is to actually treat your backend like an API. This means that we'll only send JSON back and forth. But this also means that we'll need to do more work in JavaScript! Like, we need to actually build the new <tr>
HTML row by hand from the JSON data!
Obviously, that is where we need to get to! But we'll start with the old-school way first, and then refactor to the modern approach as we learn more and more cool stuff.
Making $wrapper Wrap Everything
In both situations, step one is the same: we need attach a listener on submit of the form. Head over to our template:
// ... lines 1 - 2 | |
{% block body %} | |
<div class="row"> | |
<div class="col-md-7"> | |
// ... lines 6 - 52 | |
{{ include('lift/_form.html.twig') }} | |
</div> | |
// ... lines 55 - 61 | |
</div> | |
{% endblock %} | |
// ... lines 64 - 77 |
The form itself lives in another template that's included here: _form.html.twig
inside app/Resources/views/lift
:
{{ form_start(form, { | |
'attr': { | |
'class': 'form-inline', | |
'novalidate': 'novalidate' | |
} | |
}) }} | |
{{ form_errors(form) }} | |
{{ form_row(form.item, { | |
'label': 'What did you lift?', | |
'label_attr': {'class': 'sr-only'} | |
}) }} | |
{{ form_row(form.reps, { | |
'label': 'How many times?', | |
'label_attr': {'class': 'sr-only'}, | |
'attr': {'placeholder': 'How many times?'} | |
}) }} | |
<button type="submit" class="btn btn-primary">I Lifted it!</button> | |
{{ form_end(form) }} |
This is a Symfony form, but all this fanciness ultimately renders a good, old-fashioned form
tag. Give the form another class: js-new-rep-log-form
:
{{ form_start(form, { | |
'attr': { | |
'class': 'form-inline js-new-rep-log-form', | |
'novalidate': 'novalidate' | |
} | |
}) }} | |
// ... lines 7 - 20 | |
{{ form_end(form) }} |
Copy that and head into RepLogApp
so we can attach a new listener. But wait... there is one problem: the $wrapper
is actually the <table>
element:
// ... lines 1 - 2 | |
{% block body %} | |
<div class="row"> | |
<div class="col-md-7"> | |
// ... lines 6 - 12 | |
<table class="table table-striped js-rep-log-table"> | |
// ... lines 14 - 50 | |
</table> | |
{{ include('lift/_form.html.twig') }} | |
</div> | |
// ... lines 55 - 61 | |
</div> | |
{% endblock %} | |
// ... lines 64 - 77 |
And the form does not live inside of the <table>
!
When you create little JavaScript applications like RepLogApp
, you want the wrapper to be an element that goes around everything you need to manipulate.
Ok, no problem: let's move the js-rep-log-table
class from the table itself to the div
that surrounds everything:
// ... lines 1 - 2 | |
{% block body %} | |
<div class="row"> | |
<div class="col-md-7 js-rep-log-table"> | |
// ... lines 6 - 12 | |
<table class="table table-striped"> | |
// ... lines 14 - 50 | |
</table> | |
// ... lines 52 - 53 | |
</div> | |
// ... lines 55 - 61 | |
</div> | |
{% endblock %} | |
// ... lines 64 - 77 |
Down below, I don't need to change anything here, but let's rename $table
to $wrapper
for clarity:
// ... lines 1 - 64 | |
{% block javascripts %} | |
// ... lines 66 - 69 | |
<script> | |
$(document).ready(function() { | |
var $wrapper = $('.js-rep-log-table'); | |
var repLogApp = new RepLogApp($wrapper); | |
}); | |
</script> | |
{% endblock %} |
The Form Submit Listener
Now adding our listener is simple: this.$wrapper.find()
and look for .js-new-rep-log-form
. Then, .on('submit')
, have this call a new method: this.handleNewFormSubmit
. And don't forget the all-important .bind(this)
:
// ... lines 1 - 2 | |
(function(window, $) { | |
window.RepLogApp = function ($wrapper) { | |
// ... lines 5 - 15 | |
this.$wrapper.find('.js-new-rep-log-form').on( | |
'submit', | |
this.handleNewFormSubmit.bind(this) | |
); | |
}; | |
// ... lines 21 - 81 | |
})(window, jQuery); |
Down below, add that function - handleNewFormSubmit
- and give it the event argument. This time, calling e.preventDefault()
will prevent the form from actually submitting, which is good. For now, just console.log('submitting')
:
// ... lines 1 - 2 | |
(function(window, $) { | |
// ... lines 4 - 21 | |
$.extend(window.RepLogApp.prototype, { | |
// ... lines 23 - 58 | |
handleNewFormSubmit: function(e) { | |
e.preventDefault(); | |
console.log('submitting!'); | |
} | |
}); | |
// ... lines 64 - 81 | |
})(window, jQuery); |
Ok, test time! Head back, refresh, and try the form. Yes! We get the log, but the form doesn't submit.
Adding AJAX
Turning this form into an AJAX call will be really easy... because we already know that this form works if we submit it in the traditional way. So let's just literally send that exact same request, but via AJAX.
First, get the form with $form = $(e.currentTarget)
:
// ... lines 1 - 2 | |
(function(window, $) { | |
// ... lines 4 - 21 | |
$.extend(window.RepLogApp.prototype, { | |
// ... lines 23 - 58 | |
handleNewFormSubmit: function(e) { | |
e.preventDefault(); | |
var $form = $(e.currentTarget); | |
// ... lines 63 - 67 | |
} | |
}); | |
// ... lines 70 - 87 | |
})(window, jQuery); |
Next, add $.ajax()
, set the url
to $form.attr('action')
and the method
to POST
. For the data
, use $form.serialize()
:
// ... lines 1 - 2 | |
(function(window, $) { | |
// ... lines 4 - 21 | |
$.extend(window.RepLogApp.prototype, { | |
// ... lines 23 - 58 | |
handleNewFormSubmit: function(e) { | |
e.preventDefault(); | |
var $form = $(e.currentTarget); | |
$.ajax({ | |
url: $form.attr('action'), | |
method: 'POST', | |
data: $form.serialize() | |
}); | |
} | |
}); | |
// ... lines 70 - 87 | |
})(window, jQuery); |
That's a really lazy way to get all the values for all the fields in the form and put them in the exact format that the server is accustomed to seeing for a form submit.
That's already enough to work! Submit that form! Yea, you can see the AJAX calls in the console and web debug toolbar. Of course, we don't see any new rows until we manually refresh the page...
So that's where the real work starts: showing the validation errors on the form on error and dynamically inserting a new row on success. Let's do it!
fast-forward 5 years. stimulus and hotwire are a thing. boy, what a spiral. although worth to mention pjax along the way