Updated Jun 23, 2026

Calling APIs & Dependency Injection

Up to now your components have been self-contained: they held their own state and re-rendered when it changed. Real apps aren't like that. A component needs collaborators — something that fetches data, talks to a backend, logs an error. This phase is about how a component gets those collaborators and how it reaches a server to load real data.

Here's the whole chapter in one mental model, so the rest is detail:

  • Components don't build their own dependencies — they ask for them. A component declares "I need a data service" and Blazor hands it one. That's dependency injection (DI), and it's the exact same DI container as ASP.NET Core — you've met it already if you've done ASP.NET Core.
  • To reach a backend, you inject an HttpClient (or, better, a service that wraps one) and call JSON helpers like GetFromJsonAsync. The backend is a normal ASP.NET Core API.

Hold those two sentences. Everything below hangs off them.

📝 We'll keep building the products UI: this time it loads the product list from a real API and lets the reader create a new product that gets POSTed to the backend. The API itself is an ASP.NET Core service — see ASP.NET Core From Zero for the other side of the wire.

The mental model: injected collaborators

Imagine a component that needs to fetch products. The naive instinct is to have the component new up whatever it needs:

// The instinct we're going to NOT follow.
var http = new HttpClient();
var service = new ProductService(http);

The problem: the component is now welded to those concrete types. You can't swap the service for a fake one in a test, you can't configure the HttpClient in one place, and every component that needs products repeats this wiring.

DI flips it. You register your services once, centrally, and components ask for what they need. Blazor's container constructs them, wires up their own dependencies, and hands the finished object over. The component never says new — it says "give me one."

This is the same IServiceProvider container ASP.NET Core uses, with the same lifetimes (Scoped, Singleton, Transient). If DI in ASP.NET Core already clicked for you, you already understand Blazor's DI.

Registering services and injecting them

Registration happens in Program.cs, where the app is wired up:

// Program.cs
builder.Services.AddScoped<IProductService, ProductService>();

What just happened: you told the container "whenever something asks for an IProductService, build a ProductService and reuse it for the lifetime of this scope." Registering against the interface (IProductService) is the move that makes the component swappable later — it depends on the contract, not the concrete class.

Now a component asks for it with the @inject directive at the top of the .razor file:

@inject IProductService Products

<ul>
    @if (products is not null)
    {
        @foreach (var p in products)
        {
            <li>@p.Name — [email protected]</li>
        }
    }
</ul>

@code {
    private List<Product>? products;

    protected override async Task OnInitializedAsync()
    {
        products = await Products.GetAllAsync();
    }
}

What just happened: @inject IProductService Products declares a property named Products that Blazor fills in before the component renders. By the time OnInitializedAsync runs (the load-your-data hook from Phase 4), Products is ready to use. Notice the component calls Products.GetAllAsync() — it has no idea there's an HttpClient behind it. That's the whole point.

If you prefer attributes over the directive (handy in a code-behind file or a base class), the equivalent inside @code is:

[Inject]
public IProductService Products { get; set; } = default!;

What just happened: [Inject] does the same job as @inject — Blazor sets this property after constructing the component. The = default! quiets the nullable-reference compiler warning: you're promising "Blazor will set this, trust me," since DI assigns it before any of your code runs.

💡 @inject is the directive form (top of the markup); [Inject] is the attribute form (inside @code). Same mechanism, pick whichever reads better where you are. There's no behavioral difference.

Calling an API with HttpClient

Now the part where data actually crosses the network. Blazor uses the standard .NET HttpClient, paired with the JSON helper extension methods in System.Net.Http.Json. These helpers serialize and deserialize JSON for you, so you work in typed objects, not raw strings.

The four you'll use constantly:

Method What it does
GetFromJsonAsync<T>(url) GET, deserialize the JSON response into a T
PostAsJsonAsync(url, obj) POST obj as JSON in the body
PutAsJsonAsync(url, obj) PUT obj as JSON (update)
DeleteAsync(url) DELETE the resource at the URL

Here's the products list loading from the API directly (we'll improve on the raw-HttpClient approach in a moment), with the loading state from Phase 4:

@inject HttpClient Http

@if (products is null)
{
    <p>Loading products...</p>
}
else
{
    <ul>
        @foreach (var p in products)
        {
            <li>@p.Name — [email protected]</li>
        }
    </ul>
}

@code {
    private List<Product>? products;

    protected override async Task OnInitializedAsync()
    {
        products = await Http.GetFromJsonAsync<List<Product>>("api/products");
    }
}

What just happened: GetFromJsonAsync<List<Product>>("api/products") fires a GET to api/products, reads the JSON array that comes back, and deserializes it into a List<Product>. Because the call is awaited inside OnInitializedAsync, the component renders once before the data arrives (products is still null, so the reader sees "Loading products..."), then re-renders when it lands. The null-as-loading-state pattern is the same one you learned for the lifecycle — it carries straight over here.

Creating a product is the write side. A small form POSTs a new product, then refreshes the list:

@inject HttpClient Http

<input @bind="newName" placeholder="Product name" />
<input @bind="newPrice" type="number" placeholder="Price" />
<button @onclick="Create" disabled="@isSaving">
    @(isSaving ? "Saving..." : "Add product")
</button>

@code {
    private string newName = "";
    private decimal newPrice;
    private bool isSaving;
    private List<Product>? products;

    private async Task Create()
    {
        isSaving = true;
        var newProduct = new Product { Name = newName, Price = newPrice };
        await Http.PostAsJsonAsync("api/products", newProduct);
        products = await Http.GetFromJsonAsync<List<Product>>("api/products");
        newName = "";
        newPrice = 0;
        isSaving = false;
    }
}

What just happened: PostAsJsonAsync("api/products", newProduct) serializes newProduct to JSON and POSTs it. After it returns, we re-fetch the list so the new item shows up, then clear the inputs. Setting isSaving around the await gives the reader feedback and disables the button so a double-click can't double-submit — Blazor re-renders before the await (button shows "Saving...") and again after it resumes (button re-enabled, list refreshed).

⚠️ WebAssembly vs Server: where the HttpClient call comes from

This is the gotcha that bites people, so let's be precise. How you register the HttpClient differs between the two hosting models (Phase 1), because the code runs in different places.

Blazor WebAssembly — the C# runs in the browser. So the HTTP call leaves the browser, just like a fetch() would. You register an HttpClient with a BaseAddress:

// Program.cs (Blazor WebAssembly)
builder.Services.AddScoped(sp => new HttpClient
{
    BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});

What just happened: every component that injects HttpClient gets one pointed at your app's base URL, and requests originate from the user's browser. ⚠️ Because it's a real browser request to (potentially) a different origin, CORS applies — if your API is on a different domain/port, the API must send the right CORS headers or the browser will block the call. That's a server-side configuration on the API, not something the Blazor component can fix.

Blazor Server — the C# runs on the server. There's no browser making the request, so there's no browser-CORS issue. The idiomatic approach here is a typed client registered via IHttpClientFactory, which manages connection pooling and lets you configure the client in one place:

// Program.cs (Blazor Server)
builder.Services.AddHttpClient<IProductService, ProductService>(client =>
{
    client.BaseAddress = new Uri("https://localhost:5001/");
});

What just happened: AddHttpClient<IProductService, ProductService> registers ProductService and injects a properly-configured HttpClient into its constructor — one call wires up both. Because the request runs server-to-server, the browser's CORS policy never enters the picture.

⚠️ The trap is copying a WASM HttpClient registration into a Server app (or vice versa) and being surprised. The rule of thumb: WASM = HttpClient with BaseAddress, mind CORS; Server = typed client / IHttpClientFactory, no browser CORS.

💡 The clean pattern: wrap HttpClient in a typed service

You saw components inject HttpClient directly above. That works, but it scatters URLs and HTTP details across your UI. The pattern that scales: wrap HttpClient in a typed service and inject that into components.

public interface IProductService
{
    Task<List<Product>> GetAllAsync();
    Task CreateAsync(Product product);
}

public class ProductService : IProductService
{
    private readonly HttpClient _http;

    public ProductService(HttpClient http) => _http = http;

    public async Task<List<Product>> GetAllAsync() =>
        await _http.GetFromJsonAsync<List<Product>>("api/products") ?? new();

    public async Task CreateAsync(Product product) =>
        await _http.PostAsJsonAsync("api/products", product);
}

What just happened: the HttpClient and the API URLs now live in exactly one place. The constructor takes an HttpClient — which DI injects automatically, because you registered the client alongside the service. Components go back to the clean form from earlier: inject IProductService, call Products.GetAllAsync(), and stay blissfully unaware of HTTP.

Why this is worth the extra interface:

  • Testable. A test can supply a fake IProductService that returns canned products — no network, no server, instant.
  • Swappable. Move from one API to another, add caching, or add retry logic in one class; no component changes.
  • Honest boundaries. Components do UI; the service does data. Each stays small.

This is the same dependency-inversion idea ASP.NET Core leans on everywhere — your components depend on the IProductService contract, and DI decides which concrete implementation satisfies it. The API on the other end is an ASP.NET Core service; if you haven't built that side, ASP.NET Core From Zero is its companion guide.

Recap

  • Components ask for collaborators; they don't build them. Register services in Program.cs (builder.Services.AddScoped<IProductService, ProductService>()), then pull them in with @inject IProductService Products (or [Inject] inside @code). It's the same DI container as ASP.NET Core.
  • Reach a backend with HttpClient plus the System.Net.Http.Json helpers: GetFromJsonAsync<T>, PostAsJsonAsync, PutAsJsonAsync, DeleteAsync — you work in typed objects, not raw JSON strings.
  • Load data in OnInitializedAsync with a null loading state; set a saving flag around writes so the UI gives feedback and can't double-submit.
  • WASM vs Server registration differs: WASM registers an HttpClient with a BaseAddress and the call leaves the browser (mind CORS); Server uses a typed client / IHttpClientFactory and runs server-to-server (no browser CORS).
  • Wrap HttpClient in a typed IProductService and inject that — it keeps URLs in one place and makes the component testable and swappable.

Quick check

[
  {
    "q": "How does a Blazor component get a service it depends on, like IProductService?",
    "choices": ["It calls new ProductService() in OnInitialized", "It declares it with @inject (or [Inject]); Blazor's DI container supplies it", "It reads it from a global static field", "It passes it in as a [Parameter] from the parent"],
    "answer": 1,
    "explain": "Components ask for dependencies via @inject or [Inject], and the DI container — the same one ASP.NET Core uses — constructs and supplies them. The component never news up its own collaborators."
  },
  {
    "q": "In Blazor WebAssembly, the HTTP call to your API runs in the browser and your API is on a different origin. What must be configured for the call to succeed?",
    "choices": ["Nothing — WASM bypasses browser security", "CORS headers on the API, because the request is a real cross-origin browser request", "A typed client via IHttpClientFactory only", "StateHasChanged() after the call"],
    "answer": 1,
    "explain": "WASM runs in the browser, so the request is subject to CORS. If the API is a different origin it must send the right CORS headers. Blazor Server runs server-side and doesn't hit this — that's the key WASM-vs-Server difference."
  },
  {
    "q": "Why wrap HttpClient inside a typed IProductService instead of injecting HttpClient straight into components?",
    "choices": ["It's required — components can't inject HttpClient", "It centralizes URLs and HTTP details, and lets you swap in a fake service for testing", "It makes the HTTP calls run faster", "It avoids needing Program.cs registration"],
    "answer": 1,
    "explain": "A typed service keeps URLs and HTTP details in one place and lets components depend on the IProductService contract — so tests can supply a fake, and you can change the backend without touching the UI."
  }
]

← Phase 6: Component Communication & State · Guide overview · Phase 8: Where to Go Next →

Check your understanding

1. How does a Blazor component get a service it depends on, like IProductService?

2. In Blazor WebAssembly, the HTTP call to your API runs in the browser and your API is on a different origin. What must be configured for the call to succeed?

3. Why wrap HttpClient inside a typed IProductService instead of injecting HttpClient straight into components?

Was this page helpful?