Semantic form actions, and easier progressive enhancement #5875
Replies: 46 comments 160 replies
-
I don't quite understand how it works without js. If it redirects to Edit: if we need a hidden field anyway, we could put the action in a hidden field as well and do post requests to /todos |
Beta Was this translation helpful? Give feedback.
-
I dig it.
I foresee the most confusion around In any case, I appreciate the fastidious nature of these discussions and offer a big kudos to all the maintainers 🙌. |
Beta Was this translation helpful? Give feedback.
-
Overall, I think this is a great proposal. The separate actions makes things much easier to understand, and the One thing I don't think would be good is the singular file upload handler in the hooks; I can foresee at least some wanting to do different things with files in different actions. If the tradeoff in this case would be having to await the |
Beta Was this translation helpful? Give feedback.
-
I like this proposal and the functionality it provides, but I'm a little confused by the varying treatment of request methods. It seems like it may lead to having different rules for handling requests that are made in different contexts. One thing that's nice about the web is that requests are just requests, but if I'm reading this correctly it doesn't feel like that anymore – there's a big layer of abstraction and indirection around how each request is handled. For the sake of comparison there's similar functionality in .NET Core that makes action requests feel more consistent with other requests. You can write a template that looks like: <form method="post" asp-page-handler="updateTodo">
<!-- ... -->
</form> That non-standard The example uses some conventions that are specific to .NET and the principles behind their web framework, but I hope it still demonstrates the possibility of routing requests and still keeping things closer to the web's simple underpinnings. By contrast, the proposal above feels more complex to me. |
Beta Was this translation helpful? Give feedback.
-
I think this is a very interesting area and a good proposal to sveltekit. Forms always seemed more difficult than they should be to me. I still don't understand all the points that were proposed so I'll reread it before making other comments. But if I understood corretly Do you thinking renaming it to If I was learning sveltekit and saw |
Beta Was this translation helpful? Give feedback.
-
With this design, we could allow That would also allow us to drop the |
Beta Was this translation helpful? Give feedback.
-
I can dig it. It's a bit funky that some server-side-only files would exclusively support (Just had to think that one through while I was typing) One thing I wasn't clear on - SvelteKit will auto-create the |
Beta Was this translation helpful? Give feedback.
-
Regarding the discussion on File Uploads and placing the logic into I totally see the point of putting it there for simplicity, and it's reminiscent of using Multer in Express from so long ago. However, it's likely that developers will need some decent education if this proposal continues as described to make sure they are using it correctly. So the first thought on this is I wouldn't expect that to be there, and would expect a route for the upload instead. Second thing that comes to mind, by replacing the file in This brings me to a third thought: If files are handled in a hook, you then have to put all your validation, handling, etc. in that hook as well. Probably fine if you have something like and Avatar jpg upload and that's it (or other simple scenario), but when you get into larger apps I can see issues popping up. With more uploads and more reasons to upload, different permissions on files size and type by user, multiple file storage locations based on x-y-z, uploads to multiple routes from the client, etc. etc. - suddenly all of this is still handled in the same hook and you have a cascade of Just my 2c and others might see this vastly differently. FWIW the rest of this proposal is great. I think some hard thinking needs to go into how Semantic Actions would be implemented, but it would solve an issue I have already run into with limitation on verbs used in endpoints, just like you described. I'm currently solving this using a |
Beta Was this translation helpful? Give feedback.
-
4b. How to implement in the <form action="/search" method="GET"> |
Beta Was this translation helpful? Give feedback.
-
I see one big problem with this proposal: This approach means that if I want to have a classic application and provide REST api at the same time, the code will have to be duplicated. I suggest that it be possible to use with Eh... I just realized that the directory based router sucks. I will have to split the logic of This cane be doing with the trick: Unfortunately, I have no idea for an implementation so that such tricks do not destroy the beauty and simplicity of this and the previous change. //edit: Can be possible use actions name in HTML in I'm prefer urls like |
Beta Was this translation helpful? Give feedback.
-
This is great! I can't say I fully understand the actions change, but it looks good to me. The |
Beta Was this translation helpful? Give feedback.
-
Looks like an interesting proposal and I can see the benefits of it (and it also points to the benefit of folder based routing instead of file based) but had a couple of clarifications / questions.
Thanks. |
Beta Was this translation helpful? Give feedback.
-
@Rich-Harris I must note my suggestion Targeted Slots - sveltejs/rfcs#68 I previously cited Declarative Actions, but the author agreed that Targeted Slots solves his problem completely, solving other problems as well. <script>
import { Form } from '$app/navigation';
/** @type {import('./$types').Actions} */
export let actions;
</script>
<Form
action={actions.createTodo}
on:submit={({ values }) => {
pending = [...pending, values];
return () => {
pending = pending.filter((todo) => todo !== values);
};
}}
on:error={(e) => {
displayErrorToast(e);
}}
let:errors
let:values
>
<svelte:element slot="form"
class="flex flex-column p-4 mt-2"
id="nativeFormELement"
>
<input hidden name="type" value="new">
{#if errors?.description}
<p class="error">{errors.description}</p>
{/if}
<input
name="description"
value={values.get('description') ?? ''}
>
<button>add todo</button>
</svelte:element>
</Form> On the surface it looks usual, the magic of the proposal happens inside - in Simplified <script>
let form;
let errors;
let values;
export let action;
</script>
<slot {errors} {values} />
<svelte:element targeted:form this="form" bind:this={form} {somethingAction} {/*somethingEventsDispatch*/}/>
Where To understand how this is supposed to work, read the Targeted Slots syntax. Originally posted by @lukaszpolowczyk in #3533 (comment) And in general, I like the changes very much, especially the |
Beta Was this translation helpful? Give feedback.
-
I understand people's reservations against a separate file for this:
What if instead the actions are part of
Theoretically we can make it possible this way to also support actions inside The drawback of "some names are reserved" is negligible for me - by then you probably know these already and name clashes are close to 0 because the reserved names have nothing "actionable" in their names. |
Beta Was this translation helpful? Give feedback.
-
I really like the idea of making forms easier to handle 👍 Here are some comments on the proposed solution: reactivityWill <input
name="description"
value={values.get('description') ?? ''}
> The value of the input field does not get bound. How would I do a validate-while-you-type functionality? Also optimistic uiAll the examples above show invalid (or incomplete) JavaScript syntax when it comes to the optimistic UI feature. The standalone componentsCan we keep them? For me it is not really clear if they are replaced by
|
Beta Was this translation helpful? Give feedback.
-
Could we keep
With the action becoming |
Beta Was this translation helpful? Give feedback.
-
I like everything about this proposal except one. The thing is that whenever we wanted to declare a function in SvelteKit whether it be component, page endpoint, standalone endpoint, hooks, we declared it using function declaration. It never needed to me a method of an object. It's the Sveltish way. I think your previous proposal in which functions don't need to be a method of |
Beta Was this translation helpful? Give feedback.
-
I really like the direction this is going. However there is still something I don't understand: How would I get validation data (errors and fields) back from another page's action handler when JS is disabled? In the |
Beta Was this translation helpful? Give feedback.
-
Re: having a Remember that it's not the only way to set a form action and method. They can also be set on individual Use-case maybe having a single form with buttons for submitting changes or deleting. |
Beta Was this translation helpful? Give feedback.
-
Sorry for the above comment. Instead after thinking through I would like to suggest the following few things:
// in +page.js, or we rename to +page.load.js
// exporting either one or both of the functions below
export async function client (){ /* client side load */ }
export async function server (){ /* server side load */ }
// in +page.server.js, or we rename to +page.form.js or +page.actions.js
export async function create ({ /* fields and other props */ }){ /* logic for this action */ }
export async function delete ({ /* fields and other props */ }){ /* logic for this action */ }
export async function update ({ /* fields and other props */ }){ /* logic for this action */ }
<form method="POST">
<input type="hidden" name="action" value="create"/>
...
</form>
<form method="POST">
<input type="hidden" name="action" value="delete"/>
...
</form>
<form method="POST">
<input type="hidden" name="action" value="update"/>
...
</form> or with the value in a button (would need to decide precedence in this case if it collide with hidden input fields above) <form method="POST">
<button type="submit" name="action" value="create">Create</button>
...
</form>
<form method="POST">
<button type="submit" name="action" value="delete">Delete</button>
...
</form>
<form method="POST">
<button type="submit" name="action" value="update">Update</button>
...
</form> or if only a single form // in +page.server.js, or we rename to +page.form.js or +page.actions.js
export async function default ({ /* fields and other props */ }){ // logic for this action }
<form method="POST">
<button type="submit">Default</button>
...
</form> |
Beta Was this translation helpful? Give feedback.
-
Maybe it is possible to generate something like this: type Action = Readable<{
// the types can be generated from the action file
errors: Record<string, any>;
}> & {
name: string;
values: Record<string, any>
enhance: (form: HTMLFormElement) => void;
}
The actions can be defined in a seperate folder like
/src/actions/auth/signin.ts import type { Action } from './$types';
export const action: Action = async ({ body, url } ) => {
if (!body.get('username') {
return {
status: 403,
errors: {
username: 'please enter your username'
}
}
}
return {
status: 200,
// no redirect needed in this example
// redirect: '/'
}
} $app/actions/auth/signin const name = 'auth-signin';
export const action = {
subscribe(fn) {
const store = getAction(name);
return store.subscribe(fn);
},
get name() {
return name;
},
get enhance() {
const store = getAction(name);
async function handleSubmit(this: HTMLFormElement, ev: SubmitEvent) {
const res = await fetch(this.action, {
method: this.method,
body: new FormData(this),
});
const data = await res.json();
if (data.errors) {
store.set({ errors: data.errors });
} else {
store.set({ errors: {} });
// invalidate to load data
// this should only reload the current url path or custom paths
invalidate()
}
}
return function (node: HTMLFormElement) {
node.addEventListener('submit', handleSubmit);
return {
destroy() {
node.removeEventListener('submit', handleSubmit);
},
};
};
},
}; /src/routes/+page.svelte <script lang="ts">
// or maybe export with the same name as the file eg signin ?
import { action as signinAction } from '$app/actions/auth/signin';
</script>
<form method="post" action="?sveltekit-action={signinAction.name}" use:signinAction.enhance>
<input type="text" name="username" value={signinAction.values.username} />
<p>{$signinAction.errors.username}</p>
<button type="submit">Submit</button>
</form>
<form method="post" action="?{signinAction.name}" use:signinAction.enhance> That way the query param can be changed in the config file in case a user really wants to use that prefix. The server can look for the query param and handle the request if an action with the name is found. This may seem a bit too magic but i like the idea of not beeing limited to use an action in just one place. things that would change
|
Beta Was this translation helpful? Give feedback.
-
Svelte becoming dirty, with all the great features and enhancement I feel like it's time to take a step back and look at everything from above again. I've heard every one of Rich's talks and saw where the pain points he wants to cover with this awesome framework, and how it should be intuitive to people who just know the bare bones of the web language. and when I've looked at the recent changes seems like it's going a bit backward in this case. more and more svelte-specific rules, more helpers, and less native code. I don't say it's bad, but I think it might be a bit off-target. Obviously, you created an amazing project here, but we are here to improve it by sharing our constructive criticism. |
Beta Was this translation helpful? Give feedback.
-
I honestly think it's a mistake to move forms and form submission away from an HTTP API. If you cover few cases it can be simple, but you still need to expose HTTP to cover all the other cases. The worst case is you end up with something of decent capability of middling complexity. I understand the motivation -- we all want forms to be easier -- but I think this would be creating a honeypot trap of an API, albeit unintentionally. At least make it wholly optional and let a page have a POST handler with HTTP request and response. And try to understand and clearly document the limitations up-front, so devs can make an informed decision about opting-in. |
Beta Was this translation helpful? Give feedback.
-
Aside from my general comment, I have this suggestion: The original proposal makes a distinction between "result" and "errors". They are handled differently and produce a different outcome. I think the updated proposal carries this through (I might have missed something), and even potentially adds a third thing, "fields". e.g., returning "errors" for a native form submission causes the page to rerender, with the additional errors information made available. But validation "errors" aren't the only kind of information a form submission might generate that you'd want to re-render with. Yet "result" is only returned for fetch submissions. I think these could be unified, making this mechanism more consistent and more flexible: A form action handler could return an object with Note, the result of a form action handler now looks and acts a lot like a load. Those can be unified as well. data returned from form action handler is added to the "data chain" (at the front makes sense to me -- edit: on second thought, I'm not sure -- maybe after layout data but before page server load? IDK). And either change load to return an object with "data" or "location" (like form action handler), or have form action return "data" directly and throw a redirect like load -- the former makes more sense to me (what does throw a redirect even mean?), but either way is OK by me). |
Beta Was this translation helpful? Give feedback.
-
Eagerly waiting for some action on this. I know the community is working double its time. But since it's a big change and migration tires everyone, the more it happens earlier the better. Love you @Rich-Harris |
Beta Was this translation helpful? Give feedback.
-
Looks like there's a PR in the works for this now, is there any word on what was settled on for query params? I'm still concerned with clashes and keeping string values + function names in sync. |
Beta Was this translation helpful? Give feedback.
-
Let me dream for a second a describe how I would imagine my ideal API. First and foremost, I think SvelteKit should still have the first class support for HTTP verbs. So, we can write RESTfull APIs in it that can be consumed by things other then Svelte's web frontend. But action proposal is really nice for readability and form handling. So, we can't we have both, where each approach might be optional and up to a project's requirements. This way we can still write APIs the way we used to, but would eliminate unnecessary boilerplate (like a giant switch statement in POST handler) when needed. So, I have multiple proposals: Make handlers importable functionsimport { GET, POST, ACTION } from "sveltekit";
GET(() => {
//implementation
});
POST(() => {
//implementation
});
ACTION(async () => {
//implementation
});
ACTION("create")(async () => {
//implementation
}); Benefits:
Return regular JSON by defaultWhen a browser requests a page it adds the /**
* Regular get request, returns JSON by default. If a route was hit
* with `accept: text/html` then it loads the page with the result of this function.
*/
GET(async ({ locals }) => {
// JSON data is returned
return {
todos: await db.getTodos(locals.userid)
};
}); Benefits:
Use symbol for returned headersWe use import { ACTION, error, redirect, headers } from "sveltekit";
ACTION("create")(async ({ fields, url }) => {
const to = url.searchParams.get("redirectTo");
if (to) {
throw {
...redirect(to),
[headers]: { "X-From": url }
}
}
if (!fields.text) throw error(400, "Text shouldn't be empty!");
const todo = await db.addTodos(fields.text);
// This returns a plain JSON. BUT! `headers` is a unique symbols which is filtered
// from the response and sent as headers. Being a `Symbol` it insures uniqueness,
// avoids conflicts, allows all the returned data to be in one place (compared to
// the `setHeaders` function) and allows for clean and concise syntax.
return {
...todo,
[headers]: {
"set-cookie": await createSession()
}
};
}); Benefits:
We keep both HTTP and ACTIONs (so they enhance each other)Actions are glorified post handler. But we should be able to use them both! A post handler might be a nice middleware for our action. And actions might nicely reduce the code in a POST handler. /**
* Regular POST request handler. It also has prepared `action` parameter,
* according to whatever convention svelte will have (e.g. `?/action`). This way we can implement
* any middleware (like authentication) for every action in this route.
*/
POST(async ({ locals, action }) => {
if (action.startsWith("admin-")) {
if (!(await db.isAdmin(locals.userid))) {
throw error(403, "No Access, Sorry.")
}
}
// If nothing is returned or thrown, proceed to handle ACTIONs
});
/**
* ACTION is a higher order function which returns a filtered POST handler for a particular
* action.
*/
ACTION("create")(() => { ... });
ACTION("update")(() => { ... });
ACTION("toggle")(() => { ... });
ACTION("remove")(() => { ... });
/**
* Some actions, access to which is managed by the POST handler.
*/
ACTION("admin-dashboard")(() => { ... });
ACTION("admin-logs")(() => { ... });
/**
* If the first parameter of an ACTION if typeof "function", it is treated as default action.
* It is very similar to the POST handler, but it runs only when no other actions are called,
* while POST runs before every action.
*/
ACTION(async () => {
return {
[headers]: {
"set-cookie": await createSession()
}
};
}); ConclusionTo sum this up, the full code for a route might look like this: Full Codeimport { error, redirect, headers } from "sveltekit";
import { GET, POST, ACTION } from "sveltekit";
/**
* Regular get request, returns JSON by default. If a route was hit
* with `accept: text/html` then it loads the page with the result of this function.
*/
GET(async ({ locals }) => {
// JSON data is returned
return {
todos: await db.getTodos(locals.userid)
};
});
/**
* Regular POST request handler by default. But it also has prepared `action` parameter,
* according to whatever convention svelte will have (e.g. `?/action`). This way we can implement
* any middleware (like authentication) for every action for this route.
*/
POST(async ({ locals, action }) => {
if (action.startsWith("admin-")) {
if (!(await db.isAdmin(locals.userid))) {
throw error(403, "No Access, Sorry.")
}
}
// If nothing is returned or thrown, proceed to handle ACTIONs
});
/**
* ACTION is a higher order function which returns a filtered POST handler for a particular
* action.
*/
ACTION("create")(async ({ fields, url }) => {
const to = url.searchParams.get("redirectTo");
if (to) {
throw {
...redirect(to),
[headers]: { "X-From": url }
}
}
if (!fields.text) throw error(400, "Text shouldn't be empty!");
const todo = await db.addTodos(fields.text);
// This returns a plain JSON. BUT! `headers` is a unique symbols which is filtered
// from the response and sent as headers. Being a `Symbol` it insures uniqueness,
// avoids conflicts, allows all the returned data to be in one place (compared to
// the `setHeaders` function) and allows for clean and concise syntax.
return {
...todo,
[headers]: {
"set-cookie": await createSession()
}
};
});
/**
* The other actions are treated is a similar way
*/
ACTION("update")(() => { ... });
ACTION("toggle")(() => { ... });
ACTION("remove")(() => { ... });
/**
* Some admin actions, access to which is managed by the
* POST handler.
*/
ACTION("admin-dashboard")(() => { ... });
ACTION("admin-logs")(() => { ... });
/**
* If the first parameter of ACTION if typeof "function", this is treated as default action.
* It is very similar to the POST handler, but it runs only when no other actions are called,
* while POST runs before every action.
*/
ACTION(async () => {
return {
[headers]: {
"set-cookie": await createSession()
}
};
}); |
Beta Was this translation helpful? Give feedback.
-
Thinking out loud here, but I'm wondering if something like this might be possible to further simplify routing? Using @Rich-Harris proposal as a foundation, I propose considering removing // src/routes/todos/+page.server.js
import * as db from '$lib/db.server.js';
import type { FormActions, PageLoad } from "./$types";
export const load: PageLoad = async ({ locals }) {
return {
todos: await db.getTodos(locals.userid)
};
}
export const actions: FormActions = {
async create({ locals, fields }) {
await db.createTodo(locals.userid, fields.get('description'));
},
async update({ locals, fields }) {...},
async toggle({ locals, fields }) {...},
async remove({ locals, fields }) {...},
};
// Endpoints do not respond to requests of type "text/html"
export const endpoints: Endpoints = {
// Does not pass data to "load"
async GET(event) {
// return JSON by default (nice to have, not essential)
return { hello: "world" }
// or:
// return json({...}, { status, headers });
},
// Can also return a custom response
async POST(event) {
return new Response(
xml,
{ headers: { "content-type": "application/xml" } }
);
},
async PUT(event) {...},
async PATCH(event) {...},
async DELETE(event) {...},
async SOMETHING_CUSTOM(event) {...},
}
I'm sure I'm missing some things, but having a single |
Beta Was this translation helpful? Give feedback.
-
forms-proposal.final.v5.final.mdAlright, this thread is overdue for an update. Along with the plentiful feedback on this page (thank you all!) the maintainers have spent hours discussing the fine details of this proposal, and we're excited about where it's all landing. @dummdidumm is in the process of implementing everything in #6469. I'm going to try and articulate the design as best I can (and as concisely, though as usual there's a lot of ground to cover). Our goal here is to provide a fantastic easy-to-understand experience for the common case (with type safety, where possible), while allowing as much flexibility as people need to solve niche problems. A
|
Beta Was this translation helpful? Give feedback.
-
updated proposal below: #5875 (comment)
updated updated proposal below that: #5875 (comment)
SvelteKit has always cared about progressive enhancement, i.e. making it easy to build apps that work with or without JavaScript, which is why we promote things like SSR and native form behaviour. The demo app you get when you run
npm create svelte
includes a progressively enhanced<form>
that allows you to create and edit todo items even if JS fails for some reason.That said, the ergonomics have been something of a TODO up till now. #3533 contains a discussion of a
<Form>
component that we might add to SvelteKit, but it's only half the problem — the client and the server need to work together, and right now the server isn't really pulling its weight. The right design has been elusive, but with #5748 almost implemented, things are finally starting to come into focus.In this discussion I'll propose a new design that improves upon the
POST
,PATCH
,PUT
andDELETE
functions you might have used with page endpoints (or+page.server.js
, as of #5748). This will be another breaking change, but much more limited in scope than the tsunami of #5748 (which we're close to landing).It's a long read, so you might want to put the kettle on.
tl;dr
As with all such changes, things will make more sense if you read the whole document, so please hold off on commenting until you've done so!
GET
in+page.server.js
and+layout.server.js
will be renamed toload
+page.server.js
will no longer supportPOST
,PATCH
,PUT
andDELETE
+actions.server.js
, that exports form actions./__actions/[name]
— that forms can submit data toactions
prop pointing to these sub-routes<Form>
component will make progressive enhancement easy (though is not necessary for form actions to work)The goal of these changes is to encourage SvelteKit users to build progressively-enhanced apps — not in an eat-your-greens way, but because it's so easy that there's no point not using progressive enhancement — and in so doing contribute to a more resilient web.
While it might seem that we're introducing a bunch of new concepts, we're really refining and clarifying existing concepts — for example you would no longer need to learn the confusing distinction between
POST
in+page.server.js
andPOST
in+server.js
.What problem are we solving?
Today, you can use a page endpoint (soon to be
+page.server.js
) to handle form submissions. (Technically, they can handle any HTTP request, but since they're bound to a page they're only really useful for form submissions.) Each page can have up to four actions associated with it, one for each of thePOST
/PUT
/PATCH
/DELETE
HTTP verbs.After running the action, SvelteKit renders the resulting page, which involves running all the
GET
functions (including for+layout.server.js
files, which unlike+page.server.js
can only haveGET
), and possibly combining the resulting data with validation errors from the action.It all works, but the asymmetry between
GET
and the other verbs is jarring, and even for simple apps the every-action-corresponds-to-a-verb thing can be quite limiting. In the case of the demo app, we have an<input>
that changes the text of a todo and a<button>
that toggles its done state. Both those actions are patches, which means ourPATCH
handler has to differentiate between them. Not only that, but because the<form>
element only supportsGET
andPOST
, we have to muck around with method overrides.We'd have a better time if our actions were semantic —
createTodo
instead ofPUT
,updateTodo
andtoggleTodo
instead ofPATCH
,removeTodo
instead ofDELETE
— and we went with the grain of the platform by always using POST requests.Semantic actions
What if we did this instead — exported semantic actions from a new route file,
+actions.server.js
?FormAction
takes anevent
object which extendsRequestEvent
withvalues
— the equivalent ofawait request.formData()
— and optionally returns an object with one of the following:result
means the action succeeded. In a native form submission, the value is disregarded, but if we submitted viafetch
then the response payload (which is JSON) includes this resultlocation
also means the action succeeded. In a native form submission this will result in a 303 See Other response. Withfetch
, the location is included in the response payloaderrors
means the input was invalid somehow and the action was not carried out. In a native form submission, the page is re-rendered and the errors are made available to it (see below for discussion on how that happens); in thefetch
case, the errors are again included in the payload. In either case the status of the response defaults to 400; it may be necessary to support a customstatus
property on the returned objectReturning nothing means the action succeeded but there's no meaningful result to respond with (e.g. in the case of a deletion).
File handling
Some of you will have noticed a problem here. If you have a multipart form that includes (potentially large!) files, we need to do something with them. We probably don't just want to buffer them into memory, which is what happens if you call
await request.formData()
. One possibility is to add a new app-level function inhooks.js
:We could then make the returned representations available in the action:
Then again, an app-level file handler might be too restrictive? Would love to hear people's thoughts.
TypeScript
Typed forms are a little tricky. It would be possible to add type safety to
values
......but impossible to enforce that your form actually contains elements with the correct
name
attributes (and thatname="avatar"
only goes on<input type="file">
). Open to suggestions, but we may not be able to have complete type safety when working with forms.The new
actions
propExporting an action from
+actions.server.js
creates a new route —${routeId}/__actions/${actionName}
. For example, to post to thecreateTodo
action, you could do this:That's a little ugly. In reality you'd do this instead:
Generated types (
./$types
) ensure that the action is valid, and provide autocompletion.Validation errors
When a form is submitted with invalid data — i.e. the action returns an
errors
property — we want to show that to the user when the page is re-rendered, along with the previously submittedvalues
.This is easy in the trivial case where a page contains a single form, but breaks down if you have multiple forms — if the description for a new or existing todo was found to be invalid in this example, how would you know where to put the error UI and previous value?
We can solve this by adding data to the form itself as a hidden input, that is contained in the reflected form values. We add a writable
form
store (the reasons for this choice, as opposed to a readonly store or a prop, will become apparent later) that contains the submittedvalues
(aFormData
object) and resultingerrors
(the plain object returned by the action) like so:Progressive enhancement
So far, so good — we can tell at a glance what our
<form>
elements are doing, and we can handle data with minimal ceremony — and without requiring JavaScript.But if JavaScript is enabled we can use it to provide a better user experience — optimistic UI, and updates without reloads. All we need to do is intercept the
submit
event:Reducing boilerplate with
<Form>
As hinted at above, this is a fair amount of boilerplate. I dragged you along on that journey so that you could see that it's possible to build progressive enhancement on top of
+actions.server.js
, but ideally SvelteKit would do a lot of the work for you.For that, we have
<Form>
, with which the example above would look like this:Couple of things to note:
Earlier, I mentioned that
form
was a writable store for reasons that would be made clear later. Later is now: becauseform
is a store,<Form>
can read and writeerrors
andvalues
directly without you needing to pass them into the component.By injecting a hidden input with a unique value...
...it can determine during the initial render that the
$form
value applies to it, by checking if$form.values.get('__formid')
matches. In doing so, it can passlet:errors
andlet:values
to the component's contents, making them easier to access. (In extremely weird cases, it might be hard to guarantee that__formid
matches between client and server, so as an escape hatch we could allow anid
to be set manually.)let:errors
andlet:values
can be directly assigned to following afetch
submission, meaning that multiple forms could display validation errors in the progressive enhancement case (unlike with native form submissions, where submission results in a page reload). For example if you attempted to submit one<Form>
with bad data then attempted to submit a separate<Form>
with bad data, both could show validation errors independently$form
is primarily a way of getting data back from the server, and as such it represents a single form submission. We could instead make it a map of all form submissions to handle the progressive enhancement case, though it would involve some design compromisesOn
<Form>
#3533 there was some pushback to the idea of<Form>
, mostly because something like<form use:form>
would be easier to style. The reason for preferring<Form>
is that we can do a lot more boilerplate reduction — withuse:form
we'd need to do equivalents of__formid
/let:errors
/let:values
ourselves. Styling issues can be solved for the small price of the occasional wrapper elementValidation
You can't talk about forms without talking about validation. But we don't need to spend long on it, as this is mostly a userland concern — do your validation in the action, and the rest will fall into place.
If you need client-side validation as well as validating in the action, just make a function that can run in both environments and use it accordingly:
Replacing
GET
withload
Replacing
POST
,PUT
,PATCH
andDELETE
with actions leavesGET
by itself, which... is a bit weird.To recap: in #5748, we moved
load
into+page.js
and+layout.js
. We also changed the API ofload
andGET
— their inputs are more closely aligned, and they just return data. In fact, they're almost interchangeable. What if we leant into that? What if we normalised the APIs further, and let you choose between running yourload
function on just the server (+page|layout.server.js
), or on both the server and the client (+page|layout.js
)?This would entail:
fetch
to the server-side event. On occasion I've found myself needing to fetch data from endpoints within aGET
, which currently entails making a wasteful HTTP request that should instead be translated into a direct function call (in fact, we currently have a convoluted hack for allowingfetch
insideGET
to work during prerendering), so this feels like a welcome changedepends
to the server-side event. We don't currently track the things that cause a+page|layout.server.js
to be invalidated, but we probably should, so this too is probably a good thing to addsession
to the server-side event, or throwing an error if a server-sideload
attempts to read it for the reasons described hereload
in+page|layout.js
tries to accessrequest
,clientAddress
,platform
orlocals
, as these are only available on the serverIf you use
+page|layout.server.js
, you can access server-only properties (and import server-only modules, i.e. read private env vars etc), and you avoid shipping code to the client. If you use+page|layout.js
, you can avoid hitting the server for client-side navigation (useful if your data comes from an external API that doesn't require secrets) and return non-serializabledata
. The choice is yours.(Of course, in the rare case that you need both, you can continue to 'chain' them — the output of the server-side
load
is accessible to the sharedload
as thedata
property.)Phew! That was a long read. Appreciate you sticking with it. I'd love to hear your thoughts on this proposal, particularly if you've struggled with forms in SvelteKit previously and this would make a difference to your experience (for better or worse). Thanks!
Beta Was this translation helpful? Give feedback.
All reactions