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.
-
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