---
url: https://formhaus.dev/migrate/0.11.md
description: >-
  Upgrade from Formhaus 0.10 to 0.11: every renamed or removed API, prop, event
  and definition rule, with before and after code and a grep list.
---

# Migrate to 0.11

0.11 is the last breaking release before 1.0. Run the grep list below first, then work through the table. `checkDefinition()` reports every definition that 0.11 rejects, with a JSON pointer to the node.

```bash
npm i @formhaus/core@^0.11 @formhaus/react@^0.11
npm i @formhaus/core@^0.11 @formhaus/vue@^0.11
```

## Find usages

Search the code base for each pattern. Every hit needs a change from the table.

```bash
grep -rnE "nextStep\(|prevStep\(|skipStep\(|nextStepAsync|prevStepAsync|skipStepAsync|submitAsync|validateDefinition\(|isStepVisible|validateField\(field|shouldApplyExternalErrors|createCloudController|initialCloudState|focusProblem|form_abandoned|ActionsComponent|ProgressComponent|onStepChange|stepChange|step-change|submitHandler|submit-handler|update:value|\.direction|FormField\b|FieldComponentMap|engine\.(values|errors|currentStepIndex)\s*=|\"action\":|reveal_secret|notify_email" src
```

For definitions stored as JSON, run the check on each file:

```ts
import { checkDefinition } from '@formhaus/core';

const errors = checkDefinition(json).filter((issue) => issue.severity === 'error');
for (const issue of errors) console.log(issue.message);
```

## Changes

| Area | Old (0.10) | New (0.11) | Action |
|---|---|---|---|
| Install | `@formhaus/core` installed as a dependency of the adapter | `@formhaus/core` is a peer dependency | `npm i @formhaus/core @formhaus/react` or `@formhaus/vue` |
| Install | React `>=18.0.0` | `^18.0.0 \|\| ^19.0.0` | Use React 18 or 19 |
| Install | Vue `>=3.3.0` | `^3.3.3` | Upgrade Vue to 3.3.3 or later |
| Install | Node 18 | Node 20 for `@formhaus/core`, react and vue | Upgrade Node |
| Definition | `{ field }` without an operator, or with two | Exactly one operator | Keep one of `eq`, `neq`, `in`, `notIn`, `notEmpty`, `gt`, `gte`, `lt`, `lte` |
| Definition | Unknown condition keys such as `equals` matched always | Constructor throws | Rename to an operator |
| Definition | `evaluateCondition()` returned `true` without a known operator | Returns `false` | Give every condition one operator |
| Definition | `show`, `showAny`, route and `disabled` conditions saw all values without routes | They see the active projection: earlier steps on the path plus the same step | Move a referenced field to the same or an earlier step |
| Definition | Step condition on a field of the same step worked without routes | Constructor throws | Move the field to an earlier step |
| Definition | Condition or `matchField` on a key that is not a field: warning | Constructor throws | Add the field, or remove the condition |
| Definition | Same field `key` twice: warning | Constructor throws, unless the steps are route branches that never meet | Rename one field |
| Definition | Invalid `pattern`, condition cycle, `minLength` > `maxLength`, `min` > `max`, missing `submit`: warnings | Constructor throws | Fix the reported path |
| Definition | Missing `id`, step `id`, field `key` or `type`, wrong property types | Constructor throws | Fix the reported path |
| Definition | `FormAction.action` | Removed from the type and the JSON Schema | Delete `action` from actions |
| Definition | `NaN`, `Infinity` or `"5"` in a `number` field passed | "Enter a number" | Store numbers |
| Definition | Empty field with `matchField` passed | Fails when the matched field has a value | Add `required` to the confirmation field for "This field is required" |
| Definition | `engine.validate()` did not check `email`, dates, booleans or options | Same type checks as `validateSubmission()` | Store valid values |
| Definition | `setValue()` on a hidden field kept the value without routes | Cleared at once | Read values only while the field is visible |
| Definition | Validators and hooks got all values without routes | They get the active projection | Read other context from your own state |
| Checks | `validateDefinition(definition): string[]` | `checkDefinition(definition): DefinitionIssue[]` | `checkDefinition(d).filter((i) => i.severity === 'error')` |
| Checks | `checkDefinition()` from `/server` returned `{ errors, warnings }` | `DefinitionIssue[]` | Filter by `severity` |
| Checks | The `FormEngine` constructor threw `Error` with joined messages | `FormDefinitionError` with `issues` | `catch (e) { if (e instanceof FormDefinitionError) e.issues }` |
| Checks | `[FormEngine]` warnings on `console.warn` | `[formhaus]` warnings, or the `onWarning` option | Pass `onWarning` to collect them |
| Checks | `validateSubmission()` validated broken definitions | Throws `FormDefinitionError` | Run `checkDefinition()` before you store a definition |
| Engine | `engine.nextStep()`, `nextStepAsync()` | `await engine.next()` | Await it |
| Engine | `engine.prevStep()`, `prevStepAsync()` | `await engine.back()` | Await it |
| Engine | `engine.skipStep()`, `skipStepAsync(submit)` | `await engine.skip(submit)` | Await it |
| Engine | `engine.skip()` skipped steps without `skip` | Returns `false` | Add `skip: { label }` or call `next()` |
| Engine | `engine.submitAsync(handler)` | `engine.submit(handler)` | Rename |
| Engine | Actions moved even while another was pending: `next()` | Return `false` while an action is pending | Call `cancelPendingActions()` first |
| Engine | `engine.values.x = v`, assigning `values`, `errors`, `currentStepIndex` | Read-only getters | `setValue()`, `setErrors()` |
| Engine | `StepChangeContext.direction` | `reason` | `context.reason === 'back'` |
| Engine | `SubmitFn` with optional `skippedSteps` | Required `string[]` | Pass skipped step ids when you call it |
| Engine | `onStepValidate` and hooks with all values | Active projection | See the projection row |
| Engine | `validateField(field, value, values, validators)` standalone | `validateFieldValue()` | Rename. `engine.validateField(key)` is unchanged |
| Engine | `isStepVisible()` | `isVisible()` | Rename |
| Engine | `getDefaultMessage(rule: string)` | `getDefaultMessage(rule: ValidationRule)` | Pass a rule name |
| Engine | `shouldApplyExternalErrors` from `@formhaus/core` | From `@formhaus/core/internal` | Change the import |
| Cloud | `createCloudController`, `initialCloudState`, `focusProblem` from `/cloud` | From `@formhaus/core/internal` | Change the import, or use `FormhausForm` |
| Cloud | `CloudCallbacks`, `CloudState` types from `/cloud` | From `@formhaus/core/internal` | Change the import |
| Cloud | `@formhaus/core/cloud` loaded `GET /f/{id}/definition` | `GET /f/{id}/form?core=<version>` | Allow the path in proxies and CSP |
| Cloud | `FormhausForm` ignored the form's success message and redirect URL | Shows the message and opens the URL after a submission | Pass `success` (React) or the `#success` slot (Vue) to keep your own handling |
| Analytics | `form_abandoned` event type | Removed | Delete handlers |
| React | `ActionsComponent`, `ProgressComponent` | `actionsComponent`, `progressComponent` | Rename the props |
| React | `onStepChange(stepId, direction)` | `onAfterStepChange(context)` | Read `context.toStepId`, `context.reason` |
| React, Vue | `<h3>` step title | `<h2>`, `headingLevel` prop | Pass `headingLevel={3}` to keep `<h3>` |
| React, Vue | Engine recreated only when `definition.id` changed | Recreated when the `definition` object changes | Pass a stable object |
| React, Vue | `submit.disabled` read `engine.values` | Reads `getSubmitValues()` | Nothing, unless you relied on off-path values |
| React, Vue | Field ids `email` (React), `fh-field-email` (Vue) | `<form id>-<key>`, sanitized | Select by `name` or label |
| React, Vue | Option ids `<key>-<value>` | `<field id>-<index>` | Select options by label |
| React, Vue | Helper and error ids `<key>-helper` | `<field id>-helper`, `<field id>-error` | Read `aria-describedby` |
| Vue | `@submit` notification after `submitHandler` | `@submit` is the awaited handler, `(values, skippedSteps)` | Move `:submit-handler="save"` to `@submit="save"` |
| Vue | `stepChange` event | `onAfterStepChange` prop | Read `context.toStepId` |
| Vue | Custom field `value` prop and `update:value` event | `modelValue` and `update:modelValue` | Rename, `v-model` works |
| Vue | `FormField` component | `FormhausField` | Change the import |
| Vue | `FieldComponentMap` = `Record<FieldType, Component>` | `Partial<Record<FieldType, Component>>` | Nothing for maps you pass |
| Vue | `FormActions` `action` event | Removed | Delete `@action` |
| MCP | `validate_definition` returned `{ valid, errors, warnings }` | `{ valid, issues }` | Read `issues` |
| MCP | `simulate_path` `validation.errors` | `validation.issues` | Read `issues` |
| MCP | `update_form_settings` with `reveal_secret` | `reveal_webhook_secret` tool | Call the new tool |
| MCP | `update_form_settings` with `notify_email` | `notify_mode` | `"milestones"` or `"off"` |

## Definitions

### One operator per condition

Before:

```json
{ "key": "detail", "type": "text", "label": "Detail", "show": [{ "field": "kind" }] }
```

After:

```json
{ "key": "detail", "type": "text", "label": "Detail", "show": [{ "field": "kind", "notEmpty": true }] }
```

### Conditions on later steps

Before, without routes, a field on step 1 could depend on step 2:

```json
{
  "steps": [
    { "id": "one", "title": "One", "fields": [{ "key": "x", "type": "text", "label": "X", "show": [{ "field": "y", "eq": "yes" }] }] },
    { "id": "two", "title": "Two", "fields": [{ "key": "y", "type": "text", "label": "Y" }] }
  ]
}
```

After, the field it depends on comes first:

```json
{
  "steps": [
    { "id": "one", "title": "One", "fields": [{ "key": "y", "type": "text", "label": "Y" }] },
    { "id": "two", "title": "Two", "fields": [{ "key": "x", "type": "text", "label": "X", "show": [{ "field": "y", "eq": "yes" }] }] }
  ]
}
```

### Comparisons

New in 0.11. Scores from radio options compare as numbers:

```json
{ "key": "problem", "type": "textarea", "label": "What went wrong?", "show": [{ "field": "score", "lte": 6 }] }
```

### Actions

Remove `action` from `submit`, `cancel`, `next`, `back` and `skip`:

```json
{ "submit": { "label": "Send", "action": "send" } }
```

```json
{ "submit": { "label": "Send" } }
```

## Code

### Engine actions

```ts
// 0.10
engine.nextStep();
await engine.nextStepAsync();
engine.prevStep();
await engine.submitAsync(save);
```

```ts
// 0.11
await engine.next();
await engine.back();
await engine.submit(save);
```

### Read-only state

```ts
// 0.10
engine.values.email = 'ada@example.com';
engine.errors = { email: 'Taken' };
```

```ts
// 0.11
engine.setValue('email', 'ada@example.com');
engine.setErrors({ email: 'Taken' });
```

### Definition errors

```ts
// 0.10
const problems = validateDefinition(json);
try { new FormEngine(json); } catch (error) { console.log(String(error).split('\n')); }
```

```ts
// 0.11
import { checkDefinition, FormDefinitionError, parseDefinition } from '@formhaus/core';

const errors = checkDefinition(json).filter((issue) => issue.severity === 'error');
try {
  const definition = parseDefinition(json);
} catch (error) {
  if (error instanceof FormDefinitionError) console.log(error.issues);
}
```

### React props

```tsx
// 0.10
<FormRenderer definition={definition} onSubmit={save} ActionsComponent={Actions} onStepChange={(id) => track(id)} />
```

```tsx
// 0.11
<FormRenderer definition={definition} onSubmit={save} actionsComponent={Actions} onAfterStepChange={({ toStepId }) => track(toStepId)} />
```

### Vue submit and custom fields

```vue
<!-- 0.10 -->
<FormRenderer :definition="definition" :submit-handler="save" @step-change="track" />
```

```vue
<!-- 0.11 -->
<FormRenderer :definition="definition" @submit="save" :on-after-step-change="({ toStepId }) => track(toStepId)" />
```

```vue
<!-- 0.11 custom field -->
<script setup lang="ts">
import type { FormFieldProps } from '@formhaus/vue';

const props = defineProps<FormFieldProps>();
const emit = defineEmits<{ (e: 'update:modelValue', value: unknown): void }>();
</script>

<template>
  <input :id="props.id" :value="props.modelValue" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" />
</template>
```

### Tests that select by id

```ts
// 0.10
document.querySelector('#email');
```

```ts
// 0.11
document.querySelector('[name="email"]');
screen.getByLabelText('Email');
```
