Working With Forms In SvelteKit
Published Jan 20, 2023
Table of Contents
- Previously
- Setup
- Creating A Fake Database
- Reintroduction To Forms
- Working With Forms Using API Endpoints
- Working With Forms The SvelteKit Way Using Form Actions
- Progressive Form Enhancement
- Customize The Enhance Action To Show A Loading UI
- Form Validation In SvelteKit
- Advanced Enhance Action Customization
Previously
This is part of a SvelteKit series and while each part is meant to be self-contained here are the previous parts in case you want to catch up:
- What is SvelteKit?
- SvelteKit Project Structure
- SvelteKit Routing
- SvelteKit API Endpoints And Loading Data For Pages
Setup
I’m going to initialize a skeleton SvelteKit project using TypeScript.
# install SvelteKit
npm create svelte@latest
# install dependencies
npm i
# run development server
npm run dev I’m going to add a root layout with some global styles using Pico.
<slot />
<style>
@import 'https://unpkg.com/@picocss/pico@latest/css/pico.min.css';
:global(body) {
padding: 2rem;
}
:global(input, button) {
border-radius: 1rem;
}
</style> Creating A Fake Database
I’m going to create a server-only module inside lib that’s going to act as a fake database for the to-do list.
type Todo = {
id: number
text: string
completed: boolean
}
let todos: Todo[] = [
{
id: Date.now(),
text: 'Learn how forms work',
completed: false
}
]
export function getTodos() {
return todos
}
export function addTodo(text: string) {
const todo: Todo = {
id: Date.now(),
text,
completed: false
}
todos.push(todo)
}
export function removeTodo(id: number) {
todos = todos.filter((todo) => todo.id !== id)
}
export function clearTodos() {
todos = []
} Reintroduction To Forms
A form is a way to exchange information between the browser and server — it’s just a container for form controls with some optional attributes to configure how the form behaves.
<form method="GET" action="/login">
<input type="text" name="user" />
<input type="password" name="password" />
<button type="submit">Login</button>
</form> The action attribute defines the location (URL) where the form’s collected data should be sent and the method attribute defines which HTTP method to send the data with. The form attributes are optional. The default form method is GET and if you don’t include action it’s going to make the request to the same page.
The GET method requests a resource from the server and appends the form data at the end of the URL like http://example.com/?user=test&password=1234 since the body is empty and it not great for sending a large amount of data and is not secure.
<form method="POST" action="/login">
<input type="text" name="user" />
<input type="password" name="password" />
<button type="submit">Login</button>
</form> The POST method is similar to GET but it can return a resource depending on the data sent in the request body of the HTTP request — no data is appended to the URL and the data is included in the body instead.
In our example the form is going to send data to the URL using the HTTP POST method. The server is going to receive the data as a list of key: value pairs contained in the HTTP request.
If you submit the form above you can see the parsed form data from user=test&password=1234 under payload in the network tab of the developer tools.
user: test
password: 1234 The name attribute for each form control is important, so the browser knows which name to give each piece of data.
Working With Forms Using API Endpoints
This part is important for understanding how forms work and it’s going to give you more appreciation when you learn how simple SvelteKit makes working with forms in the next section.
I’m going to create a routes/todos folder with some route files.
routes
└── todos
├── +page.server.ts
├── +page.svelte
└── +server.ts First I’m going to get the data for the page which is the currently empty to-do list.
import { getTodos } from '$lib/server/database'
export async function load() {
const todos = getTodos()
return { todos }
} Let’s loop over the to-do items and add the form.
<script lang="ts">
export let data
// todo
async function addTodo(event: Event) {}
// todo
async function removeTodo(event: Event) {}
</script>
<ul>
{#each data.todos as todo}
<li>
<span>{todo.text}</span>
<form on:submit|preventDefault={removeTodo} method="POST">
<input type="hidden" name="id" value={todo.id} />
<button class="delete" type="submit">❌</button>
</form>
</li>
{/each}
</ul>
<form on:submit|preventDefault={addTodo} method="POST">
<input type="text" name="todo" />
<button type="submit">+ Add Todo</button>
</form>
<style>
ul {
padding: 0;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
}
span {
text-transform: capitalize;
}
.delete {
margin: 0;
background: none;
border: none;
}
.error {
color: tomato;
}
</style> The action attribute is optional because we’re going to use the same endpoint for the form.
🐿️ You might be more used to JavaScript instead of using forms and it might feel weird using forms for what might seem easier to use a button for but it’s perfectly normal and you should embrace it (you’re going to see how awesome it is).
I’m going to create a POST and DELETE function inside +server.ts that should get the data from the form and do the appropriate action — I’m going to use a formData object to send success and errors to update the UI for the user.
import { json } from '@sveltejs/kit'
import { addTodo, removeTodo } from '$lib/server/database'
type Data = {
success: boolean
errors: Record<string, string>
}
export async function POST({ request }) {
const formData = await request.formData()
const todo = String(formData.get('todo'))
const data: Data = {
success: false,
errors: {}
}
if (!todo) {
data.errors.todo = 'required'
return json(data, { status: 400 })
}
addTodo(todo)
data.success = true
return json(data)
}
export async function DELETE({ request }) {
const formData = await request.formData()
const todoId = Number(formData.get('id'))
removeTodo(todoId)
return json({ success: true })
} For the todoId I used a hidden input field to get the id for the to-do when looping through the to-do list in the template.
Let’s go back to +page.svelte and create addTodo and removeTodo functions and update the UI based on the response.
<script lang="ts">
import { invalidateAll } from '$app/navigation'
// we need the same type
type Data = {
success: boolean
errors: Record<string, string>
}
export let data
// used in the template
let form: Data
async function addTodo(event: Event) {
const formEl = event.target as HTMLFormElement
const data = new FormData(formEl)
// you can see everything about the form
console.dir(form)
const response = await fetch(formEl.action, {
method: 'POST',
body: data
})
const responseData = await response.json()
// { success: true, errors: {} } object
form = responseData
// reset form
formEl.reset()
// rerun `load` function for the page
await invalidateAll()
}
async function removeTodo(event: Event) {
const formEl = event.target as HTMLFormElement
const data = new FormData(formEl)
// forms only support `GET` and `POST` methods but
// SvelteKit maps this to the `DELETE` function
const response = await fetch(formEl.action, {
method: 'DELETE',
body: data
})
await invalidateAll()
}
</script>
<ul>
{#each data.todos as todo}
<li>
<span>{todo.text}</span>
<form on:submit|preventDefault={removeTodo} method="POST">
<input type="hidden" name="id" value={todo.id} />
<button class="delete" type="submit">❌</button>
</form>
</li>
{/each}
</ul>
<form on:submit|preventDefault={addTodo} method="POST">
<input type="text" name="todo" />
{#if form?.errors?.todo}
<p class="error">This field is required</p>
{/if}
<button type="submit">+ Add Todo</button>
</form>
{#if form?.success}
<p>Added todo! 🥳</p>
{/if} If you add or remove a to-do you can see the fetch request and the payload in the network tab — the request also returns a JSON response that has success and errors which is then used on the front-end to update the UI for the user.
This taught us a lot about about forms but…
This is not a great user and developer experience because:
- It only works with JavaScript
- You had to invent your own wonky validation
- Using
fetchandinvalidateis weird and the framework should do more for you - Repeating types feels bad
This is why you should use SvelteKit form actions and in the next section I’m going to show you why they’re so awesome.
Working With Forms The SvelteKit Way Using Form Actions
Inside a standalone +server.ts endpoint you can use functions that map to HTTP verbs like GET or POST but form actions take this idea a step further and you can define methods that map to an action inside a +page.server.ts file.
export const actions = {
default: async (event) => {
// ...
}
} If you don’t define an action attribute you can use the default method but for multiple actions it’s replaced by named actions like action="?/addTodo".
<form method="POST">
<!-- ... -->
</form> Actions always use POST requests because GET requests shouldn’t have side-effects and they can be invoked from other pages.
<form method="POST" action="/todos?/addTodo">
<!-- ... -->
</form> If you need to POST the same form data to a different action you can also use the formaction attribute.
<form method="POST" action="?/addTodo">
<!-- ... -->
<button>+ Add todo</button>
<button formaction="?/clearTodos">Clear</button>
</form> I’m going to convert the previous example to use form actions. First I’m going to delete the +server.ts file since you don’t need it, so you should have this folder structure.
routes
└── todos
├── +page.server.ts
└── +page.svelte Fetching data for the page using the load function is the same but now you can also export an actions object from +page.server.ts.
Let’s start with creating actions for adding, removing and clear to-do items.
import { fail } from '@sveltejs/kit'
import { addTodo, clearTodos, getTodos, removeTodo } from '$lib/server/database'
export async function load() {
const todos = getTodos()
return { todos }
}
export const actions = {
addTodo: async ({ request }) => {
const formData = await request.formData()
const todo = String(formData.get('todo'))
if (!todo) {
return fail(400, { todo, missing: true })
}
addTodo(todo)
return { success: true }
},
removeTodo: async ({ request }) => {
const formData = await request.formData()
const todoId = Number(formData.get('id'))
removeTodo(todoId)
return { success: true }
},
clearTodos: () => {
clearTodos()
}
} You can already see how much less code you have to write and SvelteKit handles returning validation errors using the fail function that can have the data you can use for the input value to get around the form reset.
The action responds with data that’s available through the form property and $page.form store and reruns the load function for the page.
<script lang="ts">
export let data
export let form
</script>
<ul>
{#each data.todos as todo}
<li>
<span>{todo.text}</span>
<form method="POST" action="?/removeTodo">
<input type="hidden" name="id" value={todo.id} />
<button class="delete" type="submit">❌</button>
</form>
</li>
{/each}
</ul>
<form method="POST" action="?/addTodo">
<input type="text" name="todo" value={form?.todo ?? ''} />
{#if form?.missing}
<p class="error">This field is required</p>
{/if}
<button type="submit">+ Add Todo</button>
<button formaction="?/clearTodos" class="secondary" type="submit">
Clear
</button>
</form>
{#if form?.success}
<p>Added todo! 🥳</p>
{/if}
<style>
ul {
padding: 0;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
}
span {
text-transform: capitalize;
}
.delete {
margin: 0;
background: none;
border: none;
}
.error {
color: tomato;
}
</style> 🐿️ The SvelteKit language server is doing a lot of work under the hood and sometimes you might have a problem with the generated types in which case you can press Ctrl + P and type
> Svelte: Restart Language Serverbut if that doesn’t work restart the development server or your editor as a last resort.
If you look at the payload in the network tab when adding or removing a to-do you’re going to see the parsed query string and form data.
Actions might look like magic but they’re just a URL that invokes a function. If you take / out of ?/addTodo you end up ?addTodo which is just a search param but / lets SvelteKit know you meant to use an action.
Progressive Form Enhancement
Try to add or remove a to-do item from the list and pay attention to the icon in the browser tab and look at your network tab.
You’re going to notice each time you add or remove a to-do the page reloads. This is the default form behavior once you submit it — this means the form works without JavaScript!
JavaScript can fail to load for many reasons but I’m not advocating that every site should work without JavaScript and should instead work before JavaScript is loaded on the page. This approach to development makes your app more resilient and you can use JavaScript once it’s available on the page to improve the user experience using progressive enhancement as intended.
Remember the first example how we had to do everything by hand? SvelteKit does that for you and wraps everything in a neat use:enhance Svelte action (unrelated to form actions) that does the same thing.
<script lang="ts">
import { enhance } from '$app/forms'
export let data
export let form
</script>
<ul>
{#each data.todos as todo}
<li>
<span>{todo.text}</span>
<form method="POST" action="?/removeTodo" use:enhance>
<input type="hidden" name="id" value={todo.id} />
<button class="delete" type="submit">❌</button>
</form>
</li>
{/each}
</ul>
<form method="POST" action="?/addTodo" use:enhance>
<input type="text" name="todo" value={form?.todo ?? ''} />
{#if form?.missing}
<p class="error">This field is required</p>
{/if}
<button type="submit">+ Add Todo</button>
<button formaction="?/clearTodos" class="secondary" type="submit">
Clear
</button>
</form>
{#if form?.success}
<p>Added todo! 🥳</p>
{/if}
<!-- ... --> It only takes one line of code if you count the import and everything works the same but instead of reloading the page it’s going to prevent the default form behavior and use JavaScript — if you look at the network tab you should see the fetch request.
When you submit the form the use:enhance action is going to:
- Update the
form,$page.formand$page.statusproperty - Reset the
<form>element and rerun theloadfunction for the page by usinginvalidateAll - Use
gotofor a redirect response
If you want to learn how it works you can look at form.js inside the SvelteKit repo which looks very familiar to what I showed you earlier — everything leads to this point because I want to give you perspective and understanding how the things you use work and why.
Customize The Enhance Action To Show A Loading UI
You can customize the behavior of use:enhance by providing a submit function that runs before the form is submitted and can return a callback that has access to the result.
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms'
export let data
export let form
const addTodo: SubmitFunction = (input) => {
// do something before the form submits
console.log(input)
return async (options) => {
// do something after the form submits
console.log(options)
}
}
</script>
<!-- ... --> The input contains:
actionfor the URL detailscancelto prevent the submissioncontrolleris theAbortControlleryou can use to cancel a requestdatais theFormDataobjectformis the<form>element
The options contains:
actionfor the URL detailsformis the<form>elementresultwhich is anActionResultobjectupdatefunction that runs the regular logic, otherwise you would have to do it yourself
You can’t always rely on a fast response from the server in which case the user might leave your site because it looks broken.
I defined a sleep function to simulate a slow response which I’m going to use inside the addTodo action.
import { fail } from '@sveltejs/kit'
import { addTodo, getTodos, removeTodo } from '$lib/server/database'
export async function load() {
const todos = getTodos()
return { todos }
}
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export const actions = {
addTodo: async ({ request }) => {
const formData = await request.formData()
const todo = String(formData.get('todo'))
if (!todo) {
return fail(400, { todo, missing: true })
}
await sleep(2000)
addTodo(todo)
return { success: true }
},
removeTodo: async ({ request }) => {
const formData = await request.formData()
const todoId = Number(formData.get('id'))
removeTodo(todoId)
},
clearTodos: () => {
clearTodos()
}
} Using what we know I’m going to set loading = true before the form submits and when it’s done we can set loading = false.
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms'
export let data
export let form
let loading = false
const addTodo: SubmitFunction = () => {
loading = true
return async ({ update }) => {
loading = false
await update()
}
}
</script>
<ul>
{#each data.todos as todo}
<li>
<span>{todo.text}</span>
<form method="POST" action="?/removeTodo" use:enhance>
<input type="hidden" name="id" value={todo.id} />
<button class="delete" type="submit">❌</button>
</form>
</li>
{/each}
</ul>
<form method="POST" action="?/addTodo" use:enhance={addTodo}>
<input type="text" name="todo" value={form?.todo ?? ''} />
{#if form?.missing}
<p class="error">This field is required</p>
{/if}
<button aria-busy={loading} class:secondary={loading} type="submit">
{#if !loading}+ Add todo{/if}
</button>
<button formaction="?/clearTodos" class="secondary" type="submit">
Clear
</button>
</form>
{#if form?.success}
<p>Added todo! 🥳</p>
{/if}
<!-- ... --> Awesome! 😄
You can show a toast notification when a user adds and removes a to-do item but I’m going to leave that as an exercise to the reader.
Form Validation In SvelteKit
I’m going to show you how you can validate a form using the popular schema validation library Zod (hail Zod!) and show you a couple of tricks. 🛹
I’m going to create a routes/login route using the first example but I’m going to add the required attribute for the input fields.
<script lang="ts">
import { enhance } from '$app/forms'
</script>
<form method="POST" use:enhance>
<input type="text" name="user" required />
<input type="password" name="password" required />
<button type="submit">Login</button>
</form> The browser has some built-in validation but the problem is that anyone can be hackerman and edit client-side code and submit whatever they want — this serves more as a nicer user experience than a guard against malicious actors and that is why you should always do server-side validation.
The next part is going to make more sense if I describe the shape of the object I want for the form prop on the page.
{
"data": {
"name": "",
"email": ""
},
"errors": {
"user": "required",
"password": "required"
}
} As you can see there is no rule what you should return and now you can do form?.data?.value to return the input value and form?.errors?.value to provide feedback.
Let’s add the validation inside the default form action.
import { fail, redirect } from '@sveltejs/kit'
export const actions = {
default: async ({ request }) => {
// get the form data
const formData = await request.formData()
// get the input values
const user = String(formData.get('user'))
const password = String(formData.get('password'))
// gather errors
const errors: Record<string, unknown> = {}
if (!user || typeof user !== 'string') {
errors.user = 'required'
}
if (!password || typeof password !== 'string') {
errors.password = 'required'
}
// in case of an error return the data and errors
if (Object.keys(errors).length > 0) {
const data = {
data: Object.fromEntries(formData),
errors
}
return fail(400, data)
}
// redirect the user
redirect(303, '/todos')
}
} This can now we be used in the template.
<script lang="ts">
import { enhance } from '$app/forms'
export let form
</script>
<form method="POST" use:enhance>
<input type="text" name="user" value={form?.data?.user ?? ''} />
{#if form?.errors?.user}
<p class="error">Name is required</p>
{/if}
<input type="password" name="password" value={form?.data?.password ?? ''} />
{#if form?.errors?.password}
<p class="error">Password is required</p>
{/if}
<button type="submit">Login</button>
</form>
<style>
.error {
color: tomato;
}
</style> This is great but having to get every input value and using conditional statements even for a basic form feels tedious.
This is a perfect use case for Zod that’s great at validating an unknown input. First you create a schema of the fields you expect and parse it using Zod which is going to make sure the values are correct or otherwise throw an error and you can abstract the validation logic inside a function.
🐿️
zod-form-datais a great example of using web standards because it was made for Remix but works for SvelteKit becauseFormDatais a web standard.
I’m going to install zod and the zod-form-data package that makes it simple to parse FormData and URLSearchParams.
npm i z zod-form-data I’m going to change the previous example to use Zod for validation.
import { fail, redirect } from '@sveltejs/kit'
import { zfd } from 'zod-form-data'
export const actions = {
default: async ({ request }) => {
// get the form data
const formData = await request.formData()
// define the validation schema
const loginSchema = zfd.formData({
user: zfd.text(),
password: zfd.text()
})
// parse the validation schema
const result = loginSchema.safeParse(formData)
// in case of an error return the data and errors
if (!result.success) {
const data = {
data: Object.fromEntries(formData),
errors: result.error.flatten().fieldErrors
}
return fail(400, data)
}
// redirect the user
redirect(303, '/todos')
}
} This isn’t the only way to do validation with Zod and you can read more about error handling in Zod from the docs but form validation deserves its own post.
You can abstract the validation logic inside a validation function that you can use alongside other utils and have a separate file for your validation schemas.
export const validate(formData: FormData, schema: Schema) {
// ...
} You can use it like this inside your project.
const formData = await request.formData()
const { data, errors } = validate(formData) Advanced Enhance Action Customization
You might want to reproduce a part of the default use:enhance behavior in which case you can use applyAction.
I mentioned how actions can be invoked from other pages and in this example I want to reuse the /login endpoint in routes/+page.svelte and I copied over everything from routes/login/+page.svelte.
If you submit the form it kinda works but you don’t get the validation data back because form is never updated.
<script lang="ts">
import { enhance } from '$app/forms'
export let form
</script>
<form method="POST" action="/login" use:enhance>
<input type="text" name="name" value={form?.data?.name ?? ''} />
{#if form?.errors?.name}
<p class="error">Name is required</p>
{/if}
<input type="email" name="email" value={form?.data.email ?? ''} />
{#if form?.errors?.email}
<p class="error">Email is required</p>
{/if}
<button type="submit">Login</button>
</form>
<style>
.error {
color: tomato;
}
</style> Let’s try using the update method and see if it works.
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms'
export let form
const login: SubmitFunction = () => {
return async ({ update }) => {
await update()
}
}
</script>
<form method="POST" action="/login" use:enhance={login}>
<!-- ... -->
</form> That won’t work because update can’t update form and $form.page from anywhere else but you can use applyAction and pass it result to customize the use:enhance behavior further.
<script lang="ts">
import { applyAction, enhance, type SubmitFunction } from '$app/forms'
export let form
const login: SubmitFunction = () => {
return async ({ result }) => {
await applyAction(result)
}
}
</script>
<form method="POST" action="/login" use:enhance={login}>
<!-- ... -->
</form> If you submit the form form and $page.form is going to get updated.
As I mentioned previously result is an ActionResult object but I haven’t talk about its properties which are:
datareturned from the actionstatuscodetypewhich can besucces,failure,redirect,error
Using applyAction(result) is going to do different things based on result.type:
successandfailureis going to updateform,$page.formand$page.statusregardless where you’re submitting fromredirectis going to invokegoto(result.location)erroris going to render the nearest+error.sveltepage
Here’s an example of how that looks in action. 🥁
const submit: SubmitFunction = ({ form, data, action, cancel }) => {
// do something before the form submits
return async ({ result }) => {
// do something after the form submits
if (result.type === 'success') {
// do something...
// use the default behavior for this result type
await applyAction(result)
}
if (result.type === 'failure') { /* ... */ }
if (result.type === 'redirect') { /* ... */ }
if (result.type === 'error') { /* ... */ }
}
} This way you can customize the behavior of use:enhance and dip in and out of the regular behavior when you need it.
That’s everything you should know to feel confident working with forms in SvelteKit and I hope you learned how forms work but more importantly how to make your app more resilient.
In the next part you’re going to learn about using advanced layouts in SvelteKit.