8

In Blazor, how can I undo invalid user input, without changing the state of the component to trigger a re-render?

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)
    {
        var userValue = e.Value?.ToString(); 
        if (int.TryParse(userValue, out var v))
        {
            count = v;
        }
        else 
        {
            if (String.IsNullOrWhiteSpace(userValue))
            {
                count = 0;
            }

            // if count hasn't changed here,
            // I want to re-render "A"

            // this doesn't work
            e.Value = count.ToString();

            // this doesn't work either 
            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 doesn't actually change.

So, basically I need to re-render A to undo invalid user input, even thought the state hasn't changed. How can I do that without the bind-xxx magic?

Sure, bind-xxx is great, but there are cases when a non-standard behavior might be desired, built around a managed event handler like ChangeEvent.


Updated, to compare, here's how I could have done it in React (try it online):

function App() {
  let [count, setCount] = useState(1);
  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;
    // re-render even when count hasn't changed
    setCount(newValue); 
  };
  return (
    <>
      Count: <button onClick={handleClick}>{count}</button><br/>
      A: <input value={count} onInput={handleChange}/><br/>
    </>
  );
}

Also, here's how I could have done it in Svelte, which I find conceptually very close to Blazor (try it online).

<script>
  let count = 1;
  const handleClick = () => count++;
  const handleChange = 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 user input
    else
      count = newValue; 
    }
  };    
</script>

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

Updated, to clarify, I simply want to undo whatever I consider an invalid input, retrospectively after it has happened, by handling the change event, without mutating the component's state itself (counter here).

That is, without Blazor-specific two-way data binding, HTML native type=number or pattern matching attributes. I simply use the number format requirement here as an example; I want to be able to undo any arbitrary input like that.

The user experience I want (done via a JS interop hack): https://blazorrepl.telerik.com/wPbvcvvi128Qtzvu03

Surprised this so difficult in Blazor compared to other frameworks, and that I'm unable to use StateHasChanged to simply force a re-render of the component in its current state.

noseratio
  • 59,932
  • 34
  • 208
  • 486
  • 1
    You can, but it still won't change the `value` because that hasn't changed. You can force a new instance of the input, but then you have to manually handle focus state. I would want a very good reason to put the effort in to a solution when "the `@bind-xxx` magic" is there to achieve what you want. – Mister Magoo Nov 18 '21 at 09:55
  • 1
    @MisterMagoo, currently it's a learning exercise to better understand the component elements life cycle. Nevertheless, I can think of cases when I may want to do custom data binding via event handlers like that. – noseratio Nov 18 '21 at 10:04
  • Well, you've picked a tricky example because there is a disconnect between the type of `count` and the type of `input`. `count` doesn't change when you type a letter, so you would need to replicate the "magic" and use a string representation of `count` as the `value` of the input, rather than `count` itself - then when you change the string, it will refresh the input – Mister Magoo Nov 18 '21 at 10:55
  • 1
    @MisterMagoo is it *really* so uncomment to have a different type between a state property and a corresponding UI input element? I've updated the question with a Rect example, to show what I'm trying to achieve. – noseratio Nov 18 '21 at 11:31
  • 2
    I can't comment on how common things are, but I would say the React example is not using strongly typed values, so effectively is using a string for count and "magically" binding it for you...like Blazor would do if you let it – Mister Magoo Nov 18 '21 at 13:18
  • 1
    It's not about types, I could do the same with TypeScript in React. The difference is that I can request a re-render in Rect with `setCount`, even if `count` hasn't changed. I'm surprised I can't find a similar feature in Blazor. – noseratio Nov 18 '21 at 13:25
  • 1
    Blazor is efficient and will not update the DOM if it doesn't think it needs to - by using an integer that hasn't changed for the `value` , you are falling "victim" of that efficiency. You can force the value to change by using a different type for the UI (which it is - input works with strings) than the state. – Mister Magoo Nov 18 '21 at 14:19
  • 1
    @noseratio: It would be interesting to see how React solved this problem. You call setState, it will trigger render of VDOM, but since there is no diff of VDOM before and after, how do they know what to update in the actual DOM? – Liero Nov 29 '21 at 06:17
  • @Liero, that's a good question. I think what they do is similar to 2-way Blazor bindings. I've since then found out that I don't even have to call `setState` when `count` doesn't change. The input will be undone automatically: https://stackblitz.com/edit/react-js-akfkqw?file=index.js – noseratio Nov 29 '21 at 08:05
  • If I didn't use JSX and its event binding magic, and (say) created an input element with `document.createElement`, then manually added `input` event handler from inside a `useEffect` callback, then I think it would be a totally different story :) – noseratio Nov 29 '21 at 08:15
  • In you react example `setCount` created by `useState` uses `setState` internally – Liero Nov 29 '21 at 08:42
  • @Liero, it does indeed, but [here](https://stackblitz.com/edit/react-js-akfkqw?file=index.js) I only call `setCount` (and hence `setState`) if `newValue !== count` is true. If the value stays the same, React still reconciles the DOM with the current (unchanged) value of `count`, despite I don't call `setCount` or do anything else in this case. – noseratio Nov 29 '21 at 09:41
  • Also, unlike with Blazor, I can see the new `value` attribute in DevTools. Looks like React calls `setAttribute("value")`, while Blazor only does `element.value = xxx` inside their automatic bindings. – noseratio Nov 29 '21 at 09:42

7 Answers7

4

You can use @on{DOM EVENT}:preventDefault to prevent the default action for an event. For more information look at Microsoft docs: https://learn.microsoft.com/en-us/aspnet/core/blazor/components/event-handling?view=aspnetcore-6.0#prevent-default-actions

UPDATE

An example of using preventDefault

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

@code {
    int count = 1;
    string input = "";

    void Increment() => count++;

    private void KeyHandler(KeyboardEventArgs e)
    {
        //Define your pattern
        var pattern = "abcdefghijklmnopqrstuvwxyz";
        if (!pattern.Contains(e.Key) && e.Key != "Backspace")
        {
            //This is just a sample and needs exception handling
            input = input + e.Key;
            count = int.Parse(input);
        }
        if (e.Key == "Backspace")
        {
            input = input.Remove(input.Length - 1);
            count = int.Parse(input);
        }
    }
}
ggeorge
  • 1,496
  • 2
  • 13
  • 19
  • I'm not sure how `preventDefault` may help here, could you elaborate? Here's the fiddle: blazorrepl.telerik.com/cvlPclQd056arTDH01, use your idea to make box `A` behave like box `B` if something like `123abc` is typed in. The goal is to do it using `oninput` event handler (i.e. without Blazor `bind-xxx` data biding or HTML input type/patterns). If you get it working, please click Share and come with back a link. Thank you! – noseratio Nov 22 '21 at 02:01
  • 2
    @noseratio I did not realize that you need only `oninput` event handler. It turns out that prevent default won't work with `oninput`. This could work i.e. with `onkeydown`. – ggeorge Nov 22 '21 at 07:21
  • 1
    I think I might have not been fully clear (I tried:) but the core of my question is about how to re-render a Blazor component in its current state. I.e, if the input is invalid, I'm not updating `counter` but rather want to re-render with the current (valid) `counter`. – noseratio Nov 22 '21 at 07:28
  • 1
    @noseratio check this: https://blazorrepl.telerik.com/GPlPQwFJ57jvjRT005 – ggeorge Nov 22 '21 at 15:57
  • Keep in mind there's also copy/paste, drag & drop with mouse, a11ty and screen readers. Having read [the link](https://github.com/dotnet/aspnetcore/issues/17099#issuecomment-556825048) shared by @RyoukoKonpaku I think a proper solution may not exist yet in Blazor. – noseratio Nov 22 '21 at 21:02
3

So, basically I need to re-render A to undo invalid user input, even thought the state hasn't changed. How can I do that without the bind-xxx magic?

You can force recreating and rerendering of any element/component by changing value in @key directive:

<input @oninput=OnChange value="@count" @key="version" />
void OnChange(ChangeEventArgs e)
{
    var userValue = e.Value?.ToString(); 
    if (int.TryParse(userValue, out var v))
    {
        count = v;
    }
    else 
    {
        version++;
        if (String.IsNullOrWhiteSpace(userValue))
        {
            count = 0;
        }         
   }
}

Notice, that it will rerender the entire element (and it's subtree), not just the attribute.


The problem with your code is, that the BuildRendrerTree method of your component generates exactly the same RenderTree, so the diffing algorithm doesn't find anything to update in the actual dom.

So why the @bind directive works?

Notice the generated BuildRenderTree code:

//A
__builder.OpenElement(6, "input");
__builder.AddAttribute(7, "oninput", Microsoft.AspNetCore.Components.EventCallback.Factory.Create<Microsoft.AspNetCore.Components.ChangeEventArgs>(this, OnChange));
__builder.AddAttribute(8, "value", count);
__builder.CloseElement();

//B
__builder.AddMarkupContent(9, "<br>\r\nB: ");
__builder.OpenElement(10, "input");
__builder.AddAttribute(11, "value", Microsoft.AspNetCore.Components.BindConverter.FormatValue(count));
__builder.AddAttribute(12, "oninput", Microsoft.AspNetCore.Components.EventCallback.Factory.CreateBinder(this, __value => count = __value, count));
__builder.SetUpdatesAttributeName("value");
__builder.CloseElement();

The trick is, that @bind directive adds:

__builder.SetUpdatesAttributeName("value");

You can't do this in markup for EventCallback right now, but there is an open issue for it: https://github.com/dotnet/aspnetcore/issues/17281

However you still can create a Component or RenderFragment and write the __builder code manually.

Liero
  • 25,216
  • 29
  • 151
  • 297
  • Thanks for the answer, very helpful. I've played with `@key` previously as a [part of the related Twitter discussion](https://twitter.com/noseratio/status/1462559049115860999?s=20), it can be useful for re-rendering static parts, but I did rule it out in this case, because of the focus issue as you've mentioned. – noseratio Nov 27 '21 at 03:08
  • Ideally, we'd need a reconciliation without removal and re-creation. Maybe it will be implemented one day: https://github.com/dotnet/aspnetcore/issues/17281 – noseratio Nov 27 '21 at 03:12
  • 1
    Yes, I have mentioned the issue in the answer as well. You still might create custom component, use JS interop (without eval) or handle such logic in javascript. I would recommend custom InputBase implementation, because it has ValueAsString and typed Value property, and overridable methods for conversion. Instead of reseting the input, it goes to error validation state. Imho better ux. – Liero Nov 27 '21 at 08:02
  • 1
    BTW, I have solved the focus issue in javascript, where I detect rerendering (editform in my case). I listen to focusout event, and check whether the focused element was removed from dom. If yes, I try to find equivalent in the updated dom and set focus – Liero Nov 27 '21 at 08:52
1

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!

MrC aka Shaun Curtis
  • 19,075
  • 3
  • 13
  • 31
  • Thanks, that certainly does it, but the side effect it is that quite visible flickering caused by double re-render: https://blazorrepl.telerik.com/mlFllCvt16Cwkcip08. Type `22x` inside A, then inside B to compare, to see what I mean. – noseratio Nov 18 '21 at 19:19
  • I'm not sure how `Task.Yield` works inside Blazor/WASM. Normally it's a [wrapper around `SynchronizationContext.Post`](https://stackoverflow.com/a/22652690/1768303), but in this execution environment, it probably maps to `setTimeout`, which is enough to cause flickering. – noseratio Nov 18 '21 at 19:23
  • @noseratio - see my additional comments to the answer your query on what's going on under the hood in Blazor Component event handling. The "flicker" is caused by the render events. I normally use the Blazor InputBase controls or build my own versions. – MrC aka Shaun Curtis Nov 18 '21 at 20:14
  • Thanks, I think I understand well how async and Yield works :) BTW, this approach doesn't work for `0xxxx`, you'd need something like `Int.MaxValue` instead of `0`, but that's still a workaround. My point is, the flickering is still there, be it `Yield` or `Task.Delay(1)`, because we do a redundant property change. There should be a better way of undoing invalid user input, without double re-rendering. May be I should study how `bind-xxx` is implemented. – noseratio Nov 18 '21 at 20:51
1

From looking at the code, it seems you wanna sanitize user input? E.g. can enter only numbers, date formats etc... I agree it's kinda hard if you wanna use the event handlers manually in this regard at the moment, but it's still possible to validate input using expanded properties and Blazor's binding system.

What you want would look like this: (try it here)

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

@code {
    int _count = 1;
    public string Count 
    {
        get => _count.ToString();
        set 
        {
            if(int.TryParse(value, out var val)) {
                _count = val;
            }
            else {
                if(string.IsNullOrEmpty(value)) {
                    _count = 0;
                }
            }
        }
    }

    void Increment() => _count++;
}
Ryouko Konpaku
  • 136
  • 1
  • 7
  • That's not what I'm looking for, sorry. I specifically have exactly this piece of code (``) in my question to illustrate what I'm asking about. Otherwise, I could have simply used `type="number"`, which is HTML standard. – noseratio Nov 21 '21 at 17:56
  • I want to undo whatever I consider invalid input, retrospectively after it has happened, simply by handling the change event. I.e., *without* mutating the component's state (`counter` in this case) and *without* Blazor 2-way data binding attributes or HTML native input pattern filters. I use the number requirement here as an example; I want to be able to undo any arbitrary user input like that. – noseratio Nov 21 '21 at 18:42
  • The difference between the sample in the question is that the actual type of `Count` is `string` instead of `int` and we're storing the real state of `Count` on the `_count` field. This way you have free control on parsing whatever validation logic on the `set` body as per above, e.g. if I type the letter "Z" increment count by 10 etc... So instead of "undo" we're simply deciding if we wanna commit the input from the user or not in this case. Since the type of the property is `string` Blazor's binding system won't do anything and will act in the same way as the `@oninput` event handler. – Ryouko Konpaku Nov 21 '21 at 23:59
  • I might be missing a point, but It works as is without making it `String`, if you use `bind-xxx` 2-ways data binding. I mentioned that in the question, try typing inside box `B:` https://blazorrepl.telerik.com/cvlPclQd056arTDH01. I'm interested to make it work *without* data binding, using 1-way flow: rendering + `oninput` event handler (see box `A:`). Do you think you can change your snippet to work like that? Tks. – noseratio Nov 22 '21 at 00:50
  • 1
    At the moment this isn't easy to pull off using regular event handlers without `@bind-value` sadly. You can check this [github comment](https://github.com/dotnet/aspnetcore/issues/17099#issuecomment-556825048) from the developers that explains the issue and also future feature to fix it that they're planning. Right now the cleanest implementation is using the `@bind` + expanded properties like above. The idea why I made it `string` on the sample is because you'll have full freedom on parsing the input to whatever type you'd want (e.g. case where count isn't an `int`, but a `Guid`, CC etc...). – Ryouko Konpaku Nov 22 '21 at 13:55
1

You will have to use @on{DOM EVENT}-preventDefault.

It is coming from the javascript world and it prevents the default behavior of the button.

In blazor and razor, validation or DDL trigger postback, which means the request is processed by the server and re-renders.

When doing this, you need to make sure your event does not 'bubble' as you are preventing from the event to perform postback.

If you find your event bubbling, meaning element event going up the elements it is nested in, please use:

stopPropagation="true";

for more info:

Suppressing events in blazor

Barr J
  • 10,636
  • 1
  • 28
  • 46
  • Are you suggesting something different from @ggeorge's [answer](https://stackoverflow.com/a/70059744/1768303)? BTW, we're talking about Blazor/WASM here, not Blazor/Server. – noseratio Nov 24 '21 at 08:48
  • Added on top just another thing to notice – Barr J Nov 24 '21 at 09:42
1

This was a really interesting question. I've never used Blazor before, but I had an idea about what might help here, albeit this is a hacky answer too.

I noticed if you changed the element to bind to the count variable, it would update the value when the control lost focus. So I added some code to swap focusing elements. This seems to allow typing non-numeric characters without changing the input field, which is what I think is desired here.

Obviously, not a fantastic solution but thought I'd offer it up in case it helps.

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

@code {
    ElementReference textInput1;
    ElementReference textInput2;
    int count = 1;

    void Increment() => count++;

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

            textInput2.FocusAsync();        
            textInput1.FocusAsync();
        }
    }
}
Ryan Wildry
  • 5,612
  • 1
  • 15
  • 35
0

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)
    {
        var userValue = e.Value?.ToString(); 
        if (int.TryParse(userValue, out var v))
        {
            count = v;
        }
        else 
        {
            if (String.IsNullOrWhiteSpace(userValue))
            {
                count = 0;
            }

            // this doesn't work
            e.Value = count.ToString();

            // this doesn't work either (no rererendering):
            StateHasChanged();           

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

To be clear, I realize this is a horrible hack (last but not least because it uses eval); I could possibly improve it by using ElementReference and an isolated JS import, but all of that wouldn't be necessary if e.Value = count just worked, like it does with Svelte. I've raised this as a Blazor issue in ASP.NET repo, hopefully it might get some attention.

noseratio
  • 59,932
  • 34
  • 208
  • 486