Search code examples
blazorblazor-server-side

Why isn't sibling component updating its content?


I am developing a Blazor server app. One of the classes is Employee.cs which is injected at Startup.cs

Employee.cs

public class Employee
{
    public string Name { get; set; } = "James";
}

Startup.cs

  ...
  ...
  services.AddSingleton<Employee>();

To use this injected instance, I wrote a simple component Welcome.razor

Welcome.razor

@inject Employee Emp

<h1>Welcome [email protected]</h1>

The Welcome component is a permanent part of the UI, and is displayed at the top of the page.

I created a razor page ChangeName.razor that also displays the Name from the injected instance, and a button to alter the value. When the button is clicked, the name is changed within the page containing the button, but has no effect on the Welcome.razor even after calling StateHasChanged()

ChangeName.razor

@page "/"
@inject Employee Emp

<button @onclick="@ChangeName">Change Name</button>
<div>Employee name in page is: @Emp.Name</div>

@code {
    void ChangeName() {

        Emp.Name = "Mike";

        StateHasChanged();    // has no effect on the Welcome.razor content

    }
}

Once the button is clicked, the name in Welcome.razor stays as James but the name in ChangeName.razor page is changed to Mike. Why aren't they synced?

Here is what I did as a work-around:

Welcome.razor

@code {
    static Welcome _welcome;

    protected override void OnInitialized()
    {
        base.OnInitialized();
        _welcome = this;        // grab a reference to the current instance 
                                // and assign it to the static instance
    }

    public Refresh() {
       _welcome.StateHasChanged();    // this can be called from any other component now
    }
}

Now I can just call Welcome.Refresh() from anywhere and the name will change. But this solution is ugly and there must be something wrong that I am doing..

What can I do to automatically trigger StateHasChanged() for any component that is displaying a shared object?


Solution

  • [Polite] You are making several fundamental mistakes.

    1. Read the Blazor Service Scope documentation - Transient is one instance per DI service provider request. The service needs to be Scoped to be a shared instance across the SPA session.

    2. Don't tightly couple components by passing around references. You didn't create the instances and aren't in control of their lifecycles. You can end up with a reference a stale component that's no longer part of the RenderTree and has been disposed.

    3. Use the Notification pattern - a service or cascaded object with setters and one or more change events - to communicate between distant components i.e. where you can't use parameters and Event Callbacks.

    4. There are very few use cases where you need to call StateHasChanged. The primary one is in an Event Handler for a notification as outlined in 3. Outside that, if you're resorting to StateHasChanged to try and update the UI, you're in trouble. Treat the cause not the symptom. Fix the logic.

    Links to other questions and answers on the Notification Pattern - Search SO for "Blazor Notification Pattern" for more.

    How can I trigger/refresh my main .RAZOR page from all of its sub-components within that main .RAZOR page when an API call is complete?

    Passing shared parameter to blazorcomponents

    Can a Blazor element access data in a peer element?

    I need to update state of blazor component when other component changed

    Using the Notification Pattern

    You need to implement the Notification pattern.

    Yes Refresh() is not just ugly, it's horrible. It just makes a protected method public. It's protected for some very good reasons. Respect the design decision.

    The project template for this code is Net8.0 "Blazor Web App" with either InteractiveServer or InteractiveWebAssembly and Global interactivity options selected.

    public record Employee(string Name);
    
    public class EmployeeService
    {
        public Employee Employee { get; private set; } = new("Fred"); 
        public event EventHandler<Employee>? EmployeeChanged;
    
        public void UpdateEmployee(Employee employee)
        {
            this.Employee = employee;
            this.EmployeeChanged?.Invoke(this, employee);
        }
    }
    

    Registered:

    var builder = WebApplication.CreateBuilder(args);
    
    // Add services to the container.
    builder.Services.AddRazorComponents()
        .AddInteractiveServerComponents();
    
    builder.Services.AddScoped<EmployeeService>();
    
    var app = builder.Build();
    

    Welcome:

    @inject EmployeeService EmployeeService
    
    <h1>Welcome @EmployeeService.Employee.Name</h1>
    
    @code {
        protected override void OnInitialized()
            => this.EmployeeService.EmployeeChanged += this.OnEmployeeChanged;
    
        private void OnEmployeeChanged(object? sender, Employee employee)
        => this.InvokeAsync(StateHasChanged);
    
        public void Dispose()
            => this.EmployeeService.EmployeeChanged -= this.OnEmployeeChanged;
    }
    

    Demo Page:

    @page "/"
    @inject EmployeeService EmployeeService
    
    <PageTitle>Home</PageTitle>
    
    <Welcome />
    
    <div>
        <button class="btn btn-primary" @onclick="this.ChangeEmployee">Change Employee</button>
    </div>
    
    @code {
        private Employee _fred = new("Fred");
        private Employee _shaun = new("Shaun");
    
        private void ChangeEmployee()
        {
            if (this.EmployeeService.Employee == _fred)
                this.EmployeeService.UpdateEmployee(_shaun);
            else
                this.EmployeeService.UpdateEmployee(_fred);
        }
    }
    

    I've used records [I believe in mutation control], but you can switch to classes if you prefer.