This article covers the problem and solution to enable Blazor Components to allow callbacks, with optional data, from an external template.
The Problem
I was working on a Blazor Component that needed to support external button(s) in the footer template that interacted with method(s) in the Component.
Typically, the Component would look something like:
<div>
<h1>@HeaderText</h1>
<p>@BodyText</p>
@if (FooterTemplate is not null)
{
@FooterTemplate
}
</div>
@code {
[Parameter]
public string HeaderText { get; set; }
[Parameter]
public string BodyText { get; set; }
[Parameter]
public RenderFragment? FooterTemplate { get; set; }
private void OnClicked()
{
// do something here
}
}
Then we would use the Component something like:
<ComponentName>
<FooterTemplate>
<button @onclick="OnClicked">Close</button>
</FooterTemplate>
</ComponentName>
The issue here is that the button @onclick
will call a local method and not call the method in the component.
The Solution
This article focuses on a solution using RenderFragment<>
and EventCallback
/EventCallback<>
to enable calling methods in the component from external templates. We will also look at how to pass parameters back using the same solution.
TL;DR
Downloadable code for the solution can be found at the end of the article.
EventCallback
- Official Definition: A bound event handler delegate.
- Blazor University: The
EventCallback
class is a special Blazor
class that can be exposed as a Parameter so that components can easily notify consumers when something of interest has occurred. When building Components with Data binding, we use an EventCallback
to notify that a property has changed.
RenderFragment
Official Definition: A RenderFragment
represents a segment of UI to render. RenderFragment<TValue>
takes a type parameter that can be specified when the render fragment is invoked.
Implementation
When we look at the code for the constructor and the InvokeAsync
method in EventCallback
class, it is defined as follows:
public EventCallback(IHandleEvent? receiver, MulticastDelegate? @delegate)
{
this.Receiver = receiver;
this.Delegate = @delegate;
}
public Task InvokeAsync(object? arg)
=> this.Receiver == null
? EventCallbackWorkItem.InvokeAsync<object>(this.Delegate, arg)
: this.Receiver.HandleEventAsync(
new EventCallbackWorkItem(this.Delegate), arg);
public Task InvokeAsync() => this.InvokeAsync((object) null);
What interests us here is that we can pass a method in on initialization and pass parameters (optionally) when invoked.
RenderFragment<TValue>
allows us to expose objects/classes to the Template for the Component. We can now change the above problem code as follows:
- Component:
<div>
<h1>@HeaderText</h1>
<p>@BodyText</p>
@if (FooterTemplate is not null)
{
@FooterTemplate(new EventCallback(null, OnCallbackClicked))
}
</div>
@code {
[Parameter]
public string HeaderText { get; set; }
[Parameter]
public string BodyText { get; set; }
[Parameter]
public RenderFragment<EventCallback>? FooterTemplate { get; set; }
private void OnCallbackClicked()
{
// do something here
}
}
- Usage:
<ComponentName>
<FooterTemplate>
<button @onclick="async ()
=> await context.InvokeAsync().ConfigureAwait(false)">
Close
</button>
</FooterTemplate>
</ComponentName>
We can simplify this code using Method Group:
<ComponentName>
<FooterTemplate>
<button @onclick="context">
Close
</button>
</FooterTemplate>
</ComponentName>
So How Does This Work?
context
is the EventCallback
passed from the Component to the Template. When the Template button is pressed, the InvokeAsync
method is invoked on the context
and executes the delegate OnCallbackClicked
method in the Component.
With the Method Group simplification above, the compiler automagically knows to call the InvokeAsync
on the context
(EventCallback
) class as defined in the EventCallback
class..
What if We Want to Pass Parameters Back to the Component?
We use the generic EventCallback<T>
to pass one (1) or more parameters. For this, we will use an argument class.
- Arguments:
public interface IElementCallbackArgs
{
}
public class MessageCallbackArgs : IElementCallbackArgs
{
public string? Message { get; set; }
}
- Component:
<div>
<h1>@HeaderText</h1>
<p>@BodyText</p>
@if (FooterTemplate is not null)
{
@FooterTemplate(
new EventCallback<IElementCallbackArgs>(
null,
new Action<MessageCallbackArgs> (args => OnCallbackClicked(args))))
}
</div>
@code {
[Parameter]
public string HeaderText { get; set; }
[Parameter]
public string BodyText { get; set; }
[Parameter]
public RenderFragment<EventCallback<IElementCallbackArgs>>?
FooterTemplate { get; set; }
private void OnCallbackClicked(MessageCallbackArgs args)
{
// do something here
}
}
Again, we can use Method Groups to simplify the code:
<div>
<h1>@HeaderText</h1>
<p>@BodyText</p>
@if (FooterTemplate is not null)
{
@FooterTemplate(
new EventCallback<IElementCallbackArgs>(
null,
OnCallbackClicked))
}
</div>
- Usage:
<ComponentName>
<FooterTemplate>
<button @onclick="async () => await OnClickedAsync(context)">
Close
</button>
</FooterTemplate>
</ComponentName>
@code {
private static async Task OnClickedAsync(
EventCallback<IElementCallbackArgs> callback)
=> await callback.InvokeAsync(
new MessageCallbackArgs
{
Message = "message goes here"
}).ConfigureAwait(false);
}
So like the first callback only, here we are doing the same invocation, however we are passing back data specific to the event / button press.
Improvements
The code as-is works as expected. What we can do is encapsulate the EventCallback
in a wrapper interfaces and classes. The code below is a base implementation that allows to be expanded upon.
- Definition:
public interface IElementCallback
{
EventCallback Execute { get; }
}
public interface IElementCallback<T>
{
EventCallback<T> Execute { get; }
}
- Implementation:
public class ElementCallback : IElementCallback
{
public ElementCallback(MulticastDelegate @delegate)
=> Execute = new EventCallback(null, @delegate);
public EventCallback Execute { get; }
}
public class ElementArgsCallback : IElementArgsCallback
{
public ElementArgsCallback(MulticastDelegate @delegate)
=> Execute = new EventCallback<IElementCallbackArgs>(null, @delegate);
public EventCallback<IElementCallbackArgs> Execute { get; }
}
Example 1 - Basic
The following example has a button inside the component and a button in the template. This is to simulate when a component could have a preset button or allow for an optional custom button.
- Component:
BasicComponent
<button type="button"
class="btn btn-primary me-4"
@onclick="ButtonClicked">
OK
</button>
@if (ContentTemplate is not null)
{
@ContentTemplate(new ElementCallback(OnCallbackClicked))
}
<hr />
<ul>
@if (!Messages.Any())
{
<li>No buttons clicked...</li>
}
else
{
@foreach (string message in Messages)
{
<li>@message</li>
}
}
</ul>
<hr />
@code {
[Parameter]
public RenderFragment<IElementCallback>? ContentTemplate { get; set; }
private void ButtonClicked()
=> Clicked($"Button clicked in {nameof(BasicComponent)}");
private void OnCallbackClicked()
=> Clicked("External button clicked");
private readonly IList<string> Messages = new List<string>();
private void Clicked(string message)
{
Messages.Add(message);
InvokeAsync(StateHasChanged);
}
}
- Implementation:
<h2>Example 1 - Simple ElementCallback</h2>
<BasicComponent>
<ContentTemplate>
<button type="button" class="btn btn-outline-success"
@onclick="context.Execute">
Template OK
</button>
</ContentTemplate>
</BasicComponent>
- Output:
The demonstration projects WasmComponentCallback1
and ServerComponentCallback1
can be found in the download below.
Example 2 - Arguments
This example expands on the first and passes back a message to the component.
- Component:
BasicComponent
<button type="button"
class="btn btn-primary me-4"
@onclick="ButtonClicked">
OK
</button>
@if (ContentTemplate is not null)
{
@ContentTemplate(new ElementArgsCallback(OnCallbackClicked))
}
<hr />
<ul>
@if (!Messages.Any())
{
<li>No buttons clicked...</li>
}
else
{
@foreach (string message in Messages)
{
<li>@message</li>
}
}
</ul>
<hr />
@code {
[Parameter]
public RenderFragment<IElementArgsCallback>? ContentTemplate { get; set; }
private void ButtonClicked()
=> Clicked($"Button clicked in {nameof(ArgsComponent)}");
private void OnCallbackClicked(MessageCallbackArgs args)
=> Clicked(args.Message ?? "External button clicked");
private readonly IList<string> Messages = new List<string>();
private void Clicked(string message)
{
Messages.Add(message);
InvokeAsync(StateHasChanged);
}
}
- Implementation:
<h2>Example 2 - Message ElementCallback</h2>
<ArgsComponent>
<ContentTemplate>
<button type="button" class="btn btn-outline-success"
@onclick="@(async () => await ClickedAsync(context.Execute))">
Template OK
</button>
</ContentTemplate>
</ArgsComponent>
@code {
private int _count = 1;
private async Task ClickedAsync(
EventCallback<IElementCallbackArgs> callback)
=> await callback.InvokeAsync(
new MessageCallbackArgs
{
Message = $"Message > Click # {_count++}"
}).ConfigureAwait(false);
}
- Output:
The demonstration projects WasmComponentCallback2
and ServerComponentCallback2
can be found in the download below.
Suggested Alternative Solution
An alternative solution from Shaun C Curtis was proposed where the Component itself is passed to the template, not an EventCallback
.
This is not advised as you not only lose meaning of the context, but you expose properties and methods that should not be exposed to the template. Unexpected usage can and will most likely break your component.
A Better Alternative Solution
If an EventCallback
with arguments is too limiting, then expose a Model
class to encapsulate and control what is exposed externally to the template (RenderFragment
) is recommended.
- Model & Interface
public interface IMessageModel
{
void SetText(string Text);
}
public class MessageModel : IMessageModel
{
private string? _text;
[Parameter]
public string Text
{
get => _text ?? "";
set
{
if (_text == value)
return;
_text = value;
TextChanged.InvokeAsync(value);
OnTextChanged?.Invoke(value);
}
}
[Parameter]
public EventCallback<string> TextChanged { get; set; }
void IMessageModel.SetText(string text)
=> Text = text;
public event Action<string>? OnTextChanged;
}
- Component (
AltArgsCompponent
):
@implements IDisposable
<button type="button" class="btn btn-primary me-4"
@onclick="ButtonClicked">
OK
</button>
@if (ContentTemplate is not null)
{
@ContentTemplate(Message)
}
<hr />
<ul>
@if (!Messages.Any())
{
<li>No buttons clicked...</li>
}
else
{
@foreach (string message in Messages)
{
<li>@message</li>
}
}
</ul>
<hr />
@if (!string.IsNullOrWhiteSpace(Message.Text))
{
<p>@($"{LastMessageTimestamp} > {Message.Text}")</p>
}
else
{
<p>Waiting for a message...</p>
}
<hr />
@code {
[Parameter]
public RenderFragment<IMessageModel>? ContentTemplate { get; set; }
private readonly MessageModel Message = new();
private string? LastMessageTimestamp;
private void ButtonClicked()
=> Clicked($"Button clicked in {nameof(AltArgsCompponent)}");
protected override void OnInitialized()
{
Message.OnTextChanged += OnMessageModelChanged;
base.OnInitialized();
}
private void OnMessageModelChanged(string message)
{
LastMessageTimestamp = DateTime.Now.ToLongTimeString();
Clicked(message);
}
private readonly IList<string> Messages = new List<string>();
private void Clicked(string message)
{
Messages.Add(message);
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
Message.OnTextChanged -= OnMessageModelChanged;
}
}
- Usage (
Index.razor
):
<h1>Alternative Template Callback Demo</h1>
<AltArgsCompponent>
<ContentTemplate>
<button class="btn btn-dark"
@onclick="_ => context.SetText(GetMessage())">Click Me</button>
</ContentTemplate>
</AltArgsCompponent>
@code {
private int _count = 1;
private string GetMessage()
=> $"Message > Click # {_count++}";
}
- Output:
This shows how to implement an alternative solution correctly when the EventCallback
is not suitable.
There is a little bit more code here than my original solution. The MessageModel
implements both two-way binding and event notification. It also uses an explicit interface to only expose methods that the Template is allowed to use.
This is also a MVVM-friendly design (Note: MVVM is outside the scope of this article).
The demonstration projects WasmComponentCallback3
and ServerComponentCallback3
can be found in the download below.
Another Alternative Solution
After updating the article with A Better Alternative Solution, Shaun C Curtis suggested another solution using Func<T, Task>
. This is an Asynchronous Deleagate sharing MessageCallbackArgs, like in example 2 above rather than an EventCallback.
Whilst his solution is in the comments below, I've included it here in the article for completeness.
- Model & Interface (
MessageCallbackArgs
& IElementCallbackArgs
)
public interface IElementCallbackArgs
{
}
public class MessageCallbackArgs : IElementCallbackArgs
{
public string? Message { get; set; }
}
- Component (
AltArgsCompponent
):
<button type="button" class="btn btn-primary me-4"
@onclick="ButtonClicked">
OK
</button>
@if (ContentTemplate is not null)
{
@ContentTemplate(CallForward)
}
<hr />
<ul>
@if (!Messages.Any())
{
<li>No buttons clicked...</li>
}
else
{
@foreach (string message in Messages)
{
<li>@message</li>
}
}
</ul>
<hr />
@code {
[Parameter]
public RenderFragment<Func<MessageCallbackArgs, Task>>?
ContentTemplate { get; set; }
[Parameter]
public string? Message { get; set; }
private Func<MessageCallbackArgs, Task> CallForward => this.ExternalClick;
private void ButtonClicked()
=> Clicked($"Button clicked in {nameof(AltArgsComponent)}");
private Task ExternalClick(MessageCallbackArgs args)
{
Clicked(args.Message ?? "External button clicked");
return Task.CompletedTask;
}
private readonly IList<string> Messages = new List<string>();
private void Clicked(string message)
{
Messages.Add(message);
InvokeAsync(StateHasChanged);
}
}
- Usage (
Index.razor
):
<h1>Alternative 2 Template Callback Demo</h1>
<AltArgsComponent>
<ContentTemplate>
<button type="button" class="btn btn-outline-dark"
@onclick="(e) => context(message)">
Template OK
</button>
</ContentTemplate>
</AltArgsComponent>
@code {
private int _count = 1;
private MessageCallbackArgs message => new()
{
Message = $"Message > Click # {_count++}"
};
}
- Output:
The demonstration projects WasmComponentCallback4
and ServerComponentCallback4
can be found in the download below.
Bonus - Blazor Server Apps (Embedded Wasm)
Embed a Blazor Web Assembly Application inside a Blazor Server Application
I am not a fan of Blazor Server applications, however the above solutions works for both Blazor WebAssembly and Blazor Server applications. So, for this update I have used a little trick to embed Blazor Web Assembly applications as RCL (Razor Component Library) in a Blazor Server Application.
To make this work you need to:
- Create a Blazor Server App
- Reference the Web Assembly project
- Remove unnecessary files (eg: dupelicate files in
wwwroot
, unused razor
files, etc) - Update the
_Host.cshtml
file with css
references plus point to the WebAssembley App.razor
The new lean project structure will look like this:
The _Host.cshtml
file will look something like:
@page "/"
@using Microsoft.AspNetCore.Components.Web
@namespace ServerComponentCallback4.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="~/" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link href="css/app.css" rel="stylesheet" />
<link href="WasmComponentCallback4.styles.css" rel="stylesheet" />
<component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" />
</head>
<body>
<app>
<component type="typeof(WasmComponentCallback4.App)"
render-mode="ServerPrerendered" />
</app>
<div id="blazor-error-ui">
<environment include="Staging,Production">
An error has occurred. This application may no longer respond until reloaded.
</environment>
<environment include="Development">
An unhandled exception has occurred. See browser dev tools for details.
</environment>
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.server.js"></script>
</body>
</html>
The key parts are:
- Linking the Webassembly
css
stylesheet(s)
<link href="WasmComponentCallback4.styles.css" rel="stylesheet" />
- Pointing to the Web Assembly
App
razor class
<app>
<component type="typeof(WasmComponentCallback4.App)"
render-mode="ServerPrerendered" />
</app>
The key benefit of setting up the projects in this way is so that both the Blazor Web Assembly & Blazor Server applications use a single code base and stay in sync.
Identify Web Assembly application mode
I have also included a snippet of code to identify if the Web Assembly app is running on its own or as a RCL in a Blazor Server app. If you look at the two (2) Alternative solutions above, you can see I have included the Environment in the screenshots.
Here is the code that does the check:
[Inject]
protected IJSRuntime? ijsRuntime { get; set; }
private string Environment
=> ijsRuntime! is IJSInProcessRuntime ? "WebAssembly" : "Server";
This code snippet can be found in the MainLayout.razor
.
Working Example
Below is a link to the code used from concept to final implementation as used when I initially looked at the problem and is mentioned in the article above.
Download v1.2 source code - 816.2 KB
Summary
The final solution for enabling Component method callback, with optional data, from an external Template delivers a clean implementation that can be easily expanded on for any use case.
Enjoy!
History
- v1.0 - 17th April, 2022 - Initial release
- v1.1 - 19th April, 2022 - Added an alternative solution
- v1.2 - 21st April, 2022 - Added an alternative solution 2 + Blazor Server app projects