Material.Blazor's Two Way Binding Approach
Material.Blazor takes an unusual approach to two way binding and rendering. Most components return ShouldRender() => false;
. This is
to resolve an inherent conflict between how Blazor re-renders components when bound parameters are updated by their consumers and
how a JavaScript framework such as Material Components Web ("MCW") works.
In a pure Blazor world, Blazor takes care of rendering and re-rendering for you. In doing so it overwrites the relevant part of the DOM. In a pure Material Theme world on the other hand, you mark your page up once and then call some instantiation JavaScript on your material web components, from which point Material Components Web manipulates the DOM with user interaction. Let's consider an empty text field.
This article explains how Blazor's natural rendering mechanism and MCW are in contention and what we do to manage this gracefully.
How MCW Manipulates the DOM
Consider an empty outlined text field before and after a user gives it focus as below. Note how the label floats up and how both it and the border gain color:
The text field's markup however has three distinct state. First what any app environment (including your app using Material.Blazor) marks up in a page, then what this becomes once the text field has been initiated and manipulated by MCW and lastly how its state after receiving focus. The transition between the last two is animated by MCW.
- Uninitiated markup
<label class="mdc-text-field mdc-text-field--outlined" > <input id="my-text-field" class="mdc-text-field__input" type="text" aria-label="Outlined Style"> <span class="mdc-notched-outline"> <span class="mdc-notched-outline__leading"></span> <span class="mdc-notched-outline__notch"> <span class="mdc-floating-label " for="my-text-field">Outlined Style</span> </span> <span class="mdc-notched-outline__trailing"></span> </span> </label>
- Markup post initiation - all changes made by MCW
<label class="mdc-text-field mdc-text-field--outlined"> <input id="my-text-field" class="mdc-text-field__input" type="text" aria-label="Outlined Style"> <span class="mdc-notched-outline mdc-notched-outline--upgraded"> <span class="mdc-notched-outline__leading"></span> <span class="mdc-notched-outline__notch"> <span class="mdc-floating-label " for="my-text-field" style>Outlined Style</span> </span> <span class="mdc-notched-outline__trailing"></span> </span> </label>
- Markup with focus - all changes made by MCW
<label class="mdc-text-field mdc-text-field--outlined mdc-text-field--focused mdc-text-field--label-floating"> <input id="my-text-field" class="mdc-text-field__input" type="text" aria-label="Outlined Style"> <span class="mdc-notched-outline mdc-notched-outline--upgraded mdc-notched-outline--notched"> <span class="mdc-notched-outline__leading"></span> <span class="mdc-notched-outline__notch"> <span class="mdc-floating-label mdc-floating-label--float-above" for="my-text-field" style="width: 87.5px;">Outlined Style</span> </span> <span class="mdc-notched-outline__trailing"></span> </span> </label>
What we see from this is that if Material.Blazor were to allow Blazor to re-render, this would be with markup similar to 1. above and MCW will then fail to manipulate the DOM correctly thereafter - we know this because we tried.
Other Blazor Issues
Another factor we faced is that Blazor can perform a "bounce" when passing values into a component. This can be observed when using Cascading Values; see this GitHub issue explaining the problem and receiving a detailed response from Steve Sanderson. Coupling this with the need to prevent Blazor rendering brought us to the two way binding pattern that we use.
How We Do Two Way Binding
When we first identified this issue during Material.Blazor's development we realized that our Blazor code needed to first render a
new component and then immediately after instantiating it step out of MCW's way by applying ShouldRender() => false;
in InputComponent
. If we did no more, two way binding would be broken because subsequent attempts by the consumer (your project)
to update Value are ignored, which is an unacceptable result. Fortunately each MCW component has a rich
JavaScript library that includes both the ability to set a value in the future and to be notified of that value being changed by
the user. We make use of this in our two way binding for the Value
parameter, and do something similar for the Disabled
parameter.
The principle is like this:
Each component inheriting from InputComponent
implements this mechanism separately calling the JavaScript provided
by MCW for that component:
- Overrides
OnParametersSet
fromInputComponent
to call the relevant MCW code via JSInterop; - Registers a JSInterop callback with MCW for notification of value changes;
- Overrides
OnDisabledSet
fromComponentFoundation
for to either call MCW via JSInterop to set the disabled state or to do so directly via Blazor binding as relevant; and - Does not bind Value to any elements in the razor markup except for
MBTextField
which binds directly to the'<input>
block.
Without this careful mechanism Blazor and MCW can enter an infinite positive feedback loop of values bouncing from current to previous value.
Debouncing
One last thing. In the GitHub issue that we mentioned above, Value
can bounce. We handle this by over-riding OnParametersSet()
and OnParametersSetAsync()
and checking for changes against cached versions of both
Value
and ComponentValue
. Material.Blazor only propagate changes to Value
when it is indicative of a genuine consumer two-way binding
data update:
- First there are cached versions of both
Value
andComponentValue
. - In
OnParametersSet()
, ifValue
differs to_cachedValue
we know that there is a genuine change of value (i.e. not a Cascading Value derived bounce).- If there is no change Material.Blazor does nothing more in
OnParametersSet()
. - If there is a change,
_cachedValue
is update and we move to the next test.
- If there is no change Material.Blazor does nothing more in
- Next we test
Value
againstComponentValue
:- If they are the same, then we know that the component has been called in response to
ComponentValue
having emitted aValueChanged
event and we exitOnParametersSet()
. - If they differ, we now know that this is a genuine new value. Material.Blazor sets
_cachedComponentValue
toValue
and calls the component's value setter via JSInterop.
- If they are the same, then we know that the component has been called in response to
When the user interacts with the app changing a component's value, this is emitted via JSInterop to set ComponentValue
- First this new value is compared by the setter for
ComponentValue
to_cachedComponentValue
further action is only taken if they differ. - Next
_cachedComponentValue
is updated accordingly. - Last the consumer code is notified of value changes by calling
ValueChanged()
. - Note that Material.Blazor never sets
Value
in response to user/component interaction - this is the pattern set out by Steve Sanderson.