Skip to content

Framework Integration

Problem

Implement framework integration in a production-friendly way with @vielzeug/formit while keeping setup and cleanup explicit.

Runnable Example

The snippet below is copy-paste runnable in a TypeScript project with @vielzeug/formit installed.

Complete examples showing how to integrate Formit with different frameworks using custom hooks/composables for reusability.

tsx
import { useEffect, useState } from 'react';
import { createForm, type FormOptions } from '@vielzeug/formit';

function useForm(init: FormOptions) {
  const [form] = useState(() => createForm(init));
  const [state, setState] = useState(form.state);

  useEffect(() => form.subscribe(setState), [form]);

  return { form, state };
}

// Usage in component
function LoginForm() {
  const { form, state } = useForm({
    defaultValues: {
      email: '',
      password: '',
    },
    validators: {
      email: (v) => (v && !String(v).includes('@') ? 'Invalid email' : undefined),
      password: (v) => (String(v).length < 8 ? 'Min 8 characters' : undefined),
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.submit(async (values) => {
          const response = await fetch('/api/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(values),
          });
          return response.json();
        });
      }}>
      <div>
        <input {...form.bind('email')} type="email" placeholder="Email" />
        {state.errors['email'] && <span className="error">{state.errors['email']}</span>}
      </div>

      <div>
        <input {...form.bind('password')} type="password" placeholder="Password" />
        {state.errors['password'] && <span className="error">{state.errors['password']}</span>}
      </div>

      <button type="submit" disabled={state.isSubmitting}>
        {state.isSubmitting ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}
typescript
// composables/useForm.ts
import { ref, onMounted, onUnmounted } from 'vue';
import { createForm, type FormOptions } from '@vielzeug/formit';

export function useForm(init: FormOptions) {
  const form = createForm(init);
  const state = ref(form.state);
  let unsubscribe;

  onMounted(() => (unsubscribe = form.subscribe((s) => (state.value = s))));
  onUnmounted(() => unsubscribe?.());

  return { form, state };
}
vue
<!-- LoginForm.vue -->
<script setup>
import { useForm } from '@/composables/useForm';

const { form, state } = useForm({
  defaultValues: {
    email: '',
    password: '',
  },
  validators: {
    email: (v) => (v && !String(v).includes('@') ? 'Invalid email' : undefined),
    password: (v) => (String(v).length < 8 ? 'Min 8 characters' : undefined),
  },
});

const handleSubmit = async () => {
  await form.submit(async (values) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    });
    return response.json();
  });
};
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <input v-bind="form.bind('email')" type="email" placeholder="Email" />
      <span v-if="state.errors['email']" class="error">
        {{ state.errors['email'] }}
      </span>
    </div>

    <div>
      <input v-bind="form.bind('password')" type="password" placeholder="Password" />
      <span v-if="state.errors['password']" class="error">
        {{ state.errors['password'] }}
      </span>
    </div>

    <button type="submit" :disabled="state.isSubmitting">
      {{ state.isSubmitting ? 'Logging in...' : 'Login' }}
    </button>
  </form>
</template>
svelte
<script lang="ts">
import { createForm } from '@vielzeug/formit';
import { writable } from 'svelte/store';
import { onMount, onDestroy } from 'svelte';

const form = createForm({
  defaultValues: {
    email: '',
    password: '',
  },
  validators: {
    email: (v) => (v && !String(v).includes('@') ? 'Invalid email' : undefined),
    password: (v) => (String(v).length < 8 ? 'Min 8 characters' : undefined),
  },
});

const state = writable(form.state);
let unsubscribe;

onMount(() => (unsubscribe = form.subscribe((s) => state.set(s))));
onDestroy(() => unsubscribe?.());

async function handleSubmit() {
  await form.submit(async (values) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values),
    });
    return response.json();
  });
}
</script>

<form on:submit|preventDefault={handleSubmit}>
  <div>
    <input {...form.bind('email')} type="email" placeholder="Email" />
    {#if $state.errors['email']}
      <span class="error">{$state.errors['email']}</span>
    {/if}
  </div>

  <div>
    <input {...form.bind('password')} type="password" placeholder="Password" />
    {#if $state.errors['password']}
      <span class="error">{$state.errors['password']}</span>
    {/if}
  </div>

  <button type="submit" disabled={$state.isSubmitting}>
    {$state.isSubmitting ? 'Logging in...' : 'Login'}
  </button>
</form>

Expected Output

  • The example runs without type errors in a standard TypeScript setup.
  • The main flow produces the behavior described in the recipe title.

Common Pitfalls

  • Forgetting cleanup/dispose calls can leak listeners or stale state.
  • Skipping explicit typing can hide integration issues until runtime.
  • Not handling error branches makes examples harder to adapt safely.