Two-way binding makes forms feel like magic. One-way binding makes every change easy to trace. Here’s how each one works, where each one bites, and the playbooks we use to migrate forms from two-way to one-way, and from one-way back to two-way.
Every form keeps two copies of the same value: one in your JavaScript state, one sitting in the input box on screen. Binding is just the answer to one question: who keeps these two in sync?
There are two answers, two-way and one-way, and neither one is “the old way”. Angular, Ember and Vue lean two-way. React is strictly one-way. Teams move in both directions: Ember and AngularJS apps move to React, and React apps move to Angular or Vue, or just pick up a form library that puts the two-way feel back.
So this post goes in four parts: two-way binding on its own, one-way binding on its own, then migrating two-way → one-way, and migrating one-way → two-way. The examples come from the Ember, Angular and React apps in our migratex-examples repo.
The Short Version
| Two-way | One-way | |
|---|---|---|
| Who writes the state | The input and your code | Only your code |
| Typical syntax |
[(ngModel)], <Input @value>, v-model
|
value + onChange
|
| Glue code | None | A setter per field (or one shared helper) |
| Where changes come from | Anything bound to the value | One setter you can search for |
| Best at | Small, local forms | Shared state and big apps |
| Main risk | Editing data you don’t own | Boilerplate and forgotten handlers |
Both keep the screen and the data in sync. The real difference is who is allowed to write.
Two-Way Binding
How it works
With two-way binding, the input and the state are glued together. Type in the box and the variable changes. Change the variable and the box changes. You write no glue code.
Angular makes it one line. In our Angular todo app, the title input and the type dropdown are wired like this:
<input name="title" [(ngModel)]="title" />
<select name="type" [(ngModel)]="type">...</select>
That “banana in a box” syntax is actually sugar for two one-way bindings: [ngModel]="title" pushes the value down, and (ngModelChange)="title = $event" writes it back. Angular just hides the second half from you. Vue’s v-model works the same way: a :value plus an @input.
Ember does it with the built-in <Input> component. Our Ember login form is just:
<Input type="text" @value={{this.email}} />
<Input type="password" @value={{this.password}} />
<Input> writes every keystroke straight back into this.email. No handler, no setter, nothing.
Where it shines
- Small forms are tiny. A login form, a search box or a settings toggle needs no handler code at all.
- The template reads like the data. You can see what each field edits just by looking at the markup.
-
Validation comes built in. Angular’s
ngModeltracksdirty,touchedandvalidfor each field for free.
Where it bites
The trouble starts when you bind to something that isn’t yours. Look at our Ember article editor:
<Input @value={{this.article.title}} />
<Input @value={{this.article.description}} />
<Textarea @value={{this.article.body}} />
this.article is not a local copy. It’s the actual ember-data record from the store, the same object every other screen reads. So every keystroke edits the shared, cached article before anyone clicks Publish. Start editing a title, leave without saving, and the record in the store still carries your half-typed change.
To keep this under control, the component needs extra machinery:
-
hasDirtyAttributesto know whether anything changed, so the Publish button can enable itself. -
unloadRecord()inwillDestroyto throw away a new article that was never saved. - A
keyuphandler,processTags, to keep therawTagListtext and the record’stagListarray in sync.
Same story one level up. comments-section.js does this.args.article.set('comments', comments), so a child component quietly changes data its parent owns. It works fine, until someone asks “who set this value?” and nobody can answer without a debugger.
One-Way Binding
How it works
With one-way binding, state flows down into the view, and the view can’t touch it. An input shows whatever state says, and typing only fires an event. Your code decides what to do with that event. Here’s the same login form in React:
const [email, setEmail] = useState('');
<input value={email} onChange={(e) => setEmail(e.target.value)} />
Between components the rule is the same: data goes down as props, and changes come back up as callbacks. Our weather app’s search bar owns its own text, and the parent only hears about it through onSearch(city) when the user actually searches. The parent never sees the half-typed city at all.
Where it shines
-
One door per value. Every change goes through
setEmail. Want to trim spaces, block a character or log every keystroke? Put it in that one line. - Easy to trace. Search for the setter and you’ve found every place that can change the value.
- Nothing leaks. A child can’t edit its parent’s data. It can only ask, through a callback.
Where it costs you
-
More typing. Every field needs a
valueand anonChange. A twenty-field form gets noisy fast. - You build the extras yourself. Dirty checks, touched state and validation don’t come for free.
-
Forgotten handlers. Leave out
onChangeand you get a read-only input plus a console warning.
Migrating Two-Way → One-Way
This is the Ember or Angular to React direction. When we ported the Ember app to React, every two-way pattern mapped to one of five one-way patterns:
| Two-way (Ember) | One-way (React) | From our repo |
|---|---|---|
<Input @value={{this.email}}> |
value + onChange + useState
|
login-form.hbs → LoginForm.tsx |
| Input bound to a store record | Local draft copy, saved through a mutation | article-form.js → ArticleForm.tsx |
hasDirtyAttributes |
isDirty derived from the draft vs an initial snapshot |
ArticleForm.tsx, SettingsForm.tsx |
Child calls args.article.set(...)
|
Callback prop (onAdd) + query cache update |
comments-section.js → CommentsSection.tsx |
keyup handler syncing two fields |
Derive at submit time with parseTags()
|
article-form.js → ArticleForm.tsx |
The big one is row two. The React form copies the article into local state once, and the cached article is never touched until the server says yes:
const initial = useRef({
title: article?.title ?? '',
description: article?.description ?? '',
body: article?.body ?? '',
rawTagList: article?.tagList.join(' ') ?? '',
});
const [form, setForm] = useState(initial.current);
// one handler for every field
const update = (field: keyof typeof form) => (e) =>
setForm((f) => ({ ...f, [field]: e.target.value }));
<input value={form.title} onChange={update('title')} />
That little update(field) helper is the trick for big forms. You get almost the same convenience as @value, but every write still goes through one setter, into one draft object, owned by one component. When the user clicks Publish, the draft goes to the server through a useSaveArticle mutation, and only then does the cache change.
Gotchas we hit
Two-way binding was doing a lot of quiet work for free. Once you remove it, you have to do that work yourself:
-
Dirty checks need normalized values. Typing
react hooks(two spaces) andreact hooksgives the same tag list. SoisDirtycomparesparseTags(form.rawTagList), not the raw string, or the Publish button lights up for nothing. -
Reset the baseline after saving. SettingsForm calls
setInitial(form)after a successful save. Skip it and the form stays “dirty” forever. -
Ask before throwing away a draft. With a local draft, navigating away means losing it. ArticleForm uses React Router’s
useBlockerto confirm before you leave with unsaved changes. -
Keep the draft when a save fails. The comment form clears its textarea only after
onAddsucceeds. Clear it first and a network error eats the user’s comment. -
Update the cache yourself. Editing the store record used to update every screen automatically. Now, when you change your username, the settings form must
removeQueriesfor the old profile andinvalidateQueriesso author names refresh everywhere.
None of these are about markup. They’re all about when state changes and who else needs to know.
Migrating One-Way → Two-Way
The other direction happens too: a React app moving to Angular or Vue, or a React team tired of writing onChange forty times. This direction is easier, because you’re adding convenience, not removing it. The risk is different: it’s tempting to bind everything, and quietly lose the clear ownership the one-way app already had.
Our todo app shows both sides. In React, each field needs state plus a handler:
const [title, setTitle] = useState('');
const [type, setType] = useState<TodoType>('normal');
<input value={title} onChange={e => setTitle(e.target.value)} />
<select value={type} onChange={e => setType(e.target.value as TodoType)}>
In Angular, the same two fields are plain class properties and [(ngModel)]. The handlers disappear. But look at the todo items: the Angular TodoItem still takes its data as @Input() and reports back through @Output() events like onComplete and onDelete. It never edits the list itself. That’s one-way across components, kept on purpose.
The mapping we follow:
| One-way (React) | Two-way (Angular / Vue) | Keep in mind |
|---|---|---|
useState + value + onChange on a local field |
[(ngModel)] or v-model on a component field |
Safe to bind. This is exactly what two-way is good at. |
| Draft copy + save mutation | Keep the draft. Bind to a copy, not the cached record | Binding straight to a shared object brings back the Ember article bug. |
| Props down + callback up |
@Input() + @Output(), or Vue props + emit
|
Use model() / defineModel only for value-like widgets (a date picker, a toggle). |
Logic in onChange (trim, mask, uppercase) |
(ngModelChange) handler, a pipe, or a ControlValueAccessor
|
Plain [(ngModel)] silently drops the logic. |
isDirty derived by hand |
form.dirty, pristine, touched
|
Free now, but dirty means “touched”, not “changed”. Type and undo, and it stays dirty. |
| React Hook Form / Formik | Angular Reactive Forms | Often a closer match than template-driven ngModel. |
Gotchas in this direction
-
Don’t bind to shared objects. If a service hands you an object that other screens also read, copy it first (
{ ...article }) and bind to the copy. Otherwise you’ve rebuilt the half-typed-title bug from the Ember app. -
Move
onChangelogic somewhere real. A React handler likesetTitle(e.target.value.trim())has no place to live in[(ngModel)]="title". Split it into[ngModel]+(ngModelChange), or use a reusable directive. -
Side effects in handlers need a new home. If
onChangealso saved to localStorage or fired analytics, two-way binding won’t do it for you. Use a setter, acomputed/effect, or avalueChangessubscription. -
Don’t turn every prop into a two-way prop. Angular’s
model()and Vue’sdefineModelmake it one line to let a child write to its parent. Use it for form-like widgets, not for passing whole records around. -
Re-check change timing. React batches updates and re-renders. Angular runs change detection and Vue updates asynchronously. Tests that check the value right after typing may need
fixture.detectChanges()orawait nextTick().
So Which One Wins?
Neither. The problem was never the convenience, it was the reach. Binding an input to a local field is fine in any framework. Binding it to a shared record is where things go wrong, whether you got there with @value or with a careless model().
So the rule we follow in both directions: two-way inside a component, one-way across components. Inside a form, use whatever makes typing easy: ngModel, v-model, an update(field) helper or React Hook Form. Across component boundaries, data goes down as props or inputs and changes come back up as callbacks or events.
Two-way inside a component, one-way across components.
Bottom line: whichever way you’re migrating, take these habits with you:
- Every value has one owner. If you can’t say which component owns it, you’ll find out the hard way.
- Edit a draft, not the source. Copy into local state, save, then update the cache. This holds in React, Angular and Vue alike.
-
Derive, don’t sync.
isDirty, tag lists, button states: compute them instead of keeping them in sync with handlers. - Make side effects explicit. Whatever the old binding or the old handler did “for free”, list it and do it on purpose.
- Test the timing. Failed saves, navigating away mid-edit, saving twice. That’s where migrations go wrong.
Our Migration Plugins
Spotting every binding in an app, deciding which ones become drafts and which become plain controlled inputs, and proving the forms still behave the same is slow work by hand. We’ve packaged how we do it as free, open-source plugins for Claude Code, one per starting point:
| Plugin | Starts from | Binding style it starts from |
|---|---|---|
ember-to-react |
Ember Classic and Octane |
<Input @value>, bound store records, mut
|
angular-to-react |
Angular 2+ and AngularJS 1.x |
[(ngModel)], ng-model, digest cycles |
backbone-to-react |
Backbone.js, Marionette, jQuery templates | Model change events + manual render()
|
jquery-to-react |
jQuery apps, plugins and jQuery UI |
.val() reads and writes by hand |
vanillajs-to-react |
Plain HTML, CSS and JavaScript | Direct DOM updates |
Each plugin comes with a migration skill, a /<plugin>:plan command that writes a migration plan without touching your code, a read-only inventory agent that maps the old app, a parity-reviewer agent that compares each migrated piece with its original, and a bundled Context7 server for up-to-date library docs. To install one in Claude Code:
/plugin marketplace add cobuild-tech/cbx-plugins
/plugin install ember-to-react@cbx-plugins
They also work in Cursor, in VS Code with GitHub Copilot, and in the GitHub Copilot CLI. Progress is kept in a .migration/ folder in your repo, so a migration can carry on across sessions and teammates. The source and setup steps are at cobuild-tech/cbx-plugins, and issues are welcome.
About MigrateX
MigrateX is our platform and service for moving software, data and infrastructure from one technology to another. It combines automation with experienced engineers. The automation does the repetitive work quickly, and the engineers make the tricky decisions, like which state becomes a local draft and which cache entries a save has to refresh.
A MigrateX project usually runs like this:
- Assessment. We map every binding, shared record and cross-component write in your app, so you see where the work is before anyone agrees on a timeline.
- Plan. With your team, we decide what moves first, what gets replaced, and what can simply be deleted.
- Migration, step by step. Automation handles the repetitive parts. Our engineers handle state, data flow and the code that connects old and new.
- Proof that it works. The same tests run against both versions. A piece only counts as moved when it passes on both.
- Handover. We remove the temporary code and leave your team with a codebase they understand and own.
Your app keeps running and shipping features the whole time, so there’s no big launch day. The examples in this post come from our migratex-examples repo, which has each small app next to its migrated version.
Got an app full of
ngModelor@valuebindings? Tell us a little about it through Contact Us, and we’ll start with a free chat about where the work is likely to be.
Further reading
- Angular. Two-way binding. https://angular.dev/guide/templates/two-way-binding
- Ember. Built-in Components: Input. https://guides.emberjs.com/release/components/built-in-components/
- React. Sharing State Between Components. https://react.dev/learn/sharing-state-between-components
- Angular. Model inputs. https://angular.dev/guide/components/inputs#model-inputs
- Vue. Component v-model. https://vuejs.org/guide/components/v-model.html
Source code
- CobuildX AI. cbx-plugins: Claude Code plugins for Backbone, Angular, Ember, jQuery and Vanilla JS to React migrations. https://github.com/cobuild-tech/cbx-plugins
- CobuildX AI. migratex-examples: small jQuery, Backbone, Angular, Ember and Vanilla JS apps and their migrated versions. https://github.com/cobuild-tech/migratex-examples
Originally published on the CobuildX blog.




