Two way binding is great and elegant in Svelte, but a recurrent situation I've come across is needing two way binding with an intermediate transformation that converts types or does some kind of clean up. For example:
value
that has the form {value, label}
, but its parent just handles a value<input type=text>
is also input for some other type (Number, date, custom one), or an input to edit an object as JSON that could also be changed from the outside.My question is: Which is a good, idiomatic and simple way of solving this pattern in Svelte?
The best reusable solution I've found so far has been to create a store factory for one-to-one transformations that returns two stores, a
and b
which you can then use and bind to other components:
// App.svelte
<script>
import oneToOne from './oneToOne.js'
const f = x => JSON.stringify({x});
const fInv = x => {try{return JSON.parse(x).x} catch(err){return NaN}};
let [a, b] = oneToOne(13, f, fInv);
</script>
A: <input bind:value={$a}/>
B: <input bind:value={$b}/>
// oneToOne.js
import { writable } from 'svelte/store';
const identity = x => x;
export default function oneToOne(val, f = identity, fInv = identity) {
let fInvB = val;
let fA = f(val);
const A = writable(fInvB);
const B = writable(fA);
B.subscribe((b) => {
if(fA !== b && !(Number.isNaN(fA) && Number.isNaN(b))) {
fInvB = fInv(b);
fA = b;
A.set(fInvB)
}
});
A.subscribe((a) => {
if(fInvB !== a && !(Number.isNaN(fInvB) && Number.isNaN(a))) {
fA = f(a);
fInvB = a;
B.set(fA)
}
});
return [A, B];
}
Does this make sense? Am I missing a simpler way of doing this or avoiding this complexity altogether?
Thanks to @H.B. I realized I had missed a comment on this github issue with what I think is the most simple/small/idiomatic way of coding this case that I've seen so far.
The idea I had missed: To avoid the circular dependency issue with in/out conversions, just declare in
/out
(or f(x)
, fInv(x)
) with the assigment in them, and then invoke them reactively. The original example would end up like this:
<script>
let a = 13, b;
const f = x => b = JSON.stringify({x});
const fInv = x => {try{a = JSON.parse(x).x} catch(err){a = NaN}};
$: f(a);
$: fInv(b);
</script>
A: <input bind:value={a}/>
B: <input bind:value={b}/>
That's it! No stores or helpers needed, with the same or less verbosity. I initially thought there might be a reentrancy problem/infinite loop if f(fInv(x)) !== x
, but If I understand correctly it works because Svelte never executes a reactive statement more than once in the same tick.
EDIT: There is no infinite loop, but there's an ugly side: depending on the order of the reactive statements, assignment in one of the two directions is going to trigger a reentrant update of the variable triggering the update (known issue). That can be annoying in some cases, when the transformation is asymetrical. See more below
Caveat/Interesting corner case
In my original example and proposed solution, when the JSON input is edited to an invalid JSON, fInv
returns NaN
for a
, but it does not propagates back to b
and the JSON input (if so, it would be {x: null}
), so you can keep typing until a valid json is reached. This is nice because it allows a "smoother" json text editing, but it creates an inconsistent state between the two inputs.
Whether this is desirable or not, this answer does not always behaves the same way: The original oneToOne.js
explicitely prevented the store update "reentrancy", so if a non number is typed in the "binary bonus track" the NaN
does not propagate back. But in this answer´s implementation, dependening on the reactive statements order the same actions can trigger a reentrancy, propagating NaN
to the original binary input.