Learn SvelteKit - Part 4: Form Actions

Matías Hernández
author
Matías Hernández
cover image featuring orange glowing ribbons spiraling around each other.

Learn SvelteKit

You are currently reading a the last installment of the Learn SvelteKit blog series. This article explains form handling in SvelteKit, including form actions setup, progressive enhancement, and SQLite integration. This series as a whole teaches you the core SvelteKit API you need to be productive building projects.


Form Actions

The next concept to review in this beginners guide are forms.

Forms are an essential part of every web application, they are the “native” way to capture user information and perform data mutation.

The form element is the way to denote that the page will have a list of form controls. This element can use some attributes to describe the form behavior.

  • action This attribute defines the location where the form will send the collected data.
  • method Is the way to define what HTTP method will be used to send the data to the action location.

SvelteKit uses this as the default way to handle communication between front-end and back-end. SvelteKit defines the concept as Form Actions

These are a set of functions defines inside a server side code like +page.server.js that declares how the data will be captured and what to do with the data. The function should be placed as part of an object named actions that needs to be exported:

// src/routes/+page.server.js
// this is a convenience function to return errors
import { fail } from '@sveltejs/kit';
// A well known/reserved export that
// describe how a form will be handled
/** @type {import('./$types').Actions} */
export const actions = {
default: async ( { request }) => {
// read the form data sent by the browser
const formData = await request.formData()
const title = formData.get('title');
const description = formData.get('description');
const priority = formData.get('priority')
// Perform some backend code/logic action
const item = await saveTodoItem(title, description, priority)
if (!item) {
return fail(401, { message: 'Invalid data' })
}
return { success: true };
}
}

The above is an example of a form action that:

  • retrieves the form data sent using formData()
  • Gets the fields from the formData Map. The fields name are the same used in the name attribute in the html form
  • Execute a back-end operation like saving the data into a database
  • Triggers an error if the user was not found, marking the error as 401.
  • return success if all went good

But why are we doing it this way? Well, the other option is more verbose that will be to create an API endpoint that is triggered when it receives a POST request, this can be done by creating a +server.ts file at the route and exporting a function named POST.

The actions object exported from the server side code can hold many actions to handle many forms. To do that you’ll have to define multiple keys inside the object and remove the default action. If you name your actions, for example addTodo instead of default the action attribute of the form will have to be replaced by adding the name of the action to perform: action="?/addTodo

Form Actions is a simpler way to handle a form directly in the server side code, the communication between this back-end code and the corresponding page/svelte component is handled by SvelteKit, all you need to do is to create a form in the +page.svelte file like the following

<script>
/** @type {import('./$types').PageData} */
export let data;
const options = [1,2,3,4,5]
</script>
<h1>{data.title}</h1>
<div>{@html data.content}</div>
<form method="POST" action="/">
<input type="text" name="title" placeholder="TODO title" />
<input type="text" name="description" />
<select name="priority">
{#each options as option}
<option value={option}>{option}</option>
{/each}
</select>
<button type="submit">Create Item</button>
</form>

That form will automatically (and natively) communicate with the default form action exported from the sibling +page.server.ts in there you have access to all the required server side code to perform any operation you would need.

Form Actions feels magical but they’re just a URL that invokes a function.

But, the default behavior of forms is to send the data and refresh the page, that doesn’t feel right nowadays, that is why SvelteKit offers a way to perform progressive enhancement

Ready to pick up the pace?

Enter your email and receive regular updates on our latest articles and courses

What do you want to take to the next level?

Progressive enhancement

The default behavior of form and form actions is to work without requiring JavaScript, just simple HTML form that send data over the wire.

This makes your application more resilient to the many things that can go wrong on different devices. But, if all goes well, the experience of using forms without JavaScript is not desirable, how can you improve it?

SvelteKit comes with a svelte action named enhance that does exactly what the name implies.

It enhances the user experience of the form, by just adding the action into the form tag you have a fully working form that will not refresh the page when you send the data, but if for some reason the JavaScript is not loaded yet, it will still work.

<script>
import { enhance } from '$app/forms'
/** @type {import('./$types').PageData} */
export let data;
const options = [1,2,3,4,5]
</script>
<h1>{data.title}</h1>
<div>{@html data.content}</div>
<form method="POST" action="/" use:enhance>
<input type="text" name="title" placeholder="TODO title" />
<input type="text" name="description" />
<select name="priority">
{#each options as option}
<option value={option}>{option}</option>
{/each}
</select>
<button type="submit">Create Item</button>
</form>

When the form is submitted the use:enhance action takes over performing the following

  • Update the form property and $page.form and $page.status stores.
  • Reset the <form> element and rerun the load function associated to the page

The default behavior of the action can also be customize to your liking. The action accepts a callback function that will run before the form is submitted.

<script>
// import the enhance action
import { enhance } from '$app/forms'
// This is the page data returned by the load function
/** @type {import('./$types').PageData} */
export let data
// This is the form property that gives you access to the form status
export let form
// This is the function that will run before the form is submitted
/** @type {import('$app/forms').SubmitFunction} */
function addTodo(input){
// do something before the form submits
console.log(input)
return async (options) => {
// do something after the form submits
console.log(options)
}
}
</script>
<!-- ... -->

This function returns an asynchronous function meant to run after the form is submitted. Through this function input argument you have access to:

  • action to get access to the URL details to which the form is posted
  • cancel a method to call to prevent form submission
  • formElement gives you direct access to the <form> element
  • formData references the FormData object as the data that is about to be submitted
  • submitter is the HTMLElement that caused the form to be submitted

The arguments for the return function are

  • action again, it gives you access to the URL details
  • form gives you access to the <form> element
  • result an ActionResult object that will give you access to the response data
  • update is a function that runs the regular logic of the form, if you don’t call it you’ll have to execute the logic by yourself.

The first thing that most developer will want to add to any form is a loading state, how can you do that for this form?. By simple creating a loading state variable that will be updated by the submit function used by the enhance action.

<script>
import { enhance } from '$app/forms'
/** @type {import('./$types').PageData} */
export let data;
const options = [1,2,3,4,5]
// Create a loading state variable
let loading = false
/** @type {import('$app/forms').SubmitFunction} */
function addTodo(input){
// before the form is submitted set the loading state to true
loading = true
return async ({ update }) => {
// After the response returns update the loading state to false
loading = false
await update()
}
}
</script>
<h1>{data.title}</h1>
<div>{@html data.content}</div>
<form method="POST" action="/" use:enhance={addTodo}>
<input type="text" name="title" placeholder="TODO title" />
<input type="text" name="description" />
<select name="priority">
{#each options as option}
<option value={option}>{option}</option>
{/each}
</select>
<button type="submit" disabled={loading}>
{#if loading}
Creating...
{:else}
Create Item
{/if}
</button>
</form>

Let’s store new todo items in a database

The form actions are exported from the +page.server.js file allow you to perform server side code with ease, like accessing a database instance

Remember from previous chapter, the +page.server.js file execute code only on the server and +page.js file execute the code both server and client

Let’s use what we know until now continue working in the TODO application allowing the user to, create a todo item, store that into a database and also retrieve all the items to show them in the page.

First, let’s check the current state of the application, the file tree should look something like this:

src
└──routes
├── +layout.svelte
├── +page.svelte
└── todo
├── [id]
│ ├── +page.js
└── └──+page.svelte

Let’s open the root +page.svelte file to add the corresponding form, it should look the same as the previous example.

Then, create the server side file +page.server.js let’s add a basic load function and the default form action as seen before.

You can also name the files as +page.server.ts to work with Typescript

previous code

The code of this file is also the same as seen before:

  • a load function that returns dummy content
  • an actions object that defines the default action, it reads the data from the form and perform a function named saveTodoItem that you need to implement.

The idea now is to be able to store the data received by the action into a database, for this example I choose to use sqlite but you can choose anything you want and will behave very similar.

The next steps will be:

  • install dependencies pnpm add better-sqlite3
  • Create the sqlite database through the cli
  • Create a new file to hold the database setup and logic under src/lib/sqlite.js

To setup the database and tables that the application will use just open the CLI, make sure you are located at the root of the project and execute:

$ sqlite todos.db
sqlite > CREATE TABLE items ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL, priority INTEGER NOT NULL);

That will create a table inside the sqlite database named items with the following keys: id as primary key and title and description as text and finally priority as integer, same as the fields that are shown in the form.

With the table created, time to go back to the code, create the file src/lib/sqlite.js this file will hold the database setup and helper function to store and retrieve information

import Database from 'better-sqlite3'
const db = new Database('todos.db')
db.pragma('journal_mode = WAL')
/**
* @typedef {{
title: string,
description: string,
priority: number
}} TodoItem
*/
/**
* @param item {TodoItem}
*/
export function saveTodoItem(item) {
const sql = db.prepare(`INSERT INTO items (title, description, priority) VALUES (?, ?, ?)`)
const info = sql.run(item.title, item.description, item.priority)
return info.changes
}
export function getTodoItems() {
const sql = db.prepare(`SELECT * FROM items`)
return sql.all()
}
/**
* @param id {number}
*/
export function getTodoItem(id) {
const sql = db.prepare(`SELECT * FROM items WHERE id = ?`)
return sql.get(id)
}

The file export 3 functions that allows you to insert items into the database, retrieve all of them and retrieve one identified by the id.

Now, you can use them inside the +page.server.js file by importing the saveTodoItem function from $lib/sqlite to be able to save the data and also importing getTodoItems to use it in the load function, now the file will looks like this

import { fail } from '@sveltejs/kit';
import { saveTodoItem, getTodoItems } from '$lib/sqlite';
/** @type {import('./$types').PageLoad} */
export async function load({ params }) {
const items = await getTodoItems()
return {
title: `This title comes from some other logic`,
content: `This was server rendered`,
items
};
}
/** @type {import('./$types').Actions} */
export const actions = {
default: async ({ request }) => {
// read the form data sent by the browser
const formData = await request.formData()
const title = formData.get('title').toString()
const description = formData.get('description').toString()
const priority = formData.get('priority').toString()
// Perform some backend code/logic action
const item = await saveTodoItem({
title,
description,
priority: parseInt(priority,10)
})
if (!item) {
return fail(401, { message: 'Invalid data' })
}
return { success: true };
}
}

Now, last step will be to update the page component to list the items returned by the load function by adding a table at the bottom like the following

<table>
<thead>
<tr>
<th>Id</th>
<th>Title</th>
<th>Description</th>
<th>Priority</th>
</tr></thead
>
<tbody>
{#each data.items as item}
<tr>
<td>{item.id}</td>
<td><a href={`/todos/${item.id}`}>{item.title}</a></td>
<td>{item.priority}</td>
</tr>
{/each}
</tbody>
</table>

This table will render the data that comes inside the data property, it will also show you a link to navigate to that item page.

Do you remember that in previous chapter you created a dynamic route under todo/[id] ?, Let’s work on that page to retrieve just one todo item.

The current status of that page is the following

<script>
/** @type {import('./$types').PageData} */
export let data;
$: console.log(data)
</script>
<h1>This is the TODO item {data.id}</h1>
<p>Here I'll show the contents of this item</p>

This route defines a +page.js file but that runs both on the server and client, we need to run only server code to retrieve the database data, rename that file to +page.server.js and let’s add the logic to get the data

import { getTodoItem } from '$lib/sqlite'
/** @type {import('./$types').PageLoad} */
export async function load({ params }) {
const item = await getTodoItem(params.id)
return {
id: params.id,
item,
}
}

Straightforward code, just grab the id from the url parameters (the dynamic part) and use that to retrieve the item from the database with the helper function created before.

Now, on the page side, let’s display the information.

<script>
/** @type {import('./$types').PageData} */
export let data;
$: console.log(data)
</script>
<h1>This is the TODO item {data.id}</h1>
<h2>Title: {data.item.title}</h2>
<p>Description: {data.item.description}</p>
<p>Priority: {data.item.priority}</p>

And that is all you need.

Now, the challenge for this step will be to update this page to allow the users to update a todo item, that means:

  • Adding a form that shows as default values the current state of the todo item
  • Adding a new form action to handle the update
  • show the updated data in the page

You can also make the pages prettier by adding styling using the <style> tag, TailwindCSS classes, or anything else you’d like.

At this point you now all that you need to kick start your journey developing applications with SvelteKit, there are more to uncover as you progress with it so stay tuned for more articles where we will delve into more advanced topics.

Ready to pick up the pace?

Enter your email and receive regular updates on our latest articles and courses

What do you want to take to the next level?