Migrate from Felte

This guide walks you through migrating a form from Felte (@felte/solid) to Formisch. It compares the mental models of both libraries, shows the same form implemented in each, and maps Felte's APIs, helpers and stores to their Formisch equivalents.

Migrating is a rewrite of your form layer, not a drop-in replacement. Felte attaches its behavior to a native <form> element through a directive, while Formisch starts from a Valibot schema and builds the fields around it. The good news: your input elements, styling and business logic carry over unchanged, and the rewrite is mostly mechanical.

Both libraries can coexist in the same application without conflicts. You can migrate one form at a time and remove Felte once the last form is converted.

Key differences

Felte's core idea is to stay close to the platform. You call createForm, spread the returned form action onto a <form> element via use:form, and Felte picks up every input by its name attribute. Validation is added separately, either as a validate function or through a validator adapter package passed to extend, and form state is exposed as Solid accessors like data, errors and isSubmitting returned by createForm.

Formisch is schema-first. A single Valibot schema provides everything at once: the TypeScript types, the runtime validation rules, and the structure of the form. There is no separate validate function, no validator adapter, and no generic type parameter to declare. When the schema changes, every field path, every validation message and every inferred type follows automatically.

The main differences in practice:

  • Field registration: Felte discovers fields through their name attribute. Formisch connects fields explicitly with the Field component and a type-safe path array.
  • Validation location: Felte configures validation on the form (validate, warn, extend). Formisch defines validation in the Valibot schema, next to the types it produces.
  • Validation timing: Felte validates fields it considers touched as you type and validates everything on submit. Formisch gives you explicit validate and revalidate config options that control when validation first runs and when it re-runs.
  • State access: Felte returns accessors from createForm that you call as functions, like isSubmitting(). Formisch exposes reactive properties on the form store, like form.isSubmitting, and per-field state on the field store, like field.errors.
  • Methods: Felte's helpers are destructured from createForm. Formisch's methods are standalone imports like reset and setInput that take the form store as their first argument, so unused methods are tree-shaken from your bundle.

Side-by-side example

The following login form has two validated fields and an async submit handler, implemented once with Felte and once with Formisch.

Felte

import { createForm } from '@felte/solid';

interface LoginValues {
  email: string;
  password: string;
}

export default function LoginPage() {
  const { form, errors, isSubmitting } = createForm<LoginValues>({
    initialValues: { email: '', password: '' },
    validate(values) {
      const errors: Partial<LoginValues> = {};
      if (!values.email) {
        errors.email = 'Please enter your email.';
      } else if (!/^\S+@\S+\.\S+$/.test(values.email)) {
        errors.email = 'The email address is badly formatted.';
      }
      if (!values.password) {
        errors.password = 'Please enter your password.';
      } else if (values.password.length < 8) {
        errors.password = 'Your password must have 8 characters or more.';
      }
      return errors;
    },
    async onSubmit(values) {
      await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(values),
      });
    },
  });

  return (
    <form use:form>
      <input name="email" type="email" />
      {errors('email') && <div>{errors('email')?.[0]}</div>}
      <input name="password" type="password" />
      {errors('password') && <div>{errors('password')?.[0]}</div>}
      <button type="submit" disabled={isSubmitting()}>
        Login
      </button>
    </form>
  );
}

In TypeScript, this snippet assumes the ambient JSX.Directives declaration for use:form that Felte projects already include.

Formisch

import { createForm, Field, Form } from '@formisch/solid';
import type { SubmitHandler } from '@formisch/solid';
import * as v from 'valibot';

const LoginSchema = v.object({
  email: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your email.'),
    v.email('The email address is badly formatted.')
  ),
  password: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your password.'),
    v.minLength(8, 'Your password must have 8 characters or more.')
  ),
});

export default function LoginPage() {
  const loginForm = createForm({
    schema: LoginSchema,
  });

  const submitForm: SubmitHandler<typeof LoginSchema> = async (values) => {
    await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    });
  };

  return (
    <Form of={loginForm} onSubmit={submitForm}>
      <Field of={loginForm} path={['email']}>
        {(field) => (
          <div>
            <input {...field.props} value={field.input} type="email" />
            {field.errors && <div>{field.errors[0]}</div>}
          </div>
        )}
      </Field>
      <Field of={loginForm} path={['password']}>
        {(field) => (
          <div>
            <input {...field.props} value={field.input} type="password" />
            {field.errors && <div>{field.errors[0]}</div>}
          </div>
        )}
      </Field>
      <button type="submit" disabled={loginForm.isSubmitting}>
        Login
      </button>
    </Form>
  );
}

Note that the manual LoginValues interface and the hand-written validate function are gone. The schema provides both, and the types flow from it into path, field.input and the values of your submit handler.

Migration steps

Install Formisch

Formisch requires Valibot as a peer dependency. Install both packages alongside Felte, so you can migrate form by form:

npm install @formisch/solid valibot

See the installation guide for other package managers and TypeScript requirements.

Move validation into a Valibot schema

Translate your Felte validation into a Valibot schema. If you used a validator adapter like @felte/validator-zod, this is a rule-by-rule translation. If you used a hand-written validate function, each if branch becomes a schema action with its error message:

import * as v from 'valibot';

const LoginSchema = v.object({
  email: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your email.'),
    v.email('The email address is badly formatted.')
  ),
  password: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your password.'),
    v.minLength(8, 'Your password must have 8 characters or more.')
  ),
});

Custom checks that don't map to a built-in action can use v.check, and async rules (which you may have placed in Felte's debounced.validate) can use v.checkAsync. Define the fields in the same order they appear in your form, since Formisch focuses the first invalid field on submit based on the schema order. See the define your form guide for details.

Replace the form setup

Replace Felte's destructured createForm result with a single form store, and replace the use:form directive with the Form component. Felte's initialValues option becomes initialInput, which is optional because required string fields start as an empty string by default:

import { createForm, Form } from '@formisch/solid';

const loginForm = createForm({
  schema: LoginSchema,
});

<Form of={loginForm} onSubmit={(values) => console.log(values)}>
  {/* Fields will go here */}
  <button type="submit">Login</button>
</Form>;

There is no generic type parameter to pass. The createForm primitive infers all types from the schema.

Replace field bindings

Felte registers inputs through their name attribute. In Formisch, you wrap each input in a Field component and spread field.props onto it, which wires up the name attribute, event handlers and ref. Felte's dotted names become path arrays, so name="account.email" turns into path={['account', 'email']}:

<Field of={loginForm} path={['email']}>
  {(field) => (
    <div>
      <input {...field.props} value={field.input} type="email" />
      {field.errors && <div>{field.errors[0]}</div>}
    </div>
  )}
</Field>

If you used @felte/reporter-solid and its ValidationMessage component, you no longer need a reporter package. The field's errors are available directly as field.errors inside the render function. See the add form fields guide for more on the field store, and the input components guide for extracting this markup into reusable components.

Update submission handling

Felte's onSubmit, onSuccess and onError config options collapse into a single submit handler passed to the onSubmit property of the Form component. It only runs after successful validation and receives the fully typed output of your schema. What Felte split into onSuccess and onError becomes regular control flow inside your async handler:

const submitForm: SubmitHandler<typeof LoginSchema> = async (values) => {
  try {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    });
    if (!response.ok) {
      // Felte: onError
    }
    // Felte: onSuccess
  } catch {
    // Felte: onError
  }
};

Note that Formisch does not replicate Felte's default submit handler, which posts to the form's action attribute when no onSubmit is given. In Formisch, you always write the request yourself. See the handle submission guide for details.

API mapping

Felte (@felte/solid)Formisch (@formisch/solid)
createForm({ onSubmit, … })createForm + onSubmit on Form
use:form directiveForm component
<input name="email" />Field component with path={['email']}
initialValuesinitialInput config option
extend: validator({ schema })schema config option (Valibot)
validate config functionValibot schema actions, v.check
debounced.validateAsync schema actions, v.checkAsync
warn config / warningsNo equivalent
data('email')field.input / getInput
errors('email')field.errors / getErrors
<ValidationMessage> (reporter)field.errors in the render function
touched('email')field.isTouched / isTouched
interacted()No equivalent (field.isEdited covers most cases)
isValid()form.isValid
isSubmitting()form.isSubmitting
isValidating()form.isValidating
isDirty()form.isDirty
setFields(path, value) / setDatasetInput
setErrors(path, errors)setErrors
setTouched(path, true)Not needed (touched state is managed automatically)
validate() helpervalidate
reset() / resetField(path)reset
setInitialValues(values)reset with initialInput
addField(path, value, index)insert
unsetField(path)remove
moveField(path, from, to)move
swapFields(path, a, b)swap
createSubmitHandler(config)handleSubmit
onSuccess / onErrorControl flow inside your async submit handler

A few entries have no direct equivalent. Felte's warn and warnings produce non-blocking messages next to errors; Formisch only tracks errors, so warnings need to be modeled outside the form state. The debounced config has no counterpart because Formisch controls validation frequency through the validate and revalidate config instead of debouncing individual functions. And interacted, which reports the name of the last field the user interacted with, has no equivalent, although the per-field isEdited flag covers the common use cases.

Common patterns

Form state

Felte returns Solid accessors from createForm that you call as functions. In Formisch, form-level state consists of reactive properties on the form store, and values are read with the getInput method, which is reactive as well:

import { createForm } from '@felte/solid';

const { data, isValid, isSubmitting } = createForm<LoginValues>({
  /* … */
});
data('email'); // string
isValid(); // boolean
import { createForm, getInput } from '@formisch/solid';

const loginForm = createForm({ schema: LoginSchema });
getInput(loginForm, { path: ['email'] }); // string | undefined
loginForm.isValid; // boolean

One difference to Felte's data, which is typed after your initialValues: Formisch input values are partial because not every field may have been set, so getInput returns string | undefined here.

Field-level state like field.input, field.errors and field.isTouched is available on the field store inside Field or via the useField primitive.

Validation timing

Felte validates the fields it considers touched as you fill the form and validates everything on submit, so errors for a field typically appear once you interact with it. Formisch makes this timing explicit: validate controls when a field is first validated (default 'submit') and revalidate controls when it is checked again (default 'input'). To get behavior close to Felte's, validate on blur and revalidate on input:

const loginForm = createForm({
  schema: LoginSchema,
  validate: 'blur',
  revalidate: 'input',
});

Also note that Formisch populates field.errors for every invalid field as soon as validation runs, without filtering by touched state. You decide when to render them, for example only after the user edited the field or attempted to submit:

{
  (field.isEdited || loginForm.isSubmitted) && field.errors && (
    <div>{field.errors[0]}</div>
  );
}

The validation guide covers these options and patterns in depth.

Field arrays

In Felte, arrays are driven by indexed name attributes like interests.0.value together with the addField and unsetField helpers. In Formisch, the schema declares the array with v.array, and the FieldArray component provides stable items keys for SolidJS's <For> component. Items are added and removed with the insert and remove methods:

import { Field, FieldArray, insert, remove } from '@formisch/solid';
import { For } from 'solid-js';

<FieldArray of={interestsForm} path={['interests']}>
  {(fieldArray) => (
    <For each={fieldArray.items}>
      {(_, getIndex) => (
        <div>
          <Field of={interestsForm} path={['interests', getIndex(), 'value']}>
            {(field) => (
              <input {...field.props} value={field.input} type="text" />
            )}
          </Field>
          <button
            type="button"
            onClick={() =>
              remove(interestsForm, { path: ['interests'], at: getIndex() })
            }
          >
            Remove
          </button>
        </div>
      )}
    </For>
  )}
</FieldArray>;

<button
  type="button"
  onClick={() =>
    insert(interestsForm, { path: ['interests'], initialInput: { value: '' } })
  }
>
  Add interest
</button>;

Felte's moveField and swapFields map to the move and swap methods. See the field arrays guide for a complete example including reordering, nesting and array-level validation.

Reset

Felte's reset helper restores the initial values, and setInitialValues updates the baseline used by the next reset. Formisch combines both into the reset method, which can also reset a single field via path:

import { reset } from '@formisch/solid';

// Reset the entire form to its initial input
reset(loginForm);

// Reset a single field (Felte: resetField('email'))
reset(loginForm, { path: ['email'] });

// Update the initial input and reset (Felte: setInitialValues + reset)
reset(loginForm, { initialInput: { email: 'new@example.com' } });

Special inputs

Felte reads checkboxes, radio buttons, selects and file inputs automatically from the DOM based on their name attribute. Formisch supports all of these too, but since fields are connected through field.props, you set the appropriate controlled attribute yourself, like checked for checkboxes:

<Field of={form} path={['cookies']}>
  {(field) => (
    <label>
      <input {...field.props} type="checkbox" checked={!!field.input} />
      Yes, I want cookies
    </label>
  )}
</Field>

The special inputs guide shows the equivalent patterns for radio groups, single and multiple selects, and file inputs.

Next steps

With your first form migrated, work through the define your form, add form fields and handle submission guides to deepen the concepts this guide touched on. The controlled fields and form methods guides are useful next reads once your migrated forms grow more dynamic.

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