Skip to content

Support generic form field components with remote forms #15110

@ixxie

Description

@ixxie

Describe the problem

When implementing a production app, its common to develop generic form field components that pass through props into the input. These facilitate shared styling and logic, for example reveal logic. In an ideal world they support bindings well.

Maybe I'm stupid, but I've struggled a month or two to figure out the right way to do this, and I'm still not confident about my solution. One of the pain points has been handling hidden inputs, which seem to be a special case. When I tried using binding on them I kept getting errors that the value cannot be undefined even though I tried to initialize them with a value.

Describe the proposed solution

I put 'support' in the title because I don't know for sure what the solution is. I understand this is a userland problem, but I believe its common enough to warrant some guidance.

My reasoning:

  • If there a simple solution, it would be nice to have an example in the docs.
  • If this is too complex, then one must wonder if the API needs to be improved to support the usecase.
  • If this in turn is also too complex, then some kind of warning is in order in the docs, explaining that generic wrappers are hard, and sketching out the tradeoffs involved.

Alternatives considered

@sillvva shared this approach which involves a lot of manual fiddling with types.

Below is the approach I am taking, which is probably a bit "dumb", but I wanted something I can more or less grok. Mostly it was trial an error to get something that functions acceptably, but I'm still not entirely happy with it. I deliberately all the logic I use in my actual app, because I want to show the kind of constraints I face.

// FormField.svelte
<script lang="ts">
  import type { RemoteFormIssue } from '@sveltejs/kit';

  import { tick, untrack } from 'svelte';

  import { useForm } from '$lib/hooks/useForm.svelte';
  import { useFormSection } from '$lib/hooks/useFormSection.svelte';
  import type { FormFieldProps } from '$lib/types/form.types';
  import { formSectionTransition } from '$lib/utils/form';

  let {
    label,
    name = label.toLowerCase().trim().replaceAll(' ', '_'),
    info,
    value = $bindable(),
    type = 'text',
    required = false,
    autofocus = false,
    children,
    hidden = false,
    action,
    deps,
    ...rest
  }: FormFieldProps = $props();

  const form = useForm();

  $effect(() => {
    if (form.autofocus && autofocus) {
      throw new Error(
        `FormField "${label}": Cannot enable autofocus on both Form and FormField. Choose one.`,
      );
    }
  });

  const field = $derived(form.remote.fields?.[name]);
  const issues: RemoteFormIssue[] = $derived(field?.issues() ?? []);

  const hide = $derived(hidden || !!children);
  const attributes = $derived(field?.as(type) ?? { name, type });

  const section = useFormSection(deps ? { state: () => ({ deps }) } : {});

  // register with section for reset tracking
  if (section) {
    $effect(() => {
      section.register(name);
      return () => {
        section.unregister(name);
        // clear form value when field unmounts (e.g., parent {#if} becomes false)
        field?.set(undefined);
      };
    });
  }

  // revealed: if in section, use section.revealed; otherwise always revealed
  const revealed = $derived(section?.revealed ?? true);

  // capture initial value for reset (before any reactive updates)
  const initialValue = untrack(() => value);

  // reset to initial value when section triggers a clear
  let prevVersion = section?.version ?? 0;
  $effect(() => {
    if (section && section.version !== prevVersion) {
      value = initialValue;
      prevVersion = section.version;
    }
  });

  let input: HTMLInputElement | null = $state(null);

  // sync local value to RemoteForm when revealed
  $effect(() => {
    if (revealed) {
      field?.set(value);
    }
  });

  // autofocus
  $effect(() => {
    if ((form.autofocus || autofocus) && !hide) {
      if (revealed) {
        tick().then(() => {
          input?.focus();
        });
      }
    }
  });

  const transition = formSectionTransition;
</script>

{#if revealed}
  <div class="form-field" class:hidden transition:transition>
    <label for={name}>
      {label}
      {#if required}
        <span class="required">*</span>
      {/if}
    </label>
    <div class="input-wrapper">
      {#if hide}
        <input type="hidden" {name} {value} />
      {:else}
        <input bind:this={input} {...attributes} {...rest} bind:value />
      {/if}
      {@render action?.()}
    </div>
    {@render children?.()}
    <div class="details">
      {#if issues.length > 0}
        {#each issues as issue, i (i)}
          <p>{issue.message}</p>
        {/each}
      {:else if info}
        <p>{info}</p>
      {/if}
    </div>
  </div>
{/if}

<style>
  /* ... */
</style>

Importance

would make my life easier

Additional Information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions