Blazor ASP.NET Core 7.0

De Banane Atomic
Aller à la navigationAller à la recherche

Links

Components libraries

Useful librairies

Pages

Pages/Items.razor
@page "/items"          @* url *@
@page "/item/{id:int}"  @* url avec un parameter *@

<PageTitle>Items</PageTitle>

@if (Items == null)  @* attendre que la valeur soit chargée *@
{
    <p><em>Loading...</em></p>
}
else
{ /* ... */ }
Pages/Items.razor.cs
public partial class Table : ComponentBase
{
    private IReadOnlyCollection<ItemDto> items;

    [Parameter]
    public int Id { get; set; }

    protected override async Task OnInitializedAsync()
    {
        items = await this.ItemClient.GetAsync();
    }

    protected override async Task OnAfterRenderAsync(bool firstRender) { }

CSS isolation

You may need of the ::deep pseudo-element to modify the style of child components.
Pages/Items.razor.css
/* scoped css only for this page */
/* @import is not allowed in scoped css */
first-level-tag { }
first-level-tag ::deep second-level-tag { }

JS isolation

Pages/MyPage.razor.js
export function myFunction() { }

export function myFunctionWithArg(arg) {
    return 'ok';
}
Pages/MyPage.razor.cs
public partial class MyPage : ComponentBase, IAsyncDisposable
{
    [Inject]
    private IJSRuntime JS { get; set; } = default!;

    private IJSObjectReference? module;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            module = await JS.InvokeAsync<IJSObjectReference>("import", "./Pages/MyPage.razor.js");
        }
    }

    private async Task MyJsFunction()
    {
        if (module is not null)
            await module.InvokeVoidAsync("myFunction");
    }

    private async ValueTask<string?> MyJsFunctionWithArg(string arg)
    {
        string result = null;
        if (module is not null) 
            await module.InvokeAsync<string>("myFunctionWithArg", arg);
        return result;
    }

    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        if (module is not null)
            await module.DisposeAsync();
    }

Event handling

MyPage.razor
<button @onclick=OnButtonClicked>Click me</button>
<button @onclick="() => OnButtonClicked(Item.Name)">Click me</button>
MyPage.razor.cs
void OnButtonClicked()
{ }

async Task OnButtonClicked(string name)
{ }

preventDefault and stopPropagation

Razor.svg
<div @onclick="OnDivClicked">
    <a href="/page1" @onclick=OnLinkClicked
                     @onclick:preventDefault    @* prevent the navigation to the url but still executes the OnLinkClicked method *@
                     @onclick:stopPropagation>  @* stop the propagation of the event (bubbling), so the OnDivClicked method is never called *@
        Page 1
    </a>
</div>

foreach

Razor.svg
<ul>
@foreach (var item in items)
{
    <li>
        @item
    </li>
}
</ul>

Form

EditItem.razor
@page "/edititem/{ItemId:int}"

<EditForm Model="@item"
          OnValidSubmit="@HandleValidSubmitAsync"
          OnInvalidSubmit="@HandleInvalidSubmitAsync">

    <button type="submit">Save</button> 
    <button @onclick="Cancel">Cancel</button>           
</EditForm>
EditItem.razor.cs
[Parameter]
public int ItemId { get; set; }

[Inject]
private IItemService ItemService { get; set; }

private Item item = null;

protected override async Task OnInitializedAsync()
{
    item = await ItemDataService.GetItemAsync(ItemId);
}

// when the submit button is clicked and the model is valid
private async Task HandleValidSubmitAsync()
{
    await ItemService.UpdateItemAsync(item);
}

// when the submit button is clicked and the model is not valid
private async Task HandleInvalidSubmitAsync() { }

Input Text

Razor.svg
<div class="form-group row">

    <label for="name"
           class="col-sm-3">
        Item: 
    </label>

    <InputText id="name"
               @bind-Value="Item.Name"
               class="form-control col-sm-8"
               placeholder="Enter name">
    </InputText>
</div>

Data binding

One way

Changes made in the UI doesn't change the binded property.
The onchange event can be used to manually update the property.

Fichier:Blazor.svg
<input type="text" value="@Description"
       @onchange=@((ChangeEventArgs __e) => Description = __e.Value.ToString())
       @onchange=OnInputChanged />

@code {
    string Description { get; set; } = "Text";

    void OnInputChanged(ChangeEventArgs e) => Description = e.Value.ToString();
}

Two way

Fichier:Blazor.svg
<input type="text" @bind="Name" />    @* by default uses the 'on change' event (focus lost) *@

<input type="text" @bind="Name"
                   @bind:event="oninput"          @* use the oninput event instead of the default onchange, the event is triggered on each input key event *@
                   @bind:after="DoActionAsync" /> @* trigger the DoActionAsync method when the UI text is modified *@

@code {
    string Name { get; set; } = "Text";

    async Task DoActionAsync() { }
}

Form validation

Data Annotation

Item.cs
[Required, StringLength(50, MinimumLength = 3, ErrorMessage = "Name must be between 3 and 50 characters")]
public string Name { get; set; }
Pages/ItemEdit.razor
<EditForm Model="@item">
    <DataAnnotationsValidator />
    <ValidationSummary />  @* affiche la liste des messages d'erreur de validation *@

    <InputText @bind-value="item.Name" 
               placeholder="Enter name" />
    <ValidationMessage class="offset-sm-3 col-sm-8" For="@(() => Item.Name)" />  @* affiche le message d'erreur de validation *@

FluentValidation

Bash.svg
dotnet add package Blazored.FluentValidation
_Imports.razor
@using Blazored.FluentValidation
Pages/MyForm.razor
<EditForm Model="@model" OnValidSubmit="@SubmitValidForm">
    <FluentValidationValidator />

Manual validation

Clients/ItemClient.cs
public async Task<OperationResult<IReadOnlyCollection<ItemResponse>>> GetItemsAsync(
    ItemQuery query,
    CancellationToken cancellationToken)
{
    ArgumentNullException.ThrowIfNull(query);

    // client validation
    var validator = new ItemQueryValidator();
    var validationResult = validator.Validate(query);
    if (!validationResult.IsValid)
    {
        return new OperationResult<IReadOnlyCollection<TransactionResponse>>(validationResult.Errors);
    }
Clients/OperationResult.cs
public class OperationResult<T>
{
    public T? Result { get; set; }
    public IDictionary<string, string[]> ValidationErrors { get; } // error messages grouped by property name
    public IReadOnlyCollection<string> Errors => ValidationErrors.SelectMany(x => x.Value).ToList();
    public bool IsValid => ValidationErrors.Count == 0;

    public OperationResult(IDictionary<string, string[]> validationErrors)  // for server side validation error
    {
        ValidationErrors = validationErrors;
    }

    public OperationResult(IEnumerable<ValidationFailure> validationFailures)  // for client side validation errors
    {
        ValidationErrors = validationFailures
            .GroupBy(x => x.PropertyName, x => x.ErrorMessage)
            .ToDictionary(x => x.Key, x => x.ToArray());
    }

    public OperationResult(T result)  // for valid result
    {
        Result = result;
        ValidationErrors = new Dictionary<string, string[]>();
    }
}

Form exemples

Deactivate the form while submitting

MyPage.razor
<EditForm Model="@search"
          OnValidSubmit="@HandleValidSubmitAsync">
    <DataAnnotationsValidator />

    <fieldset disabled="@isSearching">
        <div class="row justify-content-center">
            <InputText @bind-Value="search.Text"
                        class="col-sm-6"
                        placeholder="Nom de table" />
            <button type="submit" class="btn btn-primary col-auto">
                @if (!isSearching)
                {
                    <span class="oi oi-magnifying-glass" aria-hidden="true"></span>
                }
                else
                {
                    <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
                }
            </button>
        </div>

        <div class="row justify-content-center">
            <ValidationMessage class="text-danger col-sm-6" For="@(() => search.Text)" />
        </div>
    </fieldset>

</EditForm>

@if (isSearching)
{
    <h5 class="text-center mt-5 mb-2">Loading 
        <span class="spinner-border spinner-border-sm" role="status"></span>
    </h5>
}
else if (tables.Count > 0)
{
    <h5 class="text-center mt-5 mb-2">Résultat de la recherche</h5>
}
else if (validSearchHasBeenStarted)
{
    <h5 class="text-center mt-5 mb-2">Pas de résultat pour cette recherche</h5>
}
MyPage.razor.cs
private bool isSearching { get; set; } = false;
private bool validSearchHasBeenStarted { get; set; } = false;

private async Task HandleValidSubmitAsync()
{
    validSearchHasBeenStarted = true;
    isSearching = true;

    var data = await myClient.FetchDataAsync(search.Text);

    isSearching = false;
}

Components

Components/MyComponent.razor

Components/MyComponent.cs
[Parameter]
public EventCallback<bool> MyEventCallback { get; set; }

// invoke asynchronously callback
await MyEventCallback.InvokeAsync(true);
_Imports.razor
@* Make components available *@
@using BlazorServerApp.Components
Pages/MyPage.razor
<MyComponent @ref="MyComponent"
             MyEventCallback="@MyComponent_OnSomethingHappened"></MyComponent>
Pages/MyPageBase.cs
protected MyComponentBase MyComponent { get; set; }

public async void MyComponent_OnSomethingHappened()
{
    // refresh the DOM if elements have changed
    StateHasChanged();
}

Lifecycle methods

Due to pre-rendering in Blazor Server you can't perform any JS interop until the OnAfterRender lifecycle method.
Method Start / End Show
SetParametersAsync Start false
OnInitializedAsync Start false
OnAfterRenderAsync Start false
OnAfterRenderAsync End false
OnInitializedAsync End true
OnParametersSetAsync Start true
OnParametersSetAsync End true
OnAfterRenderAsync Start true
OnAfterRenderAsync End true
SetParametersAsync End true
  1. OnInitialized OnInitializedAsync
  2. OnParametersSet OnParametersSetAsync
  3. OnAfterRender OnAfterRenderAsync

EventCallback

ChildComponent.razor
<button @onclick="OnClickCallback">Click</button>

@code {
    [Parameter]
    public EventCallback<MouseEventArgs> OnClickCallback { get; set; }
}
Parent.razor
@* The ShowMessage is passed to the ChildComponent
   When the button will be pressed in the ChildComponent, the ShowMessage will be triggered in the parent *@
<ChildComponent OnClickCallback="@ShowMessage">
    Content of the child component is supplied by the parent component.
</ChildComponent>

@code {
    private void ShowMessage(MouseEventArgs e)
    { }
}

Navigation

Cs.svg
[Inject]
private NavigationManager NavigationManager { get; set; } = default!;

NavigationManager.NavigateTo("url");

// to implement redirection, use the OnInitializedAsync method otherwise you will get an NavigationException 
protected override async Task OnInitializedAsync()
{
    NavigationManager.NavigateTo("/item", true, true);
    await base.OnInitializedAsync();
}

Dependency injection

Program.cs
builder.Services.AddTransient<IMyService, MyService>();
Pages/MyPage.razor.cs
[Inject]
private IMyService myService { get; set; }
Lifetime Description
Singleton creates a single instance of the service.

All components requiring a Singleton service receive the same instance of the service.

Transient creates a new instance of the service on each component request.
Scoped creates a new instance of the service on each HTTP request but not across SignalR connection/circuit messages.

Which means only when:

  • The user closes the browser's window, then opens a new window and navigates back to the app.
  • The user closes a tab of the app in a browser window, then opens a new tab and navigates back to the app.
  • The user selects the browser's reload/refresh button.

Localization / Langue / Culture

Program.cs
builder.Services.AddLocalization();

app.UseRequestLocalization("fr-FR");

Call a web API from ASP.NET Core Blazor

Program.cs
//builder.Services.AddServerSideBlazor();

var myWebapiUrl = builder.Configuration["MyWebapiUrl"];
if (string.IsNullOrWhiteSpace(myWebapiUrl))
{
    throw new Exception("MyWebapiUrl setting is null");
}
builder.Services.AddHttpClient<IMyClient, MyClient>(
    client => client.BaseAddress = new Uri(myWebapiUrl)
);

Entity Framework Core

Program.cs
builder.Services.AddScoped(_ => new MyDbContext(builder.Configuration.GetConnectionString("MyDbContext")));
appsettings.json
{
  "ConnectionStrings": {
    "MyDbContext": "Data Source=MyServer; Initial Catalog=MyDb; Integrated Security=True; MultipleActiveResultSets=True;"
  },
MyRepository.cs
private readonly MyDbContext context;

public MyRepository(MyDbContext context)
{
    this.context= context;
}

public Task<Data> FetchDataAsync()
    => context.Data.ToListAsync();

Using a DbContext factory

DbContext isn't thread safe and isn't designed for concurrent use.
To handle multi-threads scenarios, use a AddDbContextFactory to create a DbContext for each query.

Program.cs
builder.Services.AddDbContextFactory<MyDbContext>();
MyRepository.cs
private readonly IDbContextFactory<MyDbContext> contextFactory;

public MyRepository(IDbContextFactory<MyDbContext> contextFactory)
{
    this.contextFactory = contextFactory;
}

public async Task<Data> FetchDataAsync()
{
    // create a DbContext for this query, then dispose it once the query has been executed
    using var context = await contextFactory.CreateDbContextAsync();
}

State management

Cs.svg
[Inject]
public ProtectedSessionStorage ProtectedSessionStore { get; set; }

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        await LoadStateAsync();
        StateHasChanged();
    }
}

private async Task LoadStateAsync()
{
    var fetchValueResult = await ProtectedSessionStore.GetAsync<string>("key");
    var value = fetchValueResult.Success ? fetchValueResult.Value : null;
}

await ProtectedSessionStore.SetAsync("key", value);

External JS libraries

LibMan library manager

Bash.svg
# install the LibMan CLI
dotnet tool install -g Microsoft.Web.LibraryManager.Cli

# create the libman.js file
libman init
# available providers: cdnjs (default), jsdelivr (choose this one), unpkg, filesystem

libman install bootswatch@5.0.1 --files dist/darkly/bootstrap.min.css

# uninstall bootstrap
libman uninstall bootstrap@5.0.1

Bootstrap

Bash.svg
# jsdelivr provider
# install only bootstrap.min.css and bootstrap.bundle.min.js (with popper)
libman install bootstrap@latest \
  --files dist/css/bootstrap.min.css \
  --files dist/css/bootstrap.min.css.map \
  --files dist/js/bootstrap.bundle.min.js \
  --files dist/js/bootstrap.bundle.min.js.map \
  --files LICENSE
# default destination path: wwwroot/lib/bootstrap
Pages/_Host.cshtml
<head>
    <!-- REPLACE <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" /> BY -->
    <link rel="stylesheet" href="lib/bootstrap/css/bootstrap.min.css" />
    <link href="css/site.css" rel="stylesheet" />

    <script src="lib/bootstrap/js/bootstrap.bundle.min.js"></script>
    <script src="_framework/blazor.server.js"></script>
</body>
Pages/Error.cshtml
<head>
    <!-- REPLACE <link href="~/css/bootstrap/bootstrap.min.css" rel="stylesheet" /> BY -->
    <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
Delete the folder wwwroot/css/bootstrap

Bootstrap icons

Bash.svg
# install the css and fonts only
libman install bootstrap-icons@latest \
  --files font/bootstrap-icons.min.css \
  --files font/fonts/bootstrap-icons.woff \
  --files font/fonts/bootstrap-icons.woff2
# default destination path: wwwroot/lib/bootstrap-icons
Pages/_Host.cshtml
<head>
    <link rel="stylesheet" href="lib/bootstrap-icons/font/bootstrap-icons.min.css" />
    <link href="css/site.css" rel="stylesheet" />
wwroot/css/site.css
/* remove this line */
@import url('open-iconic/font/css/open-iconic-bootstrap.min.css');
Delete the folder wwwroot/css/open-iconic
Shared/NavMenu.razor
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
    <i class="bi bi-house"></i> Home
</NavLink>
Shared/NavMenu.razor.css
/* replace .oi by .bi */
.bi {
    width: 2rem;
    font-size: 1.1rem;
    vertical-align: text-top;
    top: -2px;
}

Handle exceptions

Shared/MainLayout.razor
<ErrorBoundary @ref="errorBoundary">
    <ChildContent>
        @Body
    </ChildContent>
    <ErrorContent Context="e">
        @{ OnError(@e); }
        <MudAlert Severity="Severity.Error">An exception occured: @e.Message</MudAlert>
    </ErrorContent>
</ErrorBoundary>

@code {
    [Inject]
    private ILogger<MainLayout> Logger { get; set; } = null!;

    private ErrorBoundary? errorBoundary;

    private void OnError(Exception e)
    {
        Logger.LogCritical(e, e.Message);
    }

    // reset the ErrorBoundary to a non-error state on subsequent page navigation events by calling the error boundary's Recover method.
    protected override void OnParametersSet()
    {
        errorBoundary?.Recover();
    }
}

Turn on detailed errors

Display detailed error in the client. Never do that in Production, rely better on server log.
Program.cs
builder.Services.AddServerSideBlazor()
                .AddCircuitOptions(o =>
    {
        o.DetailedErrors = true;
        o.DetailedErrors = builder.Environment.IsDevelopment();  // only add details when debugging
    });

Create a new project

Bash.svg
mkdir MySolution
cd MySolution
dotnet new sln --name MySolution

dotnet new blazorserver -o MyBlazorProject --no-https
dotnet sln add ./MyBlazorProject/MyBlazorProject.csproj