Photo by Robert Zunikoff on Unsplash
Svelte 5 Ownership Mutation
One of my favorite aspects of Svelte is that it adds robust data binding to the Web, and then gets out of the way. Do not get me wrong, data binding with Observable and Proxy added to the standard is great. I know that is what Svelte is using under the hood - which makes me happy - but the syntactic sugar afforded by the Svelte compiler is pure joy. Until it is not.
One of my applications, Flavor Awesome, depends heavily on data binding to unknown form elements created at runtime by external metadata. This configuration creates a complex data binding scenario from which I have learned a lot. In this post I will dive deep into some of the more obscure data binding scenarios that I either missed, or that are not covered well, in the Svelte documentation.
The Counter Component
When describing data binding (the $state rune) in a component, the Svelte documentation presents a counter value that is a Number. When you click an increment button, the counter is incremented by one. The inverse is true for decrementing the count. Svelte then updates the display of the counter in the HTML as it detects the change. It goes something like this…
<script>
import decrease from "$lib/assets/arrow-down.svg";
import increase from "$lib/assets/arrow-up.svg";
let count = $state( 0 );
function onDecreaseClick() {
if( count > 0 ) {
count = count - 1;
}
}
function onIncreaseClick() {
count = count + 1;
}
</script>
<article>
<button
aria-label="Decrease"
disabled={count === 0 ? true : false}
onclick={onDecreaseClick}
type="button">
<img alt="Arrow pointing down" src={decrease} />
</button>
<p>{count}</p>
<button
aria-label="Increase"
onclick={onIncreaseClick}
type="button">
<img alt="Arrow pointing up" src={increase} />
</button>
</article>
Great! Wow! Data binding with Svelte is magical and easy to use. If you want to seed the counter value from outside of the component, you remove the $state rune, and replace it with a property value ($props). This is defined as such...
let {count = 0} = $props();
When you change the counter value inside the component, that change happens only inside the component itself. If you want the change to bubble up, then you need to add an event listener to the component. When the counter is changed inside the component, you make the change, and then call the event listener, passing the new counter value as an argument.
let {count = 0, onchange} = $props();
function onDecreaseClick() {
if( count > 0 ) {
count = count - 1;
if( onchange ) onchange( count );
}
}
function onIncreaseClick() {
count = count + 1;
if( onchange ) onchange( count );
}
If you are binding the counter value to the component, then you do not even need to make the change to the counter in the component itself. When the change is made to the counter value outside of the component, it will be updated inside the component as well. Data binding magic once more.
The Counter As A Property
I have been brief in this review of Svelte data binding because it is covered well in the documentation. If you need more detail, I would suggest heading over to the documentation to brush up before continuing with this post. Here is the catch: What happens when the counter is not a Number value, but rather a Number property on a custom Object? This is where things get interesting.
You might be inclined to perform the same operations, but on the relevant property. Something like...
// This will cause an error
item.count = item.count + 1;
When you do this, you will get an error from Svelte. The error is displayed as ownership_invalid_mutation. A description of the problem is provided with the error, and you can go down a rabbit hole trying to understand it. I did.
First, you have to think of $state and $props as different things. The $state rune will monitor property changes. A component property ($props) is a snapshot of the value being passed into the component.
Second, when you make a change to the value of a component property, and that value is a primitive such as Number, then you will not get the error. When the value being passed to the component is an Object however, and then you change a property of that Object, Svelte is telling you that the component does not own the original Object. More specifically, it is saying that the property changes on the Object will not be applied because unlike $state, the component property is a snapshot, not a state value.
Updating the Property
You have two choices in this scenario. This first is to use the event listener to bubble up the changed value. That change then gets applied at the level where it is owned ($state), and the subsequent data bindings are updated in turn.
let count = $state( 0 );
function onDecreaseClick() {
count = count - 1;
if( onchange ) onchange( count );
}
Depending on the complexity of your components, this works great. If your components are heavily nested, then you may have to bubble up the change repeatedly until the change gets to a place where the owner ($state) can be modified.
In the case of Flavor Awesome, the form fields are components, and they live inside a form component. That form component tracks changes on the source Object, but that Object is itself a component property. Because I am using metadata to define the form, the Object properties may be different. The form component is also responsible for validation before bubbling up the modified Object when the changes are committed. Using the bubbling approach then creates an event listener mess.
function onDecreaseClick() {
if( item === null ) return;
if( item.count > 0 ) {
const count = item.count - 1;
item = {... item, count};
if( onchange ) onchange( item );
}
}
The second option is to modify the property of the Object at the form level, and then have the changes updated through data binding. When the form is filled out, and validated, the modified Object is then bubbled. As we have just seen however, modifying an Object property will result in an error because the form component does not own the original Object.
The trick here is to replace the Object property in its entirety at the form level through Object destructuring. This will trigger data binding at the form level, and update the form field component appropriately. We are effectively working on the snapshot of the Object until it is ready to be bubbled.
On Snapshots
When the bubbled changes reach the owner, and you have made changes to the original Object, you may want to use console.log() to verify the contents before you write the code to commit the changes. If you use console.log() on the owning object you will get a different Svelte error that reads console_log_state. This error is presented because you are logging the Proxy object that holds the $state value.
console.log( $state.snapshot( item ) );
Thankfully, the $state rune has a snapshot() method you can use to get a copy of the Object itself at that point in time.
The $state.snapshot() method is useful in other scenarios as well. Flavor Awesome uses IndexedDB to manage data locally in the browser. You can put a $state value into IndexedDB collection, but what you will be storing is the Proxy object, not the value of the Object that Proxy is watching. What we want is the actual Object value. Here, you can use the $state.snapshot() method to get that original Object before you insert it into the database.
Next Steps
Data binding with Svelte is very powerful. As Uncle Ben says though "With great power comes great responsibility." Understanding the data on which you are operating becomes a very important aspect of developing with Svelte. Hopefully this post has shed some light on the deeper nuances of that process - especially as it relates to components. If you want to play with the concepts further, the code for this post is available as a GitHub Gist.