Migrate from FormKit

This guide walks you through migrating your Vue forms from FormKit (@formkit/vue) to Formisch. It covers the differences in mental model, shows the same form in both libraries side by side, and maps FormKit's APIs to their Formisch equivalents.

Migrating is a rewrite of your form layer, not a drop-in replacement. FormKit is component-driven and renders your inputs for you, while Formisch is headless and only provides the data layer. The migration therefore replaces both the form logic and the input markup that FormKit generated for you.

The good news: both libraries can coexist in the same application. Formisch does not require an app-level plugin, so you can leave FormKit's plugin registered in main.ts and migrate one form at a time. Once the last FormKit form is gone, remove the plugin and the dependency.

Key differences

FormKit is a batteries-included form framework. A single globally registered <FormKit> component renders the entire input scaffolding, including the label, help text, validation messages, and styling. The form's structure emerges at runtime from the name props of the inputs nested inside <FormKit type="form">, validation rules are co-located with each input as string props like validation="required|email", and TypeScript types for the submitted data are declared manually (or derived via the optional Zod plugin).

Formisch flips this around with a schema-first approach. A single Valibot schema is the form: it defines the structure, the TypeScript types, and all validation rules at once. The useForm composable turns the schema into a reactive form store, and headless components like Field connect your own markup to it via type-safe path arrays. When the schema changes, every path and every inferred type follows at compile time.

The main differences at a glance:

  • UI ownership: FormKit renders inputs, labels, and messages for you. Formisch renders nothing; you write the markup (or your own reusable input components) and get full control over it.
  • Source of truth: In FormKit, structure lives in the template, validation lives on each input, and types live in your TypeScript code. In Formisch, all three live in one Valibot schema.
  • Validation location and timing: FormKit validates each input against its validation prop and controls message display per input via validation-visibility. Formisch validates the whole form against the schema and controls timing form-wide via the validate and revalidate config. When errors are shown is up to your markup.
  • Reactivity: FormKit keeps state in its own framework-agnostic core node tree that you access through getNode, node contexts, or slot props. Formisch stores state directly in Vue's reactivity system, so you read form.isSubmitting or field.errors like any other reactive value.
  • Bundle size: FormKit starts at roughly 25 kB (min+gzip). Formisch starts at about 2.5 kB and only grows with the methods you import, since it is fully tree-shakable.

Side-by-side example

The following login form has two required fields with validation messages and an async submit handler, implemented once with each library.

FormKit

<script setup lang="ts">
// The <FormKit> component is registered globally via
// app.use(plugin, defaultConfig) in main.ts
const handleSubmit = async (data: { email: string; password: string }) => {
  console.log(data);
};
</script>

<template>
  <FormKit type="form" submit-label="Login" @submit="handleSubmit">
    <FormKit
      type="email"
      name="email"
      label="Email"
      validation="required|email"
      :validation-messages="{
        required: 'Please enter your email.',
        email: 'The email address is badly formatted.',
      }"
    />
    <FormKit
      type="password"
      name="password"
      label="Password"
      validation="required|length:8"
      :validation-messages="{
        required: 'Please enter your password.',
        length: 'Your password must have 8 characters or more.',
      }"
    />
  </FormKit>
</template>

Formisch

<script setup lang="ts">
import { Field, Form, useForm } from '@formisch/vue';
import type { SubmitHandler } from '@formisch/vue';
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.')
  ),
});

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

const handleSubmit: SubmitHandler<typeof LoginSchema> = async (values) => {
  console.log(values); // { email: string, password: string }
};
</script>

<template>
  <Form :of="loginForm" @submit="handleSubmit">
    <Field :of="loginForm" :path="['email']" v-slot="field">
      <input v-bind="field.props" v-model="field.input" type="email" />
      <div v-if="field.errors">{{ field.errors[0] }}</div>
    </Field>
    <Field :of="loginForm" :path="['password']" v-slot="field">
      <input v-bind="field.props" v-model="field.input" type="password" />
      <div v-if="field.errors">{{ field.errors[0] }}</div>
    </Field>
    <button type="submit" :disabled="loginForm.isSubmitting">Login</button>
  </Form>
</template>

The Formisch version renders plain HTML instead of FormKit's prebuilt scaffolding, so the label, help text, and error markup that FormKit generated is now yours to write. In a real app you would move it into a reusable TextInput component, as shown in the input components guide. In return, the submit handler receives values that are validated and fully typed by the schema, without a manually declared type.

Migration steps

Install Formisch

Install Formisch and Valibot alongside FormKit. See the installation guide for details.

npm install @formisch/vue valibot

Since Formisch requires no app.use(...) call, nothing changes in main.ts yet. Keep FormKit's plugin registered until the last FormKit form is migrated.

Move validation into a Valibot schema

Collect the validation and :validation-messages props of each input in the form and translate them into one Valibot schema. The nesting of name props (including type="group" wrappers) becomes the nesting of the object schema, and each validation rule becomes a Valibot action with its message as the first argument.

import * as v from 'valibot';

const LoginSchema = v.object({
  // FormKit: validation="required|email"
  email: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your email.'),
    v.email('The email address is badly formatted.')
  ),
});

Common rule mappings: required becomes v.nonEmpty(...) for strings, email becomes v.email(...), length:8 becomes v.minLength(8, ...), between:1,10 becomes v.minValue(1, ...) and v.maxValue(10, ...), and custom rules become v.check(...) or 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 more details.

Replace the form setup

Replace <FormKit type="form"> with the useForm composable and the Form component. If your FormKit form was prefilled via the :value prop or v-model, pass that data as initialInput instead.

<script setup lang="ts">
import { Form, useForm } from '@formisch/vue';

const loginForm = useForm({
  schema: LoginSchema,
  initialInput: { email: 'jane@example.com' },
});
</script>

<template>
  <Form :of="loginForm" @submit="handleSubmit">
    <!-- Fields will go here -->
    <button type="submit">Login</button>
  </Form>
</template>

Note that FormKit automatically renders a submit button at the bottom of every form. Formisch does not, so add your own <button type="submit">.

Replace field bindings

Each <FormKit> input becomes a Field component wrapping your own markup. The name prop becomes a type-safe path array, and nested type="group" structures become longer paths like ['address', 'street']. The v-slot directive exposes the field store with props to spread onto the element, input for v-model, and errors for display.

<template>
  <Field :of="loginForm" :path="['email']" v-slot="field">
    <label :for="field.props.name">Email</label>
    <input
      v-bind="field.props"
      :id="field.props.name"
      v-model="field.input"
      type="email"
    />
    <div v-if="field.errors">{{ field.errors[0] }}</div>
  </Field>
</template>

For field components that encapsulate a single field, the useField composable provides the same field store in the script block. See the add form fields guide for both approaches.

Update submission handling

FormKit's @submit handler receives the collected form data and the form's core node, which is also used to set backend errors via node.setErrors(...). In Formisch, the @submit handler on the Form component receives the schema-validated output, typed by SubmitHandler, and backend errors are set with the setErrors method.

import { setErrors } from '@formisch/vue';
import type { SubmitHandler } from '@formisch/vue';

const handleSubmit: SubmitHandler<typeof LoginSchema> = async (values) => {
  const response = await login(values);
  if (!response.ok) {
    setErrors(loginForm, {
      path: ['email'],
      errors: ['This email is not registered.'],
    });
  }
};

Leaving out the path sets form-level errors, which you can display via loginForm.errors.

API mapping

FormKitFormisch
plugin + defaultConfigNot needed, just import from @formisch/vue
<FormKit type="form">useForm + Form
<FormKit name="…" />Field component or useField composable
<FormKit type="group">Nested object schema + path arrays
validation propValibot schema passed to useForm
:validation-messages propMessages in the schema actions
validation-visibility propvalidate / revalidate config + your markup
:value / v-model on the forminitialInput config
@submit@submit on Form
state.validform.isValid / field.isValid
state.dirtyform.isDirty / field.isDirty
state.loadingform.isSubmitting
state.submittedform.isSubmitted
node.setErrors()setErrors
reset()reset
submitForm()submit
getNode() + node.valuegetInput
<FormKit type="repeater"> / listFieldArray + insert, remove, …
<FormKitSchema>No equivalent
@formkit/zod pluginBuilt in, the Valibot schema is the form definition

A few entries have no direct equivalent. <FormKitSchema> generates entire forms from JSON, which has no counterpart in a headless library; with Formisch you write the template yourself. The same applies to FormKit's rendered UI scaffolding, themes, and icons: in Formisch you own the markup, so labels, help text, and message rendering move into your own input components. And where FormKit configures validation display per input, Formisch configures validation timing once per form and leaves error display to your template.

Common patterns

Form state

In FormKit, form state lives on the core node and is accessed through the form's slot props or via getNode('id').context.state:

<template>
  <FormKit
    type="form"
    :actions="false"
    @submit="handleSubmit"
    v-slot="{ state }"
  >
    <!-- Fields -->
    <FormKit type="submit" label="Login" :disabled="!state.valid" />
  </FormKit>
</template>

In Formisch, the form store returned by useForm is reactive, so you read state directly in script or template. The form store exposes isSubmitting, isSubmitted, isValidating, isTouched, isEdited, isDirty, isValid, and errors, and each field store offers per-field isTouched, isEdited, isDirty, and isValid counterparts.

<template>
  <Form :of="loginForm" @submit="handleSubmit">
    <!-- Fields -->
    <button type="submit" :disabled="loginForm.isSubmitting">
      {{ loginForm.isSubmitting ? 'Submitting...' : 'Login' }}
    </button>
  </Form>
</template>

Validation timing

FormKit validates continuously and uses validation-visibility (with values like blur, live, dirty, and submit) to decide per input when messages appear. Formisch splits this into two parts. The validate and revalidate config control when validation runs for the whole form:

const loginForm = useForm({
  schema: LoginSchema,
  validate: 'blur', // First validation on blur (default: 'submit')
  revalidate: 'input', // Revalidation on every keystroke (default: 'input')
});

When errors are displayed is decided in your markup. To approximate FormKit's default behavior of revealing messages once a field was interacted with, guard the error output with field state:

<Field :of="loginForm" :path="['email']" v-slot="field">
  <input v-bind="field.props" v-model="field.input" type="email" />
  <div v-if="(field.isEdited || loginForm.isSubmitted) && field.errors">
    {{ field.errors[0] }}
  </div>
</Field>

See the validation guide for the full picture, including async validation with isValidating.

Field arrays

FormKit handles repeating groups with the repeater input (a free Pro input from @formkit/pro that must be registered separately) or a dynamic list, and the repeater brings its own UI for adding, removing, and shifting items:

<template>
  <FormKit type="form" @submit="saveTeam">
    <FormKit type="repeater" name="members" label="Team members">
      <FormKit type="text" name="name" label="Name" validation="required" />
    </FormKit>
  </FormKit>
</template>

In Formisch, the array is part of the schema, the FieldArray component provides stable items keys for v-for, and methods like insert and remove mutate the array:

<script setup lang="ts">
import {
  Field,
  FieldArray,
  Form,
  insert,
  remove,
  useForm,
} from '@formisch/vue';
import * as v from 'valibot';

const TeamSchema = v.object({
  members: v.array(
    v.object({
      name: v.pipe(v.string(), v.nonEmpty('Please enter a name.')),
    })
  ),
});

const teamForm = useForm({
  schema: TeamSchema,
  initialInput: { members: [{ name: '' }] },
});
</script>

<template>
  <Form :of="teamForm" @submit="(output) => console.log(output)">
    <FieldArray :of="teamForm" :path="['members']" v-slot="fieldArray">
      <div v-for="(item, index) in fieldArray.items" :key="item">
        <Field :of="teamForm" :path="['members', index, 'name']" v-slot="field">
          <input v-bind="field.props" v-model="field.input" type="text" />
          <div v-if="field.errors">{{ field.errors[0] }}</div>
        </Field>
        <button
          type="button"
          @click="remove(teamForm, { path: ['members'], at: index })"
        >
          Remove
        </button>
      </div>
    </FieldArray>
    <button
      type="button"
      @click="
        insert(teamForm, { path: ['members'], initialInput: { name: '' } })
      "
    >
      Add member
    </button>
    <button type="submit">Save</button>
  </Form>
</template>

The move, swap, and replace methods cover reordering. See the field arrays guide for nested arrays and array-level validation.

Reset

FormKit resets a form through its id with the reset helper, for example reset('login-form') imported from @formkit/core. In Formisch, the reset method takes the form store directly:

<script setup lang="ts">
import { reset } from '@formisch/vue';
</script>

<template>
  <button type="button" @click="reset(loginForm)">Reset</button>
</template>

Like FormKit's optional second argument to reset, Formisch accepts new initial values. Calling reset(loginForm, { initialInput: ... }) updates the baseline that dirty tracking compares against, which is the right tool when refreshed server data should become the new initial state.

Special inputs

FormKit ships dedicated input types like select, checkbox, and radio that render their options from a prop:

<template>
  <FormKit
    type="select"
    name="framework"
    label="Framework"
    :options="{ vue: 'Vue', react: 'React' }"
  />
</template>

Formisch works with the native HTML elements instead. You render the options yourself and bind the field with v-bind and v-model:

<template>
  <Field :of="form" :path="['framework']" v-slot="field">
    <select v-bind="field.props" v-model="field.input">
      <option
        v-for="option in [
          { label: 'Vue', value: 'vue' },
          { label: 'React', value: 'react' },
        ]"
        :key="option.value"
        :value="option.value"
      >
        {{ option.label }}
      </option>
    </select>
  </Field>
</template>

Checkboxes, radio groups, and multi-selects follow the same pattern. File inputs are the exception: Vue does not allow v-model on them, so instead add an input handler that writes the selected file to field.input yourself. The special inputs guide covers each of them, including checkbox groups that represent an array of strings.

Next steps

With your first form migrated, work through the main concepts to deepen your understanding: define your form, add form fields, and handle submission. To rebuild the input scaffolding that FormKit provided, follow the input components guide, and for programmatic control over your forms, explore the form methods guide.

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