Skip to content

A demo using records to bind complex object models to Blazor components

Notifications You must be signed in to change notification settings

UniMichael/blazor-record-binding-demo

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 

Repository files navigation

Blazor record binding demo

This project is a small demo showcasing an approach to data binding in Blazor using record objects and immutable collections.

It has limitations that make it a deal-breaker for any serious applications, but it's a fun example of how Blazor could work if it were to properly support immutable record models as first-class citizens in the future.

Limitations

Before we get started, you should know that there is a major problem with this approach:

Blazor's built-in form validation will not work, due to how it uses FieldIdentifiers internally. Whenever we create a copy of a record, its validation state will be lost.

It's technically possible to make this implementation work if you write your own <form> wrapper and avoid using Blazor's built-in <EditForm> component, but this isn't covered in this demo.

Motivation

Imagine you have a non-trivial data model. It's an object that may have nested objects (including collections, which may also contain other objects). This isn't an uncommon situation.

You want to make a reusable component to which you can bind your model and have it control the various values of the model's properties. You want to avoid having to bind each individual property every time you use the component.

Ideally, you could simply use something like @bind-Value="_model"". Supporting @bind-{PARAMETER} also means the component should properly support letting you define your own @bind-{PARAMETER}:get and @bind-{PARAMETER}:set bindings, as well as @bind-{PARAMETER}:after if you don't care how the data is bound, but still want to do something whenever it changes.

How this demo works

Data flow

A parent component/page binds a value to a child component. When this component does something that should change this value, the component notifies the parent that the value should be changed. The parent then updates the value and re-renders.

That's how [Parameter] and EventCallback<T> work. A component should not update a parameter directly by using its setter. Instead, it should notify the parent component/page using EventCallback<T>.InvokeAsync(newValue).

Records

In our case, we want to bind a complex object, and be able to deal with collections. However, because of how data binding works, we can only notify the parent that the object itself has changed.

Enter record (specifically: record class). Using record, we're able to easily create copies of our current parameter, and specify which properties should be different using the with keyword.

Example:

SomeRecord.cs

public record SomeRecord(string Property);

SomeRecordComponent.cs

<input @bind:get="Value.Property" @bind:set="HandlePropertyChangedAsync" />

@code {
    [Parameter]
    public SomeRecord Value { get; set; }
    
    [Parameter]
    public EventCallback<SomeRecord> ValueChanged { get; set; }

    private async Task HandlePropertyChangedAsync(T newValue)
    {
        var nextState = state with { Property = newValue };
        await StateChanged.InvokeAsync(nextState);
    }
}

We're passing a whole new record object, and we're not updating the original object's properties directly.

Immutable collections

On top of using records for our models, we also use immutable collections (i.e. ImmutableList). They prevent us from accidentally modifying the original collection. All methods that would mutate a regular collection return a new copy of an immutable collection.

References

Since records are init-only by default, they're effectively immutable, but record classes are still passed by reference.

Isn't this a problem?

No, because whenever we mutate a record, we'll have to create a copy. In the meantime, untouched record objects stay the same, so any references to them won't cause problems.

The same goes for immutable collections and immutable collections of record instances. Whenever we update a collection we create a copy of it, so if a collection's reference stays the same: we haven't modified it or its children.

Event bubbling

Child components that deal with nested objects will need to notify their parents whenever a change occurs. Their parents will need to notify their parent, and so on. Otherwise, the top-level parent has no way of knowing that its model was changed.

Consider the following code:

public record Foo(int Id, string Name);

public record Bar(int Id, string Name, Foo foo);

If you have a model of type Bar in a component, and you bind it to a component, that component has no way of knowing whether another component depends on the model. If a child component changes the Foo portion of the model, the Bar component needs to be notified, and the Bar component needs to notify its parent so that the parent can properly re-render components that also depend on the model.

This event bubbling looks like this:

FooComponent.razor

<input @bind:get="Foo.Name" @bind:set="HandleNameChangedAsync" />

@code {
    [Parameter]
    public Foo Foo { get; set; }
    
    [Parameter]
    public EventCallback<Foo> FooChanged { get; set; }
    
    // Foo changes bubble up to parent component.
    private async Task HandleNameChangedAsync(string name) =>
        await FooChanged.InvokeAsync(Foo with { Name = name });
}

BarComponent.razor

<input @bind:get="Bar.Name" @bind:set="HandleNameChangedAsync" />
<FooComponent @bind-Foo:get="Bar.Foo" @bind-Foo:set="HandleFooChangedAsync" />

@code {
    [Parameter]
    public Bar Bar { get; set; }
    
    [Parameter]
    public EventCallback<Bar> BarChanged { get; set; }
    
    // Bar changes bubble up to parent component.
    private async Task HandleNameChangedAsync(string name) =>
        await BarChanged.InvokeAsync(Bar with { Name = name });
        
    // Convert Foo changes to Bar changes and bubble up to parent component.
    private async Task HandleFooChangedAsync(Foo foo) =>
        await BarChanged.InvokeAsync(Bar with { Foo = foo });
}

Page.razor

<BarComponent @bind-Bar="_model" />

@code {
    private Bar _model = new Bar(1, "Bar", new Foo(2, "Foo"));
}

First-class support for two-way data binding

Because this works using the standard two-way data binding syntax (2 [Parameter]s, 1 value, 1 EventCallback), we're able to re-use these individual components. We're also able to override how their change event handling works by defining our own @bind-{PARAMETER}:get/set pair.

Working with copies

Remember, we're working with copies here, so the values that bubble up the component tree aren't the same as the ones in the component's model. This is a good thing, because it gives us access to the "next" state, while keeping our current (or "previous") state intact.

This means event handlers can handle logic that needs to compare the previous state to the next one.

About

A demo using records to bind complex object models to Blazor components

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published