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.
7
Answers
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 thisJS.InvokeVoidAsync
hack:To be clear, I realize this is a horrible hack (last but not least because it uses
eval
); I could possibly improve it by usingElementReference
and an isolated JS import, but all of that wouldn't be necessary ife.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.Here’s a modified version of your code that does what you want it to:
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 updatecount
. 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 toasync Task
and it now:count
.count
to another value – in this case zero.StateHasChanged
and yields. This gives the Renderer thread time to service it’s queue and re-render. The input in momentarily zero.count
to the old value.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:
Put
OnChange
into this context.var task = InvokeAsync(EventMethod)
runsOnChange
. Which starts to run synchronously.If
isNewValue
is false it’s setscount
to 0 and then yields throughTask.Yield
passing an incomplete Task back to BCEH. This can then progress and runsStateHasChanged
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 checkstask
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 toStateHasChanged
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 howTask.Yield
works!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)
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-actionsUPDATE
An example of using preventDefault
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
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.
You can force recreating and rerendering of any element/component by changing value in
@key
directive: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:
The trick is, that @bind directive adds:
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.