In my last post, we looked at how we could build custom input components on top of InputBase. Using InputBase saves us loads of extra work by managing all of the interactions with the EditForm component and the validation system, which is excellent.

However, sometimes, there are situations where we want or need to have a bit more control over how our input components behave. In this post, we are going to look at how we can build input components from scratch.

Why build from scratch?

When you consider all the functionality that we get from Blazor’s out-of-the-box input components. Plus, the ability to make customisations using the InputBase class that we looked at last time. Why would we want to build input components from scratch?

The biggest reason I’ve found so far is the ability to use input components outside of an EditForm component. Any input component which uses InputBase has to be inside of an EditForm component; otherwise, an exception is thrown. That’s because of a check in the OnParametersSet method of InputBase. It checks for an EditContext which is cascaded down via the EditForm component.

if (CascadedEditContext == null)
{
    throw new InvalidOperationException($"{GetType()} requires a cascading parameter " + $"of type {nameof(Forms.EditContext)}. For example, you can use {GetType().FullName} inside " + $"an {nameof(EditForm)}.");
}

Building from scratch is also beneficial if you’re looking to have total control over how your component acts. By creating everything yourself, you’ll be able to tailor every detail to work precisely the way you want. For example, you could create an EditForm replacement, and that could require you to build custom input components.

Building from scratch

We’re going to build a simple text input component which can be used both inside or outside an EditForm component. Text inputs are quite useful components to be able to use both inside a form for any text-based entry. But are equally useful outside of a form for things like search boxes where validation isn’t necessarily a concern.

<input value="@Value" @oninput="HandleInput" />

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

    private async Task HandleInput(ChangeEventArgs args)
    {
        await ValueChanged.InvokeAsync(args.Value.ToString());
    }
}

This is the basic setup of our CustomInputText component. We have set up a couple of parameters, Value and ValueChanged. These allow us to use Blazor’s bind directive when consuming the control. We’ve hooked onto the input controls oninput event, and every time it fires the HandleInput event invokes the ValueChanged EventCallback to update the value for the consumer.

Working as part of EditForm

For an input component to work with EditForm, it has to integrate with EditContext. EditContext is the brain of a Blazor form; it holds all of the metadata regarding the state of the form. Things like whether a field has been modified. Is it valid? As well as a collection of all of the current validation messages.

EditContext is also responsible for raising events to signal that field values have been changed, or that an attempt has been made to submit the form. This triggers the validation aspect of the form.

Integrating with EditContext

To integrate with EditContext, we need to add a CascadingParameter to our component requesting it; then we need to create a FieldIdentifier.

The FieldIdentifier class uniquely identifies a specific field or property in the form. To create an instance, we need to pass in an expression which identifies the field our component is handling. To get this expression, we can add another parameter to our component called ValueExpression. Blazor populates this expression for us based on a convention in a similar way to two-way binding using Value and ValueChanged.

[Parameter] public Expression<Func<string>> ValueExpression { get; set; }

Now we have an expression we can create an instance of FieldIdentifier; we’ll do this in the OnInitialized life cycle method.

protected override void OnInitialized()
{
    _fieldIdentifier = FieldIdentifier.Create(ValueExpression);
}

We need to tell the EditContext when the value of our field has been updated. This will trigger any validation logic that needs to run against our field. We do this by calling the NotifyFieldChanged method on the EditContext.

private async Task HandleInput(ChangeEventArgs args)
{
    await ValueChanged.InvokeAsync(args.Value.ToString());
    CascadedEditContext?.NotifyFieldChanged(_fieldIdentifier);
}

Once the field has been validated it will be marked as either valid or invalid. We can use this value to assign CSS classes to the component and style it appropriately. To access these values we can use the following code.

private string _fieldCssClasses => _editContext?.FieldCssClass(_fieldIdentifier) ?? "";

This going to set the _fieldCssClasses field to some combination of modified valid or invalid, depending on the fields current state.

The final component looks like this.

<input class="_fieldCssClasses" value="@Value" @oninput="HandleInput" />

@code {

    private FieldIdentifier _fieldIdentifier;
    private string _fieldCssClasses => CascadedEditContext?.FieldCssClass(_fieldIdentifier) ?? "";

    [CascadingParameter] private EditContext CascadedEditContext { get; set; }

    [Parameter] public string Value { get; set; }
    [Parameter] public EventCallback<string> ValueChanged { get; set; }
    [Parameter] public Expression<Func<string>> ValueExpression { get; set; }

    protected override void OnInitialized()
    {
        _fieldIdentifier = FieldIdentifier.Create(ValueExpression);
    }

    private async Task HandleInput(ChangeEventArgs args)
    {
        await ValueChanged.InvokeAsync(args.Value.ToString());
        CascadedEditContext?.NotifyFieldChanged(_fieldIdentifier);
    }

}

Working without EditForm

Actually, we’ve already covered this one. You may have noticed on the last code snippet that we used the null-conditional operator (?.) when calling the NotifyFieldChanged method. The reason for this is that if the EditContext is null, then the method won’t be called.

Why would the EditContext be null? If the control wasn’t inside of an EditForm component. That simple check will allow the control to work outside of an EditForm component without any issue.

What are the costs?

When we use this component without the EditForm component we will no longer be able to use the standard validation mechanisms. Depending on your use case this may or may not matter.

For the use cases I’ve had, it doesn’t matter, things such as site searches or date pickers, things where I can use default values or not have to care. You could, of course, deal with this manually if you choose. You’re in complete control of the component after all.

For example, we could add a Required parameter to the component. When this is true, we can check if there is an EditContext. If there isn’t, we can set a private variable to show an error message if the current value is empty.

<input class="_fieldCssClasses" value="@Value" @oninput="HandleInput" />

@if (_showValidation)
{
    <div class="validation-message">You must provide a name</div>
}

@code {

    private FieldIdentifier _fieldIdentifier;
    private string _fieldCssClasses => CascadedEditContext?.FieldCssClass(_fieldIdentifier) ?? "";
    private bool _showValidation;

    [CascadingParameter] private EditContext CascadedEditContext { get; set; }

    [Parameter] public string Value { get; set; }
    [Parameter] public EventCallback<string> ValueChanged { get; set; }
    [Parameter] public Expression<Func<string>> ValueExpression { get; set; }
    [Parameter] public bool Required { get; set; }

    protected override void OnInitialized()
    {
        _fieldIdentifier = FieldIdentifier.Create(ValueExpression);
    }

    private async Task HandleInput(ChangeEventArgs args)
    {
        await ValueChanged.InvokeAsync(args.Value.ToString());

        if (CascadedEditContext != null)
        {
            CascadedEditContext.NotifyFieldChanged(_fieldIdentifier);
        }
        else if (Required)
        {
            _showValidation = string.IsNullOrWhiteSpace(args.Value.ToString());
        }
    }

}

If we don’t want the error message to be hardcoded, that’s cool too; we can add a parameter for the error message so that it can be passed in. The point here is that you can customise the behaviour as much as you like based on your needs.

Summary

In this post, we’ve looked at how we can build bespoke input components that work inside and outside of the EditForm component. We started by looking at why we would want to do this in the first place. Then we looked at how to integrate with the built in forms and validation system of Blazor. As well as how to make the component work without that system. Finally, we talked about some of the trade off of working outside of EditForm.