We had encountered a lot of small but subtle issues with data binding in Svelte 5 recently to the point that we’ve decided to migrate away from using two-way data binding with a deeply nested data structure.
The Problem
At Iago, we’ve built bespoke internal tools over time. These tools is used by our content team to handle creating and maintaining the curriculum content in our app. While the company was mostly a React shop for the frontend code, I opted to build this in Svelte (benefits of being a co-founder :p) as it would save us so much time on the boilerplate needed to write a big, complex form-based editor with React.
The Promise of Safe Two-Way Data Binding
The promise of Svelte is that the two-way data binding can be safe as the compiler will spot and catch cases where changes occur in the data and identify what update paths need to be run. However, due to the dynamic nature of JavaScript, this is sometimes less than ideal in the real world. (We sometimes even depend on this in Svelte 4, where you would declare a two-way transformation in a separate non-reactive function so it’s not tracked by the compiler.)
After upgrading to Svelte 5, we noticed some subtle weird binding bugs that weren’t in Svelte 4 started to show up. We have some bindable()
props getting undefined
even though they’re always assigned a value. Sometimes, it’s a reactive function that doesn’t get reliably triggered for some reason.
We also have some issue with setting up two-way data transform and binding. We sometimes store data in one way but need to transform it into an intermediate structure so it maps to how the editing flow works better while keeping it in-sync. If you’re not careful, it’s very easy to cause an infinite loop as two-way data transform will keep getting triggered as they depend on each other.
It is very likely that the problems we encountered were because we were migrating from Svelte 4 instead of rewriting the app from scratch with Svelte 5 best practices & patterns. We weren’t able to produce a minimal repro to showcase the problem. Diving into Svelte 5’s internals and try to get to the bottom of these issues is way too time-consuming. We’ve decided to start looking into solving this problem the other way: Use one-way data binding and event propagation to update data. Sounds familiar? This is basically what one would do in React.
Setup
To make this work, we need a structure that holds the data, notifies components to re-render when it changes, and blocks direct updates.
Svelte does have a built-in concept of stores and the contract that a store needs to follow which works for us perfectly:
store = {
subscribe: (subscription: (value: any) => void) => (() => void),
set?: (value: any) => void }
}
We implemented a ImmutableStore
based on this contract, supporting the subscribe
for reactivity and dropping the set
function so a two-way data binding within Svelte is not possible. We used immutable-js internally to keep the internal state immutable.
import { fromJS, Map, is } from 'immutable'
import {
immutableJSONPatch,
type JSONPatchDocument,
} from 'immutable-json-patch'
export class ImmutableStore<T extends Record<any, any>> {
_data: Map<any, any>
_data_js: T
_subscribers: Set<(value: T) => void>
constructor(src: T) {
this._data = fromJS(src) as Map<any, any>
this._data_js = this._data.toJS() as T
this._subscribers = new Set()
}
update(patch: JSONPatchDocument) {
const newData = fromJS(immutableJSONPatch(this._data.toJS(), patch))
if (!is(this._data, newData)) {
this._data = newData as Map<any, any>
this._data_js = this._data.toJS() as T
this._notify()
}
}
subscribe(run: (value: T) => void): () => void {
this._subscribers.add(run)
run(this._data_js)
return () => {
this._subscribers.delete(run)
}
}
_notify() {
this._subscribers.forEach(fn => fn(this._data_js))
}
}
Note that we maintain a serialized JS object on each update so we can notify our subscriber with the same object if no change actually occurs and to save serialization cost on each access.
The fact that this complies with the store contract means you can use Svelte’s built-in support for it, for example, you can reference the data with $store.field
and that use-site will get automatic reactive updates. To pass the data to a child-component, you use prop={$store.field}
instead of bind:prop
. You also still keep the TypeScript validation of the internal data types.
Svelte 5 warnings to the rescue!
Svelte 5 will automatically check if your binds are actually bindable. This makes our migration to one-way data binding much easier. Once you switch the root component to start using the one-way store, you automatically get warnings on old two-way binding usages so we can go out and migrate those one-by-one.
Propagating Changes
As you might have spotted earlier, we chose to use JSONPatch for updating the data. We add onChange
prop to each child component and call the function with a JSONPatch document to propagate the changes back to the parent until it hits the root component with the store, then it calls the store.update
to execute the changes.
One issue is that while child components understand the data structure it holds and can emit proper JSONPatch documents for it, it does not know where the data it holds is placed in the structure from the parent’s perspective. For example, a Contact
component understands how to update the Contact
structure, but it doesn’t know if the data is stored at /contacts/vips/0
or /contacts/spam/3
, the parent component can provide this context.
To make it easy for parent components to patch up the JSONPatch document it receives from its children, we added the following function so the parent component can do a simple doc => onChange?.(patchJSONPatchDocumentWithPrefix(doc), '/contacts/vips/0)
to rewrite the patch with the proper path and continue to propagate the changes upward.
export function patchJSONPatchDocumentWithPrefix(
patch: JSONPatchDocument,
prefix: string
): JSONPatchDocument {
return patch.map(op => ({
...op,
path: `${prefix}${op.path}`,
}))
}
As each parent component in the tree adds on its context, by the time it reaches the root component, the JSONPatch document would have properly prefixed paths.
TypeScript safety
We do lose TypeScript safety in the update path as TypeScript cannot validate if the path actually fits into the structure.
Supporting both one-way and two-way bindings
Some of our core components like <Input>
, <Select>
need to support both use cases. To allow for this, we keep the value
prop in these components still bindable()
but also allow an onChange?
prop to be passed in. The component will still attempt to update the value directly unless it sees the onChange
prop in which case it will call the onChange
handler instead.
Death to two-way data binding?
Not really, we still use $state
everywhere in the app, especially for component specific states and simple structures, and those are fine.
This technique does make the data update flows much easier to follow and reason about. You can even add some console.log
to the store to actually see each piece of data being updated as you use the app.
For us, this wipes out the single biggest class of bugs we kept running into and our internal users are much happier with a more stable tool.