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<<
Top comments (3)
The funny thing is, Svelte uses attributes which are fast closer to using native html forms than react. Svelte will also work without any JavaScript enabled on the client.
Personally, I went ahead and started using the svelte superforms library and can really recommend it.
This isn't criticism of the article, I really enjoy seeing more Svelte content on here and yours is s great example! I only really started learning how massive web development was supposed to work when working with svelte.
My goto way of doing forms with svelte is also using superforms to handle everything, it makes our life much easier, but I thought that would be a little overhead for an introductory article.
Yeah, superforms can get advanced quickly. Definitely a good call!