cover

Working with forms in SvelteKit coming from React

Every application nowadays will run into the need of handling forms. In this article, we will take a look at how to handle forms in SvelteKit coming from React. We will look at how to handle form state, both validation and submission, and also loading states. For the React examples, I'll be using React with Next.js prior to v13, meaning I'm not going to be using app directory.

I know there are many differences when using Next.js v13 but for the time being, since many applications are built and are still being built using pages directory, I'll be using that for the examples.

NOTE: It's important that you follow the post sequentially, the code will start small and will grow with each functionality we add.

Form Submissions

In React, you're probably used to doing something like this to handle form submissions:

function FormSubmission() {
	function handleSubmit(e: FormEvent) {
		e.preventDefault()
		// do your fetch calls to an api endpoint
	}

	return (
		<form onSubmit={handleSubmit}>
			{/* ... */}
		</form>
	)
}

In SvelteKit on the other hand, you can use a +page.server.ts with a +page.svelte to handle form submissions without needing javascript on the client-side, doing something like this:

// +page.server.ts
export const actions = {
	login: async ({ request }) => {
		// get the input data from the FormData
		const { ... } = Object.fromEntries(await request.formData())
	}
}
<!-- +page.svelte -->
<form action="?/login" method="post">
	<!-- ... -->
</form>

This way, the form submission will go to the route you specified in the "action" attribute. It is important to use method="post" since other methods are not able to reach to form actions in SvelteKit.

You can take a more in depth look at SvelteKit's form actions documentation.

Now lets dive in

Lets get to an actual example using a form with inputs for a login functionality in React vs Svelte.

React

One way to do so with React if you're using a meta framework like Next.js is:

// /pages/api/login
export function handler(req: NextApiRequest) {
	const { username, password } = req.body
	// ...
}
function FormSubmission() {
	const [username, setUsername] = useState("")
	const [password, setPassword] = useState("")

	async function handleSubmit(e: FormEvent) {
		e.preventDefault()
		try {
			const res = await fetch("/api/login", {
				method: "POST",
				body: JSON.stringify({
					username,
					password
				})
			})

			if(!res.ok) throw new Error(...)
		} catch(err) {
			// ...
		}
	}

	return (
		<form onSubmit={handleSubmit}>
			<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} />
			<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
		</form>
	)
}

And that's it, we save the input values as state, send to the route handler and pick those values from req.body.

SvelteKit

In SvelteKit, you don't have to handle the input state if you don't want to, you can just use form actions and get the input values by their name.

export const actions = {
	default: async ({ request }) => {
		const { username, password } = Object.fromEntries(await request.formData()) as {
			username: string;
			password: string;
		};

		// ... do something with username and password
	}
};
<!-- +page.svelte -->
<form action="?/login" method="post">
	<input type="text" name="username" />
	<input type="password" name="password" />
</form>

I may be biased, but it's pretty cool we can do something like this in so less lines of code.

Validations

Of course it wouldn't be a great comparison without showing how validations would work in both cases, since every form you use nowadays will probably need some kind of validation.

There are libraries for handling form submissions with builtin integrations for validations libraries, like react-hook-form with @hookform/resolvers for React, and we have superforms for SvelteKit, that handles validation with zod, they both are made for the same purpose.

For the examples, I'll be using zod for both React and Svelte, but won't be using libraries for the sake of showing the differences. You should be able to use any other validation libraries you want with no problem, but since I'm more familiar with zod, that's what I'm goin to be using. It's good to know that I'll only handle validations on the server-side, that's why it's useless for us to save some variables state in Svelte.

As we're going to be using validation libraries, it's important that you render the errors that happen to let the user know what happened.

React

// /pages/api/login
const LoginSchema = z.object({
  username: z.string().min(3).max(10),
  password: z.string().min(10).max(100),
})

export function handler(req: NextApiRequest) {
	const result = LoginSchema.safeParse(req.body)
	
	if(!result.success) {
		return res.status(400).json({ error: result.error.format() })
	}
	
	const { username, password } = result.data

  // do something with username and password
}
function FormSubmission() {
	const [username, setUsername] = useState("")
	const [password, setPassword] = useState("")
	const [error, setError] = useState<{
		username: string | null
		password: string | null
	}>({
		username: null,
		password: null
	})

	async function handleSubmit(e: FormEvent) {
		e.preventDefault()
		try {
			const res = await fetch("/api/login", {
				method: "POST",
				body: JSON.stringify({
					username,
					password
				})
			})

			// 400 status code are not considered errors in fetch API
			if(!res.ok) {
				const data = await res.json()
				
				setError({
					username: (data?.username && data?.username._errors.length >= 1) ? data?.username._errors[0] : null,
					password: (data?.password && data?.password._errors.length >= 1) ? data?.password._errors[0] : null
				}),
			}
		} catch(err) {
			// ...
		}
	}

	return (
		<form onSubmit={handleSubmit}>
			<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} />
			{error.username && <small>{error.username}</small>}
			<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
			{error.password && <small>{error.password}</small>}
		</form>
	)
}

What we can see as a pattern here is that we have to store the username and password in states and also save the error as a state for showing it in the UI. In SvelteKit, it works very differently, lets see.

// +page.server.ts
const LoginSchema = z.object({
	username: z.string().min(3).max(10),
	password: z.string().min(10).max(100)
});

export const actions = {
	login: async ({ request }) => {
		const result = LoginSchema.safeParse(Object.fromEntries(await request.formData()));

		if (!result.success) {
			return {
				status: 400,
				error: result.error.format()
			};
		}

		const { username, password } = result.data;

		// do something with username and password
	}
};
<!-- +page.svelte -->
<script>
	export let form;
</script>

<form action="?/login" method="post">
	<input type="text" name="username" />
	{#if form?.error?.username && form.error.username._errors.length >= 1}
		<small>{form.error.username._errors[0]}</small>
	{/if}
	<input type="password" name="password" />
	{#if form?.error?.password && form.error.password._errors.length >= 1}
		<small>{form.error.password._errors[0]}</small>
	{/if}
</form>

With builtin form actions return from export let form, we can take the validation errors and show directly on the UI, without having to save internal state.

If we want to have SPA like feeling, we can even use enhance coming from "$app/forms" to progressively enhance the form, and the page will not be refreshed like an MPA when javascript is enabled.

<!-- +page.svelte -->
<script>
	import { enhance } from '$app/forms';

	export let form;
</script>

<form action="?/login" method="post" use:enhance>
	<input type="text" name="username" />
	{#if form?.error?.username && form.error.username._errors.length >= 1}
		<small>{form.error.username._errors[0]}</small>
	{/if}
	<input type="password" name="password" />
	{#if form?.error?.password && form.error.password._errors.length >= 1}
		<small>{form.error.password._errors[0]}</small>
	{/if}
</form>

With that, we learned how to create and handle form submissions, and also learned how to validate forms using a schema validator library like zod, but what if our request to the server takes some time? How would we show this to our user?

Loading states

It's extremely important to show to our user that we're not just hanging the window whenever he submits it, it's important to show in the UI that something is happening, so let's see how we would do so in React vs SvelteKit. For that, we will also add a button for submitting the form.

If you want to simulate a slow response time, you can use this util function, easy and simple, you just have to await on the call, like await wait(ms) and it will work.

function wait(ms: number) {
	return new Promise((resolve) => setTimeout(resolve, ms));
}

React

In React, we would need to add a new state and set it to the fetching state whenever it starts and whenever it ends, and based on that state, we can do whatever we want with our UI.

function FormSubmission() {
	const [username, setUsername] = useState("")
	const [password, setPassword] = useState("")
	const [error, setError] = useState<{
		username: string | null
		password: string | null
	}>({
		username: null,
		password: null
	})
	const [isLoading, setIsLoading] = useState(false)

	async function handleSubmit(e: FormEvent) {
		e.preventDefault()
		try {
			setIsLoading(true)
			const res = await fetch("/api/login", {
				method: "POST",
				body: JSON.stringify({
					username,
					password
				})
			})

			// 400 status code are not considered errors in fetch API
			if(!res.ok) {
				const data = await res.json()
				
				setError({
					username: (data?.username && data?.username._errors.length >= 1) ? data?.username._errors[0] : null,
					password: (data?.password && data?.password._errors.length >= 1) ? data?.password._errors[0] : null
				}),
			}
		} catch(err) {
			// ...
		} finally {
			setIsLoading(false)
		}
	}

	return (
		<form onSubmit={handleSubmit}>
			<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} />
			{error.username && <small>{error.username}</small>}
			<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
			{error.password && <small>{error.password}</small>}
			<button>
				{isLoading ? "Loading..." : "Submit"}
			</button>
		</form>
	)
}

Svelte

For SvelteKit, it isn't much different, we only have to handle the same loading variable changes but in a bit easier way when you get used to it.

We first will need to create a SubmitFunction for us to pass as a parameter to use:enhance, and inside there, we would change the loading states. Also, with Svelte's syntax, we don't have to create states with setters, states are just variables that you can assign and reference.

<!-- +page.svelte -->
<script lang="ts">
	import { enhance } from '$app/forms';
	import type { SubmitFunction } from '@sveltejs/kit';

	export let form;

	let loading = false;

	const handleSubmit: SubmitFunction = () => {
		loading = true;

		return async ({ update }) => {
			loading = false;
			await update();
		};
	};
</script>

<form action="?/login" method="post" use:enhance={handleSubmit}>
	<input type="text" name="username" />
	{#if form?.error?.username && form.error.username._errors.length >= 1}
		<p>{form.error.username._errors[0]}</p>
	{/if}
	<input type="password" name="password" />
	{#if form?.error?.password && form.error.password._errors.length >= 1}
		<p>{form.error.password._errors[0]}</p>
	{/if}

	<button>
		{#if loading}
			Loading...
		{:else}
			Submit
		{/if}
	</button>
</form>

Final comparison

Now let's compare the final result of both frameworks(or library, as React likes to say it is one).

React

// /pages/api/login
const LoginSchema = z.object({
  username: z.string().min(3).max(10),
  password: z.string().min(10).max(100),
})

export function handler(req: NextApiRequest) {
	const result = LoginSchema.safeParse(req.body)

	if(!result.success) {
		return res.status(400).json({ error: result.error.format() })
	}
	
	const { username, password } = result.data
	
	// do something with username and password
}
function FormSubmission() {
	const [username, setUsername] = useState("")
	const [password, setPassword] = useState("")
	const [error, setError] = useState<{
		username: string | null
		password: string | null
	}>({
		username: null,
		password: null
	})
	const [isLoading, setIsLoading] = useState(false)

	async function handleSubmit(e: FormEvent) {
		e.preventDefault()
		try {
			setIsLoading(true)
			const res = await fetch("/api/login", {
				method: "POST",
				body: JSON.stringify({
					username,
					password
				})
			})

			// 400 status code are not considered errors in fetch API
			if(!res.ok) {
				const data = await res.json()
				
				setError({
					username: (data?.username && data?.username._errors.length >= 1) ? data?.username._errors[0] : null,
					password: (data?.password && data?.password._errors.length >= 1) ? data?.password._errors[0] : null
				})
			}
		} catch(err) {
			// ...
		} finally {
			setIsLoading(false)
		}
	}

	return (
		<form onSubmit={handleSubmit}>
			<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} />
			{error.username && <small>{error.username}</small>}
			<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
			{error.password && <small>{error.password}</small>}
			<button>
				{isLoading ? "Loading..." : "Submit"}
			</button>
		</form>
	)
}

Svelte

// +page.server.ts
const LoginSchema = z.object({
	username: z.string().min(3).max(10),
	password: z.string().min(10).max(100)
});

export const actions = {
	login: async ({ request }) => {
		const result = LoginSchema.safeParse(Object.fromEntries(await request.formData()));

		if (!result.success) {
			return {
				status: 400,
				error: result.error.format()
			};
		}

		const { username, password } = result.data;

		// do something with username and password
	}
};
<!-- +page.svelte -->
<script lang="ts">
	import { enhance } from '$app/forms';
	import type { SubmitFunction } from '@sveltejs/kit';

	export let form;

	let loading = false;

	const handleSubmit: SubmitFunction = () => {
		loading = true;

		return async ({ update }) => {
			loading = false;
			await update();
		};
	};
</script>

<form action="?/login" method="post" use:enhance={handleSubmit}>
	<input type="text" name="username" />
	{#if form?.error?.username && form.error.username._errors.length >= 1}
		<p>{form.error.username._errors[0]}</p>
	{/if}
	<input type="password" name="password" />
	{#if form?.error?.password && form.error.password._errors.length >= 1}
		<p>{form.error.password._errors[0]}</p>
	{/if}

	<button>
		{#if loading}
			Loading...
		{:else}
			Submit
		{/if}
	</button>
</form>

Lines count

Of course lines does not show the complexity and nor show which one is better, but I guess for the record, it's great to see that Svelte has still less lines than React even when counting the line breaks of #if statements. So here is the final result without counting blank lines and comments.

React - 56 Svelte - 44

Wrapping up

And that's it, we wrapped up form submission, validation, errors, and loading states, hope you've learned something new or read something interesting today, see you on the next one!

>>learn svelte and learn web dev<<