Home > Back-end >  In Blazor, is there a way to undo invalid user input, without changing the state?
In Blazor, is there a way to undo invalid user input, without changing the state?

Time:11-19

Here is a simple Blazor counter example (try it online):

<label>Count:</label>
<button @onclick=Increment>@count times!</button><br> 
A: <input @oninput=OnChange value="@count"><br> 
B: <input @bind-value=count @bind-value:event="oninput">

@code {
    int count = 1;
    
    void Increment() => count  ;

    void OnChange(ChangeEventArgs e) 
    {
      if (int.TryParse(e.Value?.ToString(), out var v)) count = v;
      else StateHasChanged();
    } 
}

For input element A, I want to replicate the behavior of input element B, but without using the bind-xxx-style data binding attributes.

E.g., when I type 123x inside A, I want it to revert back to 123 automatically, as it happens with B.

I've tried StateHasChanged but it doesn't work, I suppose, because the count property hasn't actually changed.

So, basically I need to re-render A to undo the user input, even thought the state hasn't changed.

How can I do that without the bind-xxx magic? Do I need to resort to JS interop?


Updated, here's the same thing in React (try it online), to compare:

function App() {
  let [count, setCount] = useState(0);
  const handleClick = () => setCount((count) => count   1);
  const handleChange = (e) => {
    const userValue = e.target.value;
    let newValue = userValue ? parseInt(userValue) : 0;
    if (isNaN(newValue)) newValue = count;
    setCount(newValue);
  };
  return (
    <>
      Count: <button onClick={handleClick}>{count}</button>
      <br />
      A: <input value={count} onInput={handleChange} />
      <br />
    </>
  );
}

And here's how to do it manually in Svelte (try it online):

<script>
    let count = 0;
    const handleClick = () => count  ;
    const handleChange = async e => {
    const userValue = e.target.value;
    let newValue = userValue? parseInt(userValue): 0;
    if (isNaN(newValue)) newValue = count;
    if (newValue === count)
        e.target.value = count; // undo the input
    else
        count = newValue;   
    };  
</script>

Count: <button on:click={handleClick}>{count}</button>
<br />
A: <input value={count} on:input={handleChange} />
<br />

Note Svelte also bind:value as an automatic option, similar to Blazor.

CodePudding user response:

Here's a modified version of your code that does what you want it to:

@page "/"
<label>Count:</label>
<button @onclick=Increment>@count times!</button>
<br>
A:
<input @oninput=OnChange value="@count">
<br>
B:
<input @bind-value=count @bind-value:event="oninput">

@code {
    int count = 1;

    void Increment() => count  ;

    async Task OnChange(ChangeEventArgs e)
    {
        var oldvalue = count;
        var isNewValue = int.TryParse(e.Value?.ToString(), out var v);
        if (isNewValue)
            count = v;
        else
        {
            count = 0;
            // this one line may precipitate a few commments!
            await Task.Yield();
            count = oldvalue;
        }

    }
}

So "What's going on?"

Firstly razor code is pre-compiled into C# classes, so what you see is not what actually gets run as code. I won't go into that here, there's plenty of articles online.

value="@count" is a one way binding, and is passed as a string.

You may change the actual value in the input on screen, but in the Blazor component the value is still the old value. There's been no callback to tell it otherwise.

When you type 22x after 22, OnChange doesn't update count. As far as the Renderer is concerned it hasn't changed so it don't need to update that bit of the the DOM. We have a mismatch between the Renderer DOM and the actual DOM!

OnChange changes to async Task and it now:

  • Gets a copy of the old value
  • If the new value is a number updates count.
  • If it's not a number
  1. Sets count to another value - in this case zero.
  2. Yields. The Blazor Component Event handler calls StateHasChanged and yields. This gives the Renderer thread time to service it's queue and re-render. The input in momentarily zero.
  3. Set count to the old value.
  4. Returns Task complete. The Blazor Component Event handler runs to completion calling StateHasChanged a second time. The Renderer updates the display value.

Update on why Task.Yield is used

The basic Blazor Component event handler [BCEH from this point] looks like this:

var task = InvokeAsync(EventMethod);
StateHasChanged();
if (!task.IsCompleted)
{
    await task;
    StateHasChanged();
}

Put OnChange into this context.

var task = InvokeAsync(EventMethod) runs OnChange. Which starts to run synchronously.

If isNewValue is false it's sets count to 0 and then yields through Task.Yield passing an incomplete Task back to BCEH. This can then progress and runs StateHasChanged which queues a render fragment onto the Renderer's queue. Note it doesn't actually render the component, just queues the render fragment. At this point BCEH is hogging the thread so the Renderer can't actually service it's queue. It then checks task to see if it's completed.

If it's complete BCEH completes, the Renderer gets some thread time and renders the component.

If it's still running - it will be as we've kicked it to the back of the thread queue with Task.Yield - BCEH awaits it and yields. The Renderer gets some thread time and renders the component. OnChange then completes, BCEH gets a completed Task, stacks another render on the Render's queue with a call to StateHasChanged and completes. The Renderer, now with thread time services it's queue and renders the component a second time.

Note some people prefer to use Task.Delay(1), because there's some discussion on exactly how Task.Yield works!

CodePudding user response:

Below is what I've come up with (try online). I wish ChangeEventArgs.Value worked both ways, but it doesn't. Without it, I can only think of this JS.InvokeVoidAsync hack:

@inject IJSRuntime JS

<label>Count:</label>
<button @onclick=Increment>@count times!</button>
<br>
A:
<input @oninput=OnChange value="@count" id="@id">
<br>
B:
<input @bind-value=count @bind-value:event="oninput">

@code {
    int count = 1;
    string id = Guid.NewGuid().ToString("N");

    void Increment() => count  ;

    async Task OnChange(ChangeEventArgs e)
    {
        if (int.TryParse(e.Value?.ToString(), out var v))
            count = v;
        else 
        {
            // this doesn't work
            e.Value = count;

            // using JS interop as a workaround 
            await JS.InvokeVoidAsync("eval",
                $"document.getElementById('{id}').value = Number('{count}')");
        }
    }
}
  • Related