This guide will help you migrate from the deprecated @frontile/changeset-form package to the modern frontile forms. We recommend migrating to the modern Form + Field pattern with Valibot validation for the best experience, but also provide paths for teams that need to keep ember-changeset validation.
The @frontile/changeset-form package is deprecated and will be removed in future versions. This guide presents three migration paths based on your project's constraints and goals.
Form + Field pattern (Approach 1): Automatic data binding, validation, and state management via <Form> and <form.Field> components.
Standalone components (Approaches 2 & 3): Use form components directly with manual binding and validation integration.
See the Form documentation for detailed architecture information.
You have three main migration paths, ordered by recommendation:
| Approach | Validation | Components | Effort | Tech Debt | Best For |
|---|---|---|---|---|---|
| 1. Modern Forms + Valibot ⭐ | Valibot | Form + Field pattern | Low-Medium | None | New features, best long-term choice |
| 2. Changeset + Modern Components | Changeset | Standalone modern components | Medium | Medium | Keep validation, modernize UI |
| 3. Changeset + Forms-Legacy | Changeset | Legacy components | Medium | High | Large codebases needing gradual migration only |
Recommendation: Approach 1 (Modern Forms + Valibot) provides the best developer experience, performance, and maintainability. Choose it unless you have specific constraints requiring changeset validation.
Build the package you'll use: pnpm --filter forms build or pnpm --filter forms-legacy build
This is the recommended approach that provides the best developer experience and long-term maintainability. It uses the modern Form + Field pattern with Valibot validation.
✅ Starting new features or components - Best choice for greenfield development ✅ Modernizing existing forms - Clean break from legacy patterns ✅ TypeScript-first validation - Type-safe schemas with excellent IDE support ✅ Simpler validation logic - No complex changeset dependencies ✅ Future-proof - Built on modern web standards
# Remove old packages
npm uninstall @frontile/changeset-form @frontile/forms-legacy
npm uninstall ember-changeset ember-changeset-validations
# Install new packages
npm install frontile @frontile/theme valibot
{{#let (changeset this.model this.validations) as |changeset|}}
<ChangesetForm @changeset={{changeset}} as |Form|>
<Form.Input @fieldName='firstName' @label='First Name' />
<Form.Textarea @fieldName='comments' @label='Comments' />
<Form.Select
@fieldName='country'
@label='Country'
@options={{this.countries}}
as |option|
>
{{option}}
</Form.Select>
<Button @type='submit'>Save</Button>
</ChangesetForm>
{{/let}}
Complete GTS component with template:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Form, type FormResultData } from 'frontile';
import { Button } from 'frontile';
import * as v from 'valibot';
// Define schema outside class (replaces changeset validations)
const UserFormSchema = v.object({
user: v.object({
firstName: v.pipe(v.string(), v.nonEmpty('Required')),
email: v.pipe(v.string(), v.nonEmpty('Required'), v.email('Invalid'))
}),
country: v.pipe(
v.fallback(v.string(), ''), // Required for Select fields
v.string(),
v.nonEmpty('Please select a country')
),
plan: v.pipe(
v.fallback(v.string(), ''),
v.string(),
v.nonEmpty('Please select a plan')
)
});
type UserFormSchema = v.InferOutput<typeof UserFormSchema>;
export default class UserFormComponent extends Component {
@tracked formData: UserFormSchema = {
user: { firstName: '', email: '' },
country: '',
plan: ''
};
countries = [
{ key: 'us', label: 'United States' },
{ key: 'ca', label: 'Canada' }
];
handleChange = (result: FormResultData<UserFormSchema>) => {
this.formData = result.data;
};
handleSubmit = async (result: FormResultData<UserFormSchema>) => {
// Data is already validated
await this.saveUser(result.data);
};
async saveUser(data: UserFormSchema) {
// Your API call
}
<template>
<Form
@data={{this.formData}}
@schema={{UserFormSchema}}
@validateOn={{array "change" "submit"}}
@onChange={{this.handleChange}}
@onSubmit={{this.handleSubmit}}
as |form|
>
{{! Nested data with dotted notation }}
<form.Field @name="user.firstName" as |field|>
<field.Input @label="First Name" />
</form.Field>
<form.Field @name="user.email" as |field|>
<field.Input @type="email" @label="Email" />
</form.Field>
<form.Field @name="country" as |field|>
<field.SingleSelect @label="Country" @items={{this.countries}} @allowEmpty={{true}} />
</form.Field>
<form.Field @name="plan" as |field|>
<field.RadioGroup @label="Select Plan" as |Radio|>
<Radio @label="Free" @value="free" />
<Radio @label="Pro" @value="pro" />
</field.RadioGroup>
</form.Field>
<Button @type="submit">Save</Button>
</Form>
</template>
}
For advanced features like dirty tracking, loading states, and validation timing control, see the Form documentation.
This approach keeps ember-changeset validation while using modern frontile form components in standalone mode (without Form/Field wrappers).
Important: This approach uses modern form components (Input, Select, Switch, etc.) directly, NOT the Form or Field components. You maintain manual control over data binding and validation.
✅ Complex changeset validation - Don't want to rewrite working validation logic ✅ Need modern UI features - Want slots, clearable, filtering, better accessibility ✅ Hybrid migration strategy - Modernize components now, validation later ✅ Team familiar with changeset - Minimize learning curve ⚠️ Willing to accept more boilerplate - Manual binding required
npm uninstall @frontile/changeset-form
npm install frontile @frontile/theme
# Keep: ember-changeset ember-changeset-validations
Use modern components directly without Form/Field wrappers:
{{#let (changeset this.model this.validations) as |changeset|}}
<ChangesetForm @changeset={{changeset}} as |Form|>
<Form.Input @fieldName='firstName' @label='First Name' />
<Form.Select
@fieldName='country'
@label='Country'
@options={{this.countries}}
@isMultiple={{true}}
as |option|
>
{{option}}
</Form.Select>
<Button @type='submit'>Save</Button>
</ChangesetForm>
{{/let}}
{{#let (changeset this.model this.validations) as |changeset|}}
<form {{on 'submit' (fn this.handleSubmit changeset)}}>
{{! Manual binding required for standalone components }}
<Input
@label="First Name"
@value={{get changeset 'firstName'}}
@onChange={{fn this.updateField changeset 'firstName'}}
@errors={{this.getFieldErrors changeset 'firstName'}}
/>
{{! Key-based Select (not object-based!) }}
<Select
@label="Country"
@items={{this.countries}}
@selectedKey={{this.getSelectedCountry changeset}}
@onSelectionChange={{fn this.updateCountry changeset}}
@allowEmpty={{true}}
@errors={{this.getFieldErrors changeset 'country'}}
>
<:item as |item|>
<item.Item @key={{item.key}}>{{item.label}}</item.Item>
</:item>
</Select>
{{! Radio uses @checkedValue, not @checked }}
<Radio
@label="Free"
@value="free"
@checkedValue={{get changeset 'plan'}}
@onChange={{fn this.updateField changeset 'plan'}}
/>
<Radio
@label="Pro"
@value="pro"
@checkedValue={{get changeset 'plan'}}
@onChange={{fn this.updateField changeset 'plan'}}
/>
<Button @type="submit">Save</Button>
</form>
{{/let}}
Key changes: manual binding and Select key-based selection.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class ChangesetFormComponent extends Component {
@tracked hasSubmitted = false;
// Keep existing changeset validations
validations = {
firstName: validatePresence(true),
country: validatePresence(true),
plan: validatePresence(true)
};
countries = [
{ key: 'us', label: 'United States' },
{ key: 'ca', label: 'Canada' }
];
@action
updateField(changeset, fieldName, value) {
changeset.set(fieldName, value);
changeset.validate(fieldName);
}
// Select components use keys, need conversion for changeset
@action
updateCountry(changeset, selectedKey) {
changeset.set('country', selectedKey);
changeset.validate('country');
}
getSelectedCountry(changeset) {
return changeset.get('country') || null;
}
@action
async handleSubmit(changeset, event) {
event.preventDefault();
await changeset.validate();
this.hasSubmitted = true;
if (changeset.isValid) {
await changeset.save({});
}
}
getFieldErrors(changeset, fieldName) {
if (!this.hasSubmitted) return [];
return changeset.errors
.filter(e => e.key === fieldName)
.map(e => e.validation)
.flat();
}
}
This is the minimal-effort approach for large codebases that need gradual migration. It keeps changeset validation while using legacy form components.
⚠️ Warning: This approach incurs the most technical debt and should only be chosen when time/resource constraints prevent other approaches.
✅ Very large codebase - Hundreds of forms to migrate ✅ Limited development resources - Need absolute minimal effort ✅ Complex changeset dependencies - Extensive custom changeset logic ✅ Short-term solution - Plan to revisit later ⚠️ Accepting technical debt - Understanding this is a stopgap
npm uninstall @frontile/changeset-form
npm install @frontile/forms-legacy
# Keep: ember-changeset ember-changeset-validations
# Build the package
pnpm --filter forms-legacy build
Replace ChangesetForm wrapper with manual changeset binding to @frontile/forms-legacy components:
{{! Before }}
<ChangesetForm @changeset={{changeset}} as |Form|>
<Form.Input @fieldName='firstName' @label='First Name' />
</ChangesetForm>
{{! After }}
<form {{on 'submit' (fn this.handleSubmit changeset)}}>
<FormInput
@label='First Name'
@value={{get changeset 'firstName'}}
@onInput={{fn this.updateField changeset 'firstName'}}
@errors={{this.getFieldErrors changeset 'firstName'}}
@hasSubmitted={{this.hasSubmitted}}
/>
</form>
See the Forms Legacy Migration Guide for complete component mappings and detailed migration steps.
Recommendation: Plan to migrate to Approach 1 when resources allow.
Key API changes when migrating to modern forms:
@checked → @checkedValue{{! Legacy }}
<FormRadio @checked={{this.plan}} @value="free" />
{{! Modern }}
<Radio @checkedValue={{this.plan}} @value="free" />
In Approach 1, use field.RadioGroup which handles this automatically.
{{! Legacy (object selection) }}
<FormSelect @selected={{this.countryObject}} />
{{! Modern (key selection) }}
<Select @selectedKey="us" /> {{! Just the key string }}
Modern Select components use string keys instead of full objects. Add v.fallback(v.string(), '') to validation schemas for Select fields.
Approach 1 uses Form + Field: <form.Field @name="email" as |field|><field.Input /></form.Field>
Approaches 2 & 3 use standalone components with manual binding: <Input @value={{this.email}} @onChange={{this.setEmail}} />
Don't mix these patterns in the same form.