Controlled fields

By default, all form fields are uncontrolled because that's the default behavior of the browser. For a simple login or contact form this is quite sufficient.

Why controlled?

As soon as your forms become more complex, for example you set initial values or change the values of a form field via setInput, it becomes necessary that you control your fields yourself. For example, depending on which HTML form field you use, you may need to set the value, checked or selected attributes.

Text input example

For a text input field you simply add the value attribute and pass the value of the field:

<Field of={loginForm} path={['firstName']}>
  {#snippet children(field)}
    <input {...field.props} type="text" value={field.input} />
  {/snippet}
</Field>

Exception for files

The HTML <input type="file" /> element is an exception because it cannot be controlled. However, you have the possibility to control the UI around it. For inspiration you can use the code of our FileInput component from our playground.

Numbers and dates

Fields defined with a v.number() or v.date() schema need extra steps to be controlled, because the <input /> element natively understands only strings as value.

Number input example

An <input type="number" /> only reads and writes strings. So if you spread field.props onto it, field.input holds a string like "123", even though it is typed as number. To store a real number, override the input handler with one that converts the value via valueAsNumber and forwards it to field.onInput.

You also have to handle NaN. While typing a floating point number, the value can briefly be NaN, for example right after typing 1.. If you don't catch this for the displayed value, the input is cleared. It is best to encapsulate both in a separate component as described in the input components guide.

<script lang="ts">
  import type { FieldElementProps } from '@formisch/svelte';

  interface NumberInputProps extends Omit<FieldElementProps, 'oninput'> {
    type: 'number';
    label?: string;
    placeholder?: string;
    input: number | undefined;
    errors: [string, ...string[]] | null;
    required?: boolean;
    oninput: (value: number | undefined) => void;
  }

  let {
    name,
    type = 'number',
    label,
    placeholder,
    input,
    errors,
    required,
    onfocus,
    oninput,
    onchange,
    onblur,
  } = $props<NumberInputProps>();

  // Update value if it is not `NaN`
  const getValue = () => (!Number.isNaN(input) ? input : undefined);
</script>

<div>
  {#if label}
    <label for={name}>{label}</label>
  {/if}
  <input
    {name}
    {type}
    {placeholder}
    id={name}
    value={getValue()}
    aria-invalid={!!errors}
    aria-errormessage={`${name}-error`}
    {onfocus}
    oninput={(event) =>
      oninput(
        event.currentTarget.value === ''
          ? undefined
          : event.currentTarget.valueAsNumber
      )}
    {onchange}
    {onblur}
  />
  {#if errors}
    <div id={`${name}-error`}>{errors[0]}</div>
  {/if}
</div>

Pass field.onInput after spreading field.props so it overrides the native handler.

<Field of={form} path={['age']}>
  {#snippet children(field)}
    <NumberInput
      {...field.props}
      type="number"
      label="Age"
      input={field.input}
      errors={field.errors}
      oninput={field.onInput}
    />
  {/snippet}
</Field>

Date input example

The same applies to dates. An <input type="date" /> works with yyyy-mm-dd strings, while you usually want to store a Date. So you convert in both directions: format the Date for display, and parse the entered string back with valueAsDate before storing it.

<script lang="ts">
  import type { FieldElementProps } from '@formisch/svelte';

  interface DateInputProps extends Omit<FieldElementProps, 'oninput'> {
    type: 'date';
    label?: string;
    placeholder?: string;
    input: Date | undefined;
    errors: [string, ...string[]] | null;
    required?: boolean;
    oninput: (value: Date | undefined) => void;
  }

  let {
    name,
    type = 'date',
    label,
    placeholder,
    input,
    errors,
    required,
    onfocus,
    oninput,
    onchange,
    onblur,
  } = $props<DateInputProps>();

  // Transform date to string
  const getValue = () =>
    input && !Number.isNaN(input.getTime())
      ? input.toISOString().split('T', 1)[0]
      : '';
</script>

<div>
  {#if label}
    <label for={name}>{label}</label>
  {/if}
  <input
    {name}
    {type}
    {placeholder}
    id={name}
    value={getValue()}
    aria-invalid={!!errors}
    aria-errormessage={`${name}-error`}
    {onfocus}
    oninput={(event) => oninput(event.currentTarget.valueAsDate ?? undefined)}
    {onchange}
    {onblur}
  />
  {#if errors}
    <div id={`${name}-error`}>{errors[0]}</div>
  {/if}
</div>

As with the number input, pass field.onInput after the spread to override the native handler.

<Field of={form} path={['birthday']}>
  {#snippet children(field)}
    <DateInput
      {...field.props}
      type="date"
      label="Birthday"
      input={field.input}
      errors={field.errors}
      oninput={field.onInput}
    />
  {/snippet}
</Field>

Custom inputs and component libraries

Some component libraries don't expose the underlying native HTML element, which means you cannot spread field.props onto them. For these cases, use field.onInput to set the value programmatically.

<script>
  import { DatePicker } from 'some-component-library';
</script>

<Field of={form} path={['date']}>
  {#snippet children(field)}
    <DatePicker
      value={field.input}
      onChange={(newDate) => field.onInput(newDate)}
    />
  {/snippet}
</Field>

This is useful for:

  • Component libraries that wrap native elements without exposing them
  • Complex custom inputs like date pickers, rich text editors, or color pickers

The field.onInput method updates the field value and triggers validation, just like a native input would.

Next steps

Now that you understand controlled fields, you can explore more advanced topics like nested fields and field arrays to handle complex form structures.

Contributors

Thanks to all the contributors who helped make this page better!

  • GitHub profile picture of @fabian-hiller

Partners

Thanks to our partners who support the project ideally and financially.

Sponsors

Thanks to our GitHub sponsors who support the project financially.

  • GitHub profile picture of @vasilii-kovalev
  • GitHub profile picture of @UpwayShop
  • GitHub profile picture of @ruiaraujo012
  • GitHub profile picture of @hyunbinseo
  • GitHub profile picture of @nickytonline
  • GitHub profile picture of @kibertoad
  • GitHub profile picture of @caegdeveloper
  • GitHub profile picture of @Thanaen
  • GitHub profile picture of @bmoyroud
  • GitHub profile picture of @ysknsid25
  • GitHub profile picture of @dslatkin