Skip to content

<Form> #3533

Closed

Description

@Rich-Harris

Describe the problem

Best practices around form data are slightly annoying — we want to encourage the use of native <form> behaviour as far as possible (accessible, works without JS, etc) but the best UX involves optimistic updates, pending states, and client-controlled navigation.

So far our best attempt at squaring this circle is the enhance action found in the project template. It's a decent start, but aside from being tucked away in an example rather than being a documented part of the framework itself, actions are fundamentally limited in what they can do as they cannot affect SSR (which means that method overriding and CSRF protection will always be relegated to userland).

Ideally we would have a solution that

  1. was a first-class part of the framework
  2. enabled best practices and best UX
  3. worked with SSR

Describe the proposed solution

I propose adding a <Form> component. By default it would work just like a regular <form>...

<script>
  import { Form } from '$app/navigation';
</script>

<Form action="/todos.json" method="post">
  <input name="description" aria-label="Add todo" placeholder="+ tap to add a todo">
</Form>

...but with additional features:

Automatic invalidation

Using the invalidate API, the form could automatically update the UI upon a successful response. In the example above, using the form with JS disabled would show the endpoint response, meaning the endpoint would typically do something like this:

export async function post({ request, locals }) {
  const data = await request.formData();

  // write the todo to our database...
  await db.write('todos', locals.user, data.get('description'));

  // ...then redirect the user back to /todos so they see the updated page
  return {
    status: 303,
    headers: {
      location: '/todos'
    }
  };
}

If JS isn't disabled, <Form> would submit the result via fetch, meaning there's no need to redirect back to the page we're currently on. But we do want the page to reflect the new data. Assuming (reasonably) that the page is showing the result of a GET request to /todos.json, <Form> can do this automatically:

// (this is pseudo-code, glossing over some details)

const url = new URL(action, location);
url.searchParams.set(method_override.parameter, method);

const res = await fetch(url, {
  method: 'post',
  body: new FormData(form)
});

// if the request succeeded, we invalidate the URL, causing
// the page's `load` function to run again, updating the UI
if (res.ok) {
  invalidate(action);
}

Optimistic UI/pending states

For some updates it's reasonable to wait for confirmation from the server. For others, it might be better to immediately update the UI, possibly with a pending state of some kind:

<Form
  action="/todos.json"
  method="post"
  pending={({ data }) => {
    // we add a new todo object immediately — it will be destroyed
    // by one without `pending: true` as soon as the action is invalidated
    todos = [...todos, {
      done: false,
      text: data.get('text'),
      pending: true
    }];
  }}
  done={({ form }) => {
    // clear the form
    form.reset();
  }}
>
  <input name="description" aria-label="Add todo" placeholder="+ tap to add a todo">
</Form>

{#each todos as todo}
  <div class="todo" class:done={todo.done} class:pending={todo.pending}>
    <!-- todo goes here --> 
  </div>
{/each}

<style>
  .pending {
    opacity: 0.5;
  }
</style>

this example glosses over one problem — typically you might generate a UUID on the server and use that in a keyed each block or something. ideally the pending todo would also have a UUID that the server accepted so that the invalidation didn't cause glitches. need to think on that some more

Error handling

There's a few things that could go wrong when submitting a form — network error, 4xx error (e.g. invalid data) or 5xx error (the server blew up). These are currently handled a bit inconsistently. If the handler returns an explicit error code, the page just shows the returned body, whereas if an error is thrown, SvelteKit renders the error page.

#3532 suggests a way we can improve error handling, by rendering a page with validation errors. For progressively enhanced submissions, this wouldn't quite work — invalidating the action would cause a GET request to happen, leaving the validation errors in limbo. We can pass them to an event handler easily enough...

<Form
  action="/todos.json"
  method="post"
  pending={({ data }) => {...}}
  done={({ form }) => {...}}
  error={async ({ response }) => {
    ({ errors } = await response.json());
  }}
/>

...but we don't have a great way to enforce correct error handling. Maybe we don't need to, as long as we provide the tools? Need to think on this some more.

Method overriding

Since the component would have access to the methodOverride config, it could override the method or error when a disallowed method is used:

<Form action="/todos/{todo.uid}.json" method="patch">
  <input aria-label="Edit todo" name="text" value={todo.text} />
  <button class="save" aria-label="Save todo" />
</Form>

CSRF

We still need to draw the rest of the owl:

image

I think <Form> has an important role to play here though. It could integrate with Kit's hypothetical CSRF config and automatically add a hidden input...

<!-- <Form> implementation -->
<script>
  import { csrf } from '$app/env'; // or somewhere
</script>

<form {action} {method} on:submit|preventDefault={handler}>
  <input type="hidden" name={csrf.key} value={csrf.value}>
  <slot/>
</form>

...which we could then somehow validate on the server. For example — this may be a bit magical, but bear with me — maybe we could intercept request.formData() and throw an error if CSRF checking (most likely using the double submit technique) fails? We could add some logic to our existing response proxy:

// pseudo-code
const proxy = new Proxy(response, {
  get(response, key, _receiver) {
    if (key === 'formData') {
      const data = await response.formData();
      const cookies = cookie.parse(request.headers.get('cookie'));

      // check that the CSRF token the page was rendered with 
      // matches the cookie that was set alongside the page
      if (data.get(csrf.key) !== cookies[csrf.cookie]) {
        throw new Error('CSRF checking failed');
      }

      return data;
    }
  }

  // ...
});

This would protect a lot of users against CSRF attacks without app developers really needing to do anything at all. We would need to discourage people from using <Form> on prerendered pages, of course, which is easy to do during SSR.

Alternatives considered

The alternative is to leave it to userland. I don't think I've presented anything here that requires hooks into the framework proper — everything is using public APIs (real or hypothetical). But I think there's a ton of value in having this be built in, so that using progressively-enhanced form submissions is seen as the right way to handle data.

This is a big proposal with a lot of moving parts, so there are probably a variety of things I haven't considered. Eager to hear people's thoughts.

Importance

would make my life easier

Additional Information

No response

Activity

  1. kevmodrome commented on Jan 25, 2022

    @kevmodrome

    I generally like this idea. I've been using the form action and it's very nice.

    How do we handle styling? Using an action it's easy since you just leave it to the user, with a component you need some kind of API.

  2. dummdidumm commented on Jan 25, 2022

    @dummdidumm
    Member

    I like this idea as well. Some things that are not clear yet, they are essentially about how far does "progressive enhancement" go. For example, let's say I want to provide better DX and do client-side validation (like: on blur the field is marked as invalid because it's required and there's no content in it). How to achieve this? Another use case: You have a component library which wraps HTML input elements of different kinds - is it still possible to interact with them in an easy way? An advanced use case: You have a list of form items and you can add more items by clicking a + button - will the form be able to detect this dynamic addition of another form element?

    These are all use cases you don't need for a simple sign up form but which will come up in internal business apps quite often. If we don't support these use cases, this should be made clear in the documentation at least, and that it's possible to just use something else entirely if you know you'll have JS interactivity on the site anyway (closed internal business apps etc).

  3. kevmodrome commented on Jan 25, 2022

    @kevmodrome

    I like this idea as well. Some things that are not clear yet, they are essentially about how far does "progressive enhancement" go. For example, let's say I want to provide better DX and do client-side validation (like: on blur the field is marked as invalid because it's required and there's no content in it). How to achieve this? Another use case: You have a component library which wraps HTML input elements of different kinds - is it still possible to interact with them in an easy way? An advanced use case: You have a list of form items and you can add more items by clicking a + button - will the form be able to detect this dynamic addition of another form element?

    I think the correct way to handle form validation is to rely heavily on the platform - nudge users in the direction of using CSS. There's a very thorough MDN article on client-side validation. The advantage of this is that it would work both in js and non-js situations.

    Here's a very barebones example of what we might encourage users to do. It could be made even simpler if you just wanted a visual indicator without the error message.

  4. Rich-Harris commented on Jan 25, 2022

    @Rich-Harris
    MemberAuthor

    How do we handle styling?

    Styles that you would have applied to the form itself probably need to go on a wrapper element either inside or outside the component. I think this is a reasonably small price to pay

    For example, let's say I want to provide better DX and do client-side validation (like: on blur the field is marked as invalid because it's required and there's no content in it). How to achieve this?

    We could add a presubmit callback with the ability to abort, but honestly most of the things that it's possible to validate client-side (ie not stuff like 'username already taken') can be done with HTML attributes, and we should push people in that direction

    You have a component library which wraps HTML input elements of different kinds - is it still possible to interact with them in an easy way? An advanced use case: You have a list of form items and you can add more items by clicking a + button - will the form be able to detect this dynamic addition of another form element?

    The component doesn't know what it contains and it doesn't need to know - new FormData(form) only cares what's in the DOM at submit time

  5. f-elix commented on Jan 25, 2022

    @f-elix
    Contributor

    I'm so glad this is brought up! I have really dug into the Remix <Form> component recently and implemented a version of it in Sveltekit (not a package, not yet at least). As things are right now I have ro resort to some hacks (like passing action data to event.locals and populating the session with it to retrieve it in my components), so I would absolutely love if Sveltekit provided an official solution.

    What you're proposing seems really sensible to me.

    For styling, I think you could either expose class and style props, or juste use $$restProps and allow any attribute so they can be used for styling (like data attributes and such). Maybe there are problems I'm not thinking of with such a blunt approach though.

    One quirk I've run into is adding events dynamically on the form itself, like a reset event, or input/change events (so we can use event delegation and not add a listener on every field). Right now I'm forwarding the native events that I want to use from the form because Svelte doesn't support dynamic events. That is of course more related to Svelte than Sveltekit, but I think its worth mentioning, since its a use case a lot of people might run into.

  6. arxpoetica commented on Jan 25, 2022

    @arxpoetica
    Member

    For styling, I think you could either expose class and style props

    Duh. I thought of exposing style (and hated just that), but exposing both solves the styling problem. Do that @Rich-Harris and I'll stop complaining. 😉

    mmm...update: it doesn't solve the scoping problem. still have to use :global(){} or whatever to reach that darn form element

  7. added this to the 1.0 milestone on Jan 27, 2022
  8. oliie commented on Jan 28, 2022

    @oliie

    I also like this idea! An other idea is to maybe have it build in already instead of importing Form, and maybe use something like <svelte:form> instead?

  9. brittneypostma commented on Jan 28, 2022

    @brittneypostma
    Contributor

    How do we handle styling?

    Styles that you would have applied to the form itself probably need to go on a wrapper element either inside or outside the component. I think this is a reasonably small price to pay

    There could be props created on the form component to allow for custom styling also or passing them via style props.

  10. dangelomedinag commented on Jan 28, 2022

    @dangelomedinag

    I'm not entirely sure that a <Form> component is the right solution. The reasons for thinking this are the following:

    • Svelte/Sveltekit does not provide components like other frameworks eg. <Link>, on the other hand, implements a solution directly on the HTML element. (and that's awesome).
    • It is highly likely that the solution of creating a <Form> component will not fit the needs of a large project where you need to manipulate a lot of things, both the element and logic. (you will end up implementing your own solution).
    • Svelte/Sveltekit it provides enough tools to do it on our own, without making us feel helpless by the framework itself.
    • The infinite dilemma of how to apply styles to nested components comes into play. using an "action" maintains the existing advantages (and disadvantages) up to now in terms of styles.

    it seems like a better idea to provide an "action" than a component. the difference in implementation will not be too different from one another.

    <Form> component:

    <script>
      import { Form } from '$app/navigation';
    </script>
    <Form
      action="/api/test"
      method="PUT" // method overrides
      class="flex flex-column p-4 mt-2"
      id="FormComponent"
      on:pending={(e) => console.log(e.detail /* => data */)}
      on:result={(e) => console.log(e.detail /* => response */)}
      on:error={(e) => console.log(e.detail /* => Error handler */)}
    >

    <form> with action

    <script>
      import { formEnhance } from '$app/navigation';
    </script>
    <form
      action="/api/test?_method=PUT" // manually method overrides
      method="post"
      class="flex flex-column p-4 mt-2"
      id="nativeFormELement"
      use:formEnhance={{
        pending: (data) => console.log(data),
        result: async (res) => console.log(await res.json()),
        error: (e) => console.log(e.message)
      }}
    >

    PS: my language is Spanish, the text is translated, sorry if there is something strange.

  11. PierBover commented on Jan 28, 2022

    @PierBover

    IMO the big advantage of using an action like use:form is that it would return control of the whole form to the parent component.

    No more styling issues but also, what if we want to use custom actions for form validation and whatnot? Or what if we need to get a ref to the form DOM element?

  12. lukaszpolowczyk commented on Jan 28, 2022

    @lukaszpolowczyk
    Contributor

    I'm against locking such things into a component.
    It's not a universal solution.

    But if you want the benefits of a component, you might consider implementing the Declarative Actions proposal:
    sveltejs/rfcs#41

    Then you have the benefits of the component, and you don't lose out on the drawbacks of the component, because you still have the full element exposed.

    There's magic going on underneath, and it's regular elements on top.

  13. lukaszpolowczyk commented on Jan 29, 2022

    @lukaszpolowczyk
    Contributor

    @dangelomedinag Moreover, through use:action you can create an event listener, then it will look like this:

    <script>
      import { formEnhance } from '$app/navigation';
    </script>
    <form
      action="/api/test?_method=PUT" // manually method overrides
      method="post"
      class="flex flex-column p-4 mt-2"
      id="nativeFormELement"
      on:pending={(e) => console.log(e.detail /* => data */)}
      on:result={(e) => console.log(e.detail /* => response */)}
      on:error={(e) => console.log(e.detail /* => Error handler */)}
      use:formEnhance
    >

    And through Declarative Actions it will be even easier to design.

  14. Glench commented on Jan 29, 2022

    @Glench

    Really glad you're thinking about this. In my own SvelteKit projects I've found it very tedious to implement all the form plumbing, and doing it consistently has proven difficult with the result that each page in my app has different ways of doing things.

    So it would be a great relief to have one consistent, framework-provided, batteries included solution that is a sensible default, but of course be able to do your own thing if needed.

    Pending, pre-submit, post-submit

    As for pending updates, pre-submit, and post-submit, one simple solution I've found that handles all of these cases in one is to provide the form's special "do the network request submission stuff" in a function that the user can call:

    <script>
    function submit({ network_request_magic }) {
      // do anything you want before data is sent to the server
    
      const res = await network_request_magic(); // network request stuff happens here
    
      // do anything you want after response is received from the server
    }
    </script>
    
    <Form action="" on:submit={submit}>
    ...
    </Form>

    One problem with this approach is that if the user potentially leaves out a call to network_request_magic then the form may not work.

    Slot prop

    Also, if there is indeed a <Form> component, it would be great to expose the pending network status as a slot prop:

    <Form let:submitting={submitting}>
      {#if submitting}
        <SpinnerThingy />
      {/if}
      <input disabled={submitting} />
    </Form>

    I do this in my own Form component and it's great — don't even need to have a <script> tag.

  15. 29 remaining items

  16. SrGeneroso commented on May 18, 2022

    @SrGeneroso
    Contributor

    I like svelte being opinionated and forms are a fundamental part of web development so I'm eager to see some brilliant svelte magic around this topic.
    Is true HTML5 has some nice features by itself but it could be much better.

  17. fezproof commented on Jun 3, 2022

    @fezproof
    Contributor

    Was playing around with this a bit, and I feel the component is the way to go, as it allows you to use slot props!
    I mocked up a quick implementation and it allows you to do some cool stuff like this:

    <Form
      action="/todos"
      method="post"
      let:state
      on:complete={({ detail }) => {
        detail.form.reset();
      }}
    >
      <input
        class:submitting={state !== 'idle'}
        name="text"
        aria-label="Add todo"
        placeholder="+ tap to add a todo"
      />
    </Form>

    The lack of classing is not really an issue to me, it just generally results in an extra div 🤷

    Here is a basic implementation I did in an example project (I am still ignoring a lot of details, including CSRF). It works surprisingly well for the basic use case. I will be improving it and testing more scenarios over time. I will post more findings if I have any.

  18. fezproof commented on Jun 4, 2022

    @fezproof
    Contributor

    Something I have noticed while trying to improve my implementation is that invalidate does not auto cancel duplicate calls. This can lead to very confusing and inconsistent behaviour.

    It can be even seen now in the current TODOs example when deleting a bunch of records at once. I even accidentally deleted a todo once due to the the page shifting because of this.

    Should invalidate cancel duplicate requests automagically?

  19. NoelJacob commented on Jun 12, 2022

    @NoelJacob

    I chose using <form use:xxx> over <svelte:form> over <Form>. Because it would still work with css libs which apply on <form>.

    Also it would be nice if <svelte:form> and other <svelte:xxx> got the css applied to <xxx> and <form>. It might seem like this css selection might confuse developers but, if they are using the <svelte:xxx>, it means they've read docs or come across the feature online, where they'll probably be warned of this. Just a suggestion.

  20. Mlocik97 commented on Jun 26, 2022

    @Mlocik97
    Contributor

    Can we for now expose existing enhance action, so instead of having it in $lib/ we could be able to import it from @sveltejs/kit/enhance or something like that?

  21. ulvidamirli commented on Jul 23, 2022

    @ulvidamirli

    Is there any way to use await block when the request is pending with use:enhance when it is client-side rendering?

    For example:

    <script>
       export let data;
    <script/>
    
    {#await data} 
       some flashing cards visual 
    {:then data} 
       cards filled with data 
    {/await}
    
  22. abdo643-HULK commented on Jul 28, 2022

    @abdo643-HULK

    Is there any way to use await block when the request is pending with use:enhance when it is client-side rendering?

    For example:

    <script>
       export let data;
    <script/>
    
    {#await data} 
       some flashing cards visual 
    {:then data} 
       cards filled with data 
    {/await}
    

    You can do that by creating a promise in the pending function and resolving that Promise in the result. I made a example that uses a similar Pattern.
    https://svelte.dev/repl/26fc3f6e57ad4223ae53f95b30fe4f34?version=3.49.0

  23. added
    size:largesignificant feature with tricky design questions and multi-day implementation
    on Aug 1, 2022
  24. bato3 commented on Aug 13, 2022

    @bato3

    CSRF

    Instead one complex solution is better provide small parts, with the help of which users can compose the desired solution.
    My proposal

    OT: URL for CSRF in first post is broken (remove 3533# from it)

  25. Mlocik97 commented on Aug 13, 2022

    @Mlocik97
    Contributor

    @bato3 would it work without JS?

  26. lukaszpolowczyk commented on Aug 13, 2022

    @lukaszpolowczyk
    Contributor

    I must note my suggestion Targeted Slots - sveltejs/rfcs#68
    Targeted Slots would be ideal for the problem in this thread.

    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';
    </script>
    <Form
      on:pending={(e) => console.log(e.detail /* => data */)}
      on:result={(e) => console.log(e.detail /* => response */)}
      on:error={(e) => console.log(e.detail /* => Error handler */)}
    >
      <svelte:element slot="form"
        action="/api/test?_method=PUT" // manually method overrides
        method="post"
        class="flex flex-column p-4 mt-2"
        id="nativeFormELement"
      >
        <input name="description" aria-label="Add todo" placeholder="+ tap to add a todo">
      </svelte:element>
    </Form>

    On the surface it looks usual, the magic of the proposal happens inside - in Form with Targeted Slots syntax.
    Syntax description - sveltejs/rfcs#68

    Simplified Form:

    <script>
      let form;
    </script>
    <svelte:element targeted:form this="form" bind:this={form}/>
    

    Where Form, does with the binded form, whatever it wants. Also with its children, like <input/>.

    To understand how this is supposed to work, read the Targeted Slots syntax.
    It also has more capabilities.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    breaking changesize:largesignificant feature with tricky design questions and multi-day implementation

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions