You’ve got a simple HTML form. It works, but page reload on submit feels unnecessary, so you decide to handle it with AJAX.
The workflow seems pretty straightforward. Add a submit handler, call fetch, send the form data, and update the UI when the response comes back. Easy enough, right? Well…
You need to prevent the default submit behavior, build a FormData object, remember the right headers, and decide what to do with the response. If the request succeeds, you show a success message. If it fails, you figure out how to map those errors back to individual fields. While the request is in flight, you probably want to disable the button or show some kind of loading state. Then you undo all of that if something goes wrong.
None of this is particularly complex, but it’s more work to do when you just wanted to avoid a redirect.
You could use fetch, switch to Axios, or lean on jQuery if it’s already in your stack. Those choices change the syntax a bit, but they don’t really change the job. You’re still writing the same glue code to connect the form, the network request, and the UI.
The usual way
Here’s what a typical “just use fetch” form handler ends up looking like in practice:
<form id="contact-form">
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
<p id="status"></p>
</form>
<script>
const form = document.getElementById('contact-form');
const status = document.getElementById('status');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const button = form.querySelector('button');
button.disabled = true;
status.textContent = 'Sending...';
try {
const res = await fetch('<https://formspree.io/f/your-form-id>', {
method: 'POST',
body: formData,
headers: {
Accept: 'application/json',
},
});
const data = await res.json();
if (res.ok) {
status.textContent = 'Thanks! Your message has been sent.';
form.reset();
} else {
if (data.errors) {
status.textContent = data.errors.map(e => e.message).join(', ');
} else {
status.textContent = 'Something went wrong.';
}
}
} catch (err) {
status.textContent = 'Network error. Please try again.';
} finally {
button.disabled = false;
}
});
</script>
This works. It’s reliable. You can copy it into almost any project and get what you need.
But look at what’s happening around the actual request.
You have to remember to set the Accept header so you get JSON back. You’re manually parsing the response and branching based on res.ok. Validation errors come back in a format you need to transform before showing anything useful to the user. The loading state is something you manage yourself, and you have to make sure it always resets, even on failure.
And this is still a relatively clean version.
In real projects, this usually grows. You start adding field-level error messages, maybe some custom UI for success states, maybe analytics hooks. Each form ends up with its own slightly different version of this logic.
“Okay, what about Axios / jQuery?”
If you’ve done this a few times, you’ve probably tried swapping out fetch for something a bit more ergonomic.
Here’s the same idea using Axios:
import axios from 'axios';
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
try {
const res = await axios.post(
'<https://formspree.io/f/your-form-id>',
formData,
{ headers: { Accept: 'application/json' } }
);
status.textContent = 'Thanks! Your message has been sent.';
form.reset();
} catch (err) {
if (err.response?.data?.errors) {
status.textContent = err.response.data.errors
.map(e => e.message)
.join(', ');
} else {
status.textContent = 'Something went wrong.';
}
}
});
Or if you’re in a jQuery codebase, it might look more like this:
$('#contact-form').on('submit', function (e) {
e.preventDefault();
const $form = $(this);
$.ajax({
url: '<https://formspree.io/f/your-form-id>',
method: 'POST',
data: new FormData(this),
processData: false,
contentType: false,
headers: {
Accept: 'application/json',
},
success: function () {
$('#status').text('Thanks! Your message has been sent.');
$form[0].reset();
},
error: function (xhr) {
const data = xhr.responseJSON;
if (data?.errors) {
$('#status').text(
data.errors.map(e => e.message).join(', ')
);
} else {
$('#status').text('Something went wrong.');
}
},
});
});
Axios gives you nicer defaults, jQuery hides some of the lower-level details, but the structure is still the same. You’re still making the request yourself. You’re still figuring out how to handle success vs failure. You’re still responsible for turning a response into something your UI can actually show.
Switching libraries can make this feel a little cleaner, but it doesn’t really remove the work.
And at this point, it’s easy to get stuck comparing tools. Maybe fetch feels too barebones, Axios feels nicer, and jQuery is familiar. But swapping between them doesn’t really change what you’re dealing with. The request itself is only a small part of the problem. The bigger part is keeping track of everything that’s happening around the request.
What if you just… didn’t write that part?
Instead of starting from an event listener and building everything up yourself, you can flip the approach a bit. Let the form behave like a normal HTML form, and layer in the behavior you actually care about. Submission, loading state, error handling, and success messages. All the things you’ve been wiring up manually, just handled for you.
That usually means moving away from imperative code that says “when this happens, do these five things” and toward something more declarative. You describe how the form should behave, and a small layer takes care of the mechanics.
That’s the idea we kept coming back to.
Enter @formspree/ajax
That “form layer” you keep rebuilding is exactly what @formspree/ajax is trying to take off your plate.
It sits between your HTML form and the network request, and handles the parts you’ve been writing manually. You initialize it once, point it at your form, and it takes care of submission, response handling, and updating the UI.
It’s not a framework, and it doesn’t change how you structure your page. You still write a normal <form>, use your own inputs, and control the markup however you like. The difference is that you’re no longer responsible for wiring up the whole request lifecycle every time.
Because it’s built around Formspree, it also understands the shape of the responses coming back. That’s where a lot of the friction usually comes from with generic AJAX code. You’re not just sending a request, you’re interpreting a response format and mapping it to your UI. Here, that mapping is already handled.
Before vs after formspree-ajax
At a glance, both approaches are doing the same thing. Submit a form, handle the response, update the UI. The difference shows up in how much you have to wire yourself.
Traditional AJAX
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const button = form.querySelector('button');
button.disabled = true;
try {
const res = await fetch('<https://formspree.io/f/your-form-id>', {
method: 'POST',
body: formData,
headers: { Accept: 'application/json' },
});
const data = await res.json();
if (res.ok) {
form.reset();
status.textContent = 'Thanks!';
} else {
status.textContent = data.errors
? data.errors.map(e => e.message).join(', ')
: 'Something went wrong.';
}
} catch {
status.textContent = 'Network error.';
} finally {
button.disabled = false;
}
});
You’re handling submission, loading state, response parsing, and UI updates yourself. Every piece is explicit.
@formspree/ajax
<form id="contact-form">
<input type="email" name="email" data-fs-field />
<p data-fs-error="email"></p>
<textarea name="message" data-fs-field></textarea>
<p data-fs-error="message"></p>
<button type="submit" data-fs-submit-btn>Send</button>
<p data-fs-error></p>
<p data-fs-success></p>
</form>
<script>
import { initForm } from '@formspree/ajax';
initForm({
formElement: '#contact-form',
formId: 'your-form-id',
});
</script>
No submit handler. No manual request. No parsing logic. You describe the structure, and the library takes care of the rest. Less code to maintain, and fewer places where things can drift between projects.
What it quietly handles for you
Most of the value here shows up in the parts you don’t have to think about anymore.
When the form submits, the library takes over the full flow. It sends the request to Formspree, disables the submit button while it’s in flight, and restores it afterward. You don’t have to manage that state manually or worry about edge cases where it doesn’t reset properly.
Validation errors are handled in a way that maps directly to your markup. If you’ve added data-fs-error="email" (or any field name), the library places the right message there automatically. Form-level errors can go into a generic data-fs-error element, so you’re not parsing and distributing messages yourself.
On success, it can show a message in a data-fs-success element or fall back to a default behavior. The form can also reset itself after submission, so you’re not manually clearing inputs.
There are smaller details that add up too. Invalid fields get aria-invalid="true" set correctly, which helps with accessibility. Basic styling is applied by default, so things don’t look broken out of the box, but you can override it if needed.
And if you do need more control
The default behavior covers most use cases, but you’re not locked into it.
If you want to customize how things render, there are lifecycle hooks you can plug into. You can override how field errors show up, how success messages are displayed, or what happens when the form is enabled or disabled. You can also pass extra data along with the submission, which is useful when the form itself doesn’t capture everything you want to send.
Where this really helps
This kind of setup pays off most in places where you’re working close to the DOM and don’t have a framework doing this for you.
Landing pages are a good example. You want a fast, simple form with no page reload, but you don’t want to ship extra JavaScript just to manage submission state and error handling.
It also fits nicely into SaaS dashboards where you might have a few small forms scattered around. Settings pages, feedback forms, and invite flows. You get consistent behavior without having to reimplement the same logic in each place.
For internal tools, it’s even more obvious. You often want something quick that works reliably without investing time in building out form handling infrastructure. And for static sites, it removes the need to stitch together a backend just to make forms feel modern. You get AJAX behavior and a clean user experience without adding much complexity.
Try it in 2 minutes
You don’t need much to get started. Add @formspree/ajax, point it at your form, and mark up where errors and success messages should appear. From there, it handles the submission flow the same way every time, without you wiring it up again.
If you want to try it out, the quickest way is to follow the official docs, which walks through setup, attributes, and customization options. Or, you could take a look at the NPM package itself. Happy coding!
Top comments (0)