Progressive Enhancement with use:enhance
Making form actions feel instant with JavaScript, while keeping the no-JS version working underneath.
2 min read
The forms from the previous lesson already work end-to-end with a plain HTML POST — but a full page reload on every submission feels dated when JavaScript is available. use:enhance upgrades a form to submit via fetch in the background, without changing a single line of your server-side action.
Adding it to a form
<script>
import { enhance } from '$app/forms';
let { form } = $props();
</script>
<form method="POST" use:enhance>
<input name="email" value={form?.email ?? ''} />
<button>Log in</button>
</form>That's the whole upgrade. use:enhance intercepts the submit event, sends the form data via fetch instead of a full navigation, and then applies the result the same way a full page load would have: updating form, re-running load for the page, and following any redirect(). If JavaScript fails to load, is disabled, or the import errors, the form still works exactly as it did in the last lesson — that's what "progressive enhancement" means here: JavaScript is an enhancement, not a requirement.
Why this matters more than it seems
Compare this to the common pattern of an onsubmit handler that calls event.preventDefault() and does everything manually:
<!-- The harder, more fragile way -->
<script>
async function handleSubmit(e) {
e.preventDefault();
const res = await fetch('/login', { method: 'POST', body: new FormData(e.target) });
// ...now manually handle the response, update local state,
// handle redirects, handle errors, keep it in sync with load...
}
</script>
<form onsubmit={handleSubmit}>...</form>This version breaks completely if JavaScript fails, duplicates logic that already exists in your server action's response handling, and requires you to reimplement redirect and reload behavior by hand. use:enhance's default behavior already does the right thing for the overwhelming majority of forms — you opt into custom behavior only when you actually need it.
Customizing the behavior
use:enhance accepts a function for the cases where the default isn't enough — showing a loading spinner, or reacting differently to success versus failure:
<script>
import { enhance } from '$app/forms';
let submitting = $state(false);
</script>
<form
method="POST"
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
};
}}
>
<input name="email" />
<button disabled={submitting}>
{submitting ? 'Logging in…' : 'Log in'}
</button>
</form>The outer function runs immediately on submit — a good place to set a loading flag. It returns an inner callback that runs once the server responds, receiving an update() function that applies the default behavior (updating form, re-running load) on your terms, so you can wrap it with your own logic instead of losing it entirely.
The pattern to internalize
Build every form to work as a plain POST to a +page.server.js action first. Only after that works do you sprinkle use:enhance on top for a nicer experience. Building it the other way around — JavaScript-first, with the server treated as an afterthought — is how forms end up breaking for users on flaky connections, older devices, or with JavaScript disabled entirely.