Search code examples
svelte

Idiomatic way in Svelte to do two way binding with an intermediate transformation


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:

  • Binding to a select component's prop value that has the form {value, label}, but its parent just handles a value
  • Type conversions, where a <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:

Example: play with REPL here

// 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?


Solution

  • 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}/>
    

    Full REPL here

    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.