Skip to main content
ANVISoftware Solutions
Lesson 8 of 14Intermediate18 min

Dependency Injection in Components

By the end of this lesson

Inject services and pick the right lifetime.

A component regularly needs something it should not build for itself: a way to load employees, somewhere to write a log entry, the current configuration. Dependency injection is the arrangement where the component states what it needs and the framework supplies an instance. The component never calls a constructor, so it never has to know how the thing it needs is put together.

You have already seen both halves of this without them being named. Program.cs registers services. Components ask for them. What this lesson adds is the asking, and one detail of the registration that behaves differently in Blazor than it does in an API — differently enough to cause real bugs in real applications.

That detail is worth stating up front. In ASP.NET Core, a scoped service lives for one HTTP request. In Blazor Server it lives for as long as the person keeps the tab open. Every instinct you have built about scoped services needs re-reading in that light.

Components/Pages/EmployeeList.razor — asking for a service two ways
C#
@page "/employees"
@rendermode InteractiveServer
@inject IEmployeeDirectory Employees
@inject ILogger<EmployeeList> Logger

<h1>Employees</h1>

<ul>
    @foreach (var employee in employees)
    {
        <li @key="employee.Id">@employee.Name</li>
    }
</ul>

@code {
    [Inject]
    private NavigationManager Navigation { get; set; } = default!;

    private List<Employee> employees = [];

    protected override async Task OnInitializedAsync()
    {
        employees = await Employees.GetAllAsync();
        Logger.LogInformation("Loaded {Count} employees", employees.Count);
    }

    private void Open(int id) => Navigation.NavigateTo($"/employees/{id}");
}
  • @inject declares a dependency in the markup. The first part is the type to resolve, the second is the name the rest of the file uses. The property is generated for you.
  • [Inject] on a property in the @code block does the same job. Reach for it when the dependency belongs with the code rather than the markup, or when a base class needs it.
  • = default!; tells the compiler the property will hold a value even though nothing in this file assigns one. The framework sets it after creating the component and before the first render.
  • A component has no constructor injection. The framework creates the instance and then sets the injected properties, so a constructor runs before any dependency exists. That is why the load is in OnInitializedAsync and not in a constructor or a field initialiser.
  • Employees with a capital E is the injected service; employees is the private field. C# is case-sensitive, so both names can coexist, and being deliberate about which is which keeps the markup readable.
  • If IEmployeeDirectory is not registered, the failure happens when the component is created and the message names the missing type. That is the one error in this area that is straightforward to diagnose.

Three lifetimes, and what each one means once a component is doing the asking:

Transient
A new instance every time it is requested. Two components asking for the same type get two objects. Cheap to reason about, because nothing can leak between users or between screens. The safe choice when you are unsure.
Scoped
One instance per scope, shared by everything inside that scope. The whole question is what a scope is, and the answer changes with the hosting model. This is where the mistakes live.
Singleton
One instance for the whole application, shared by every user. It must be safe to use from several circuits at the same time, and anything mutable inside it is shared with everyone. Reference data such as a holiday calendar fits; anything about the current person does not.
The scope in Blazor Server
The circuit — the connection behind an interactive server component. It begins when the component becomes interactive and ends when the browser disconnects. That can be hours. Statically rendered components are outside it and use the request scope instead, so a scoped service is created more than once and carries nothing from the static render into the interactive one.
The scope in WebAssembly
The application running in that tab. There is nothing narrower, so scoped and singleton behave the same. Code that relies on a scoped service being short-lived is code that behaves differently between the two models.

The same AddScoped registration, in an API and in Blazor Server. Nothing about the registration changes; everything about the consequences does:

 ASP.NET Core requestBlazor Server circuit
What the scope isOne HTTP request. It opens when the request arrives and closes when the response is written.One circuit. It opens when a component becomes interactive and closes when the browser disconnects.
How long it livesMilliseconds, usually. Long enough to handle one piece of work.As long as the tab stays open. Hours is normal, and a tab left open overnight is not unusual.
Who shares the instanceEverything taking part in that one request.Every interactive server component in that tab, across every page the person visits.
State left inside itDiscarded with the response, so it has no chance to go stale.Kept until the tab closes, so anything cached in it is as old as the session.
A DbContext held thereTracks the entities for one request and is then disposed.Tracks every entity the session has ever loaded, and its view of the data drifts away from the database.
Two tabs, same personNot a distinction — each request is separate anyway.Two circuits, so two instances. State does not follow the user from one tab to the other.
Program.cs registrations, and a service that owns its context
C#
// Program.cs
builder.Services.AddDbContextFactory<PortalDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Portal")));

builder.Services.AddScoped<IEmployeeDirectory, EmployeeDirectory>();
builder.Services.AddSingleton<IHolidayCalendar, HolidayCalendar>();
builder.Services.AddTransient<PayslipFormatter>();

// Services/EmployeeDirectory.cs
public sealed class EmployeeDirectory(IDbContextFactory<PortalDbContext> factory)
    : IEmployeeDirectory
{
    public async Task<List<Employee>> GetAllAsync(CancellationToken token = default)
    {
        await using var db = factory.CreateDbContext();

        return await db.Employees
            .AsNoTracking()
            .OrderBy(e => e.Name)
            .ToListAsync(token);
    }
}
  • AddDbContextFactory registers a factory rather than a context. Nothing is created until a method asks, and the instance is disposed at the end of the using block.
  • await using disposes the context asynchronously, which matters because closing a connection is I/O. DbContext supports both forms; prefer the async one in async code.
  • AsNoTracking says this query is for display. The change tracker keeps no copies, so the query is cheaper and there is nothing left behind to go stale.
  • EmployeeDirectory takes its dependency through a primary constructor. Services are ordinary classes with ordinary constructors — it is components, and only components, that cannot use constructor injection.
  • IEmployeeDirectory is registered as scoped and that is fine, because it holds nothing between calls. Scoped becomes a problem when a service keeps state, not because of the word itself.
  • IHolidayCalendar is a singleton because its data is the same for everyone and changes about once a year. PayslipFormatter is transient because it is cheap and holds nothing worth sharing.
  • The CancellationToken has a default so callers who do not have one still compile, and it is passed to ToListAsync rather than accepted and dropped. The next lesson is about where that token comes from.

Summary

  • A component declares what it needs with @inject in markup or [Inject] in code, and the framework sets those properties after construction
  • Constructors and field initialisers run before injection, so a service is only usable from OnInitialized onwards
  • Transient gives a new instance each time, singleton gives one for the whole application, and scoped depends entirely on what a scope is
  • In Blazor Server a scope is the circuit, so a scoped service can live for hours and anything it caches goes stale
  • A DbContext does not belong in a Blazor Server circuit; use IDbContextFactory so each piece of work creates and disposes its own

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

What does this cache actually cache?

A team adds a DepartmentCache to the portal. It loads the department list on first use, keeps it in a field, and is registered with AddScoped. The application is Blazor Server.

A department is renamed in the database. Who sees the new name, and when? Then decide what you would change.

Show solution

Anyone who opens the portal after the rename sees it. Anyone already in the portal does not, and will not, for as long as their tab stays open — because the scope is the circuit, and the field was filled when they arrived. Someone who leaves the portal open across a working day can be looking at a list that is eight hours old.

Nothing about the code looks wrong, which is what makes it worth thinking about. Written in an API, the same class would be a per-request cache that reloads constantly and is almost pointless. Written here, it is a per-session cache with no expiry. One registration, two completely different behaviours.

There are three defensible fixes. Make it a singleton with IMemoryCache and an explicit expiry, so every user shares one copy that refreshes on a schedule you chose. Keep it scoped but give it a timestamp and reload when it is older than a few minutes. Or remove the cache and query each time, which for a department list is likely fast enough to be the right answer.

The one thing not to do is leave it as it is and call the staleness a database problem. Whichever fix you pick, the lifetime is now a decision you made rather than one you inherited from a different framework's habits.

Try it yourself

Swap a context for a factory

Start from a component that injects PortalDbContext directly and loads a list in OnInitializedAsync. Move the data access into a service that takes IDbContextFactory<PortalDbContext>, and inject that service instead.

Then add a log line each time a context is created, open two tabs, and navigate around each of them for a minute. Compare the counts.

Show solution

With direct injection you see one context created per tab, and it stays alive through every page you visit. With the factory you see one per piece of work, created and disposed while you watch. That is the entire behaviour change, and it is the one that stops entities piling up in a change tracker nobody is looking at.

The second benefit is less obvious and matters more over time. The service now owns the lifetime, so two components loading at the same moment cannot collide inside one context. You do not have to reason about whether a circuit happens to have two things in flight, because the question no longer arises.

Keep the registration for the service itself scoped. It holds no state, so there is nothing to go stale, and creating one per circuit is cheaper than creating one per component.

C#
@* Before — the component holds a context for the life of the circuit *@
@inject PortalDbContext Db

@code {
    private List<Employee> employees = [];

    protected override async Task OnInitializedAsync() =>
        employees = await Db.Employees.OrderBy(e => e.Name).ToListAsync();
}

@* After — the component asks a service, the service owns the context *@
@inject IEmployeeDirectory Employees

@code {
    private List<Employee> employees = [];

    protected override async Task OnInitializedAsync() =>
        employees = await Employees.GetAllAsync();
}

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A Blazor Server application registers a service with AddScoped. The service loads a list of departments on first use and keeps it in a field. How long does that cached list live?
Why can a Razor component not receive its dependencies through a constructor?

Saved in this browser only.