Skip to main content
ANVISoftware Solutions
Lesson 9 of 14Intermediate20 min

Calling an API

By the end of this lesson

Fetch data and handle loading and error states honestly.

Every call in this course so far has been a method on an object in the same process. It returned immediately, and if it failed, it failed in a way you wrote. An API call is different in two ways that the person using your screen can see: it takes time, and it can fail for reasons that have nothing to do with your code.

A component that ignores either of those tells the reader something untrue. It shows an empty list while data is on its way, so the reader concludes there are no employees. It shows the same empty list when the request failed, so the reader concludes the same thing and never finds out otherwise.

Being honest about it means four outcomes on screen, not one. The request is in flight. The request failed. The request worked and there is nothing to show. The request worked and here is the data. Only the last one usually gets written, and the other three are where the reader's trust is won or lost.

Program.cs and Services/EmployeeApiClient.cs
C#
// Program.cs
builder.Services.AddHttpClient<EmployeeApiClient>(client =>
{
    client.BaseAddress = new Uri(builder.Configuration["EmployeeApi:BaseAddress"]!);
    client.Timeout = TimeSpan.FromSeconds(10);
});

// Services/EmployeeApiClient.cs
public sealed class EmployeeApiClient(HttpClient http)
{
    public async Task<List<Employee>> SearchAsync(string? term, CancellationToken token)
    {
        var url = string.IsNullOrWhiteSpace(term)
            ? "api/employees"
            : "api/employees?name=" + Uri.EscapeDataString(term);

        using var response = await http.GetAsync(url, token);
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<List<Employee>>(token) ?? [];
    }
}
  • AddHttpClient registers a typed client: the framework builds the HttpClient, applies this configuration, and hands it to the constructor. The class never creates one itself.
  • IHttpClientFactory is what sits behind that. It pools and rotates the underlying handlers, which avoids two opposite failures — creating a client per call exhausts sockets under load, and keeping one forever means a DNS change is never noticed.
  • The base address and the timeout are set once, in one place. Ten seconds is a decision; the default is 100, which is far longer than anyone waits before deciding your application is broken.
  • Uri.EscapeDataString on anything a person typed. A search for "R&D" otherwise ends the query string early and the API sees a different search than the one requested.
  • EnsureSuccessStatusCode turns a 4xx or 5xx into an exception. That suits this screen, because it treats every failure the same way. When a 404 means something specific to you, check StatusCode instead of throwing.
  • ?? [] means a body of null becomes an empty list rather than a null reference three lines later in the markup.
  • Every method takes a CancellationToken and passes it on. A token that is accepted and then ignored is worse than no token, because the caller believes the work can be abandoned when it cannot.
Components/Pages/EmployeeSearch.razor — all four outcomes
C#
@page "/employees"
@rendermode InteractiveServer
@inject EmployeeApiClient Api
@inject ILogger<EmployeeSearch> Logger
@implements IDisposable

<h1>Employees</h1>

<label for="employee-search">Search by name</label>
<input id="employee-search" @bind="term" @bind:after="LoadAsync" />

@if (isLoading)
{
    <p role="status">Loading employees…</p>
}
else if (loadFailed)
{
    <div role="alert">
        <p>The employee list could not be loaded.</p>
        <button type="button" @onclick="LoadAsync">Try again</button>
    </div>
}
else if (employees.Count == 0)
{
    <p>No employees match that search.</p>
}
else
{
    <ul>
        @foreach (var employee in employees)
        {
            <li @key="employee.Id">@employee.Name — @employee.Department</li>
        }
    </ul>
}
  • Four branches, checked in the order the outcomes can occur. Loading first, because until the request finishes nothing else is known yet.
  • role="status" announces the wait to a screen reader. A spinner is an empty region to someone who cannot see it, and "loading" is the one message people most need while nothing is happening.
  • role="alert" on the failure is announced as soon as it appears, and the branch offers a way forward. An error message with no action is a dead end, and the reader's only remaining option is to reload the page and lose their search.
  • The empty branch is not a failure and must not look like one. "No employees match that search" and "the list could not be loaded" are different facts, and merging them sends people to the service desk about a system that is working.
  • @bind:after runs a method after the binding has written the value back, so the search reloads without a separate handler. It does fire on every change event, which for a busy API is an argument for a debounce.
  • employees starts as an empty list, so the loop cannot throw while the first request is in flight.
  • @implements IDisposable is how this component cleans up an abandoned request, which the next section covers.

The four outcomes, and what belongs on screen for each:

In flight
Say so, and say it where the data will appear. Keep the controls usable if a second request is reasonable, and disable them if it is not. Do not leave the previous results on screen with no indication that they are being replaced.
Failed
One plain sentence about what could not be done, and a way to try again. Log the exception with its detail; show the reader none of it. Exception text is written for you, and it can disclose more about your internals than you intended.
Succeeded, nothing to show
A sentence saying nothing matched, ideally naming what was searched or filtered. This is the outcome most often left as a blank area, and a blank area reads as broken.
Succeeded, with data
The results. This is the branch everyone writes, and it is the only one of the four that says nothing about the quality of the screen.
Abandoned
Not a state on screen, because nobody is looking at the screen any more. The reader navigated away. The right behaviour is to stop the work quietly and write nothing anywhere.
The @code block: one token source, one honest catch for each case
C#
@code {
    private readonly CancellationTokenSource cts = new();

    private List<Employee> employees = [];
    private string term = "";
    private bool isLoading;
    private bool loadFailed;

    protected override Task OnInitializedAsync() => LoadAsync();

    private async Task LoadAsync()
    {
        isLoading = true;
        loadFailed = false;

        try
        {
            employees = await Api.SearchAsync(term, cts.Token);
        }
        catch (OperationCanceledException)
        {
            // The component is going away. There is nobody left to tell.
            return;
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "Loading the employee list failed for term {Term}", term);
            loadFailed = true;
        }
        finally
        {
            isLoading = false;
        }
    }

    public void Dispose()
    {
        cts.Cancel();
        cts.Dispose();
    }
}
  • One CancellationTokenSource per component, cancelled in Dispose. When Blazor disposes the component — the reader navigated away, or a parent stopped rendering it — every request started with this token is asked to stop.
  • Setting isLoading and clearing loadFailed at the top means a retry starts from a clean state instead of showing the previous error underneath a spinner.
  • OperationCanceledException is caught separately and does nothing. It is not a failure, it is the outcome you asked for, and logging it fills your log with noise that looks like a problem.
  • The general catch logs with detail and sets a flag. The flag is what the markup reads; the exception never reaches the reader.
  • finally clears isLoading on every path, including the early return. Without it, one early return leaves a spinner on screen forever, and the reader waits for something that already finished.
  • Because LoadAsync was invoked by the framework — a lifecycle method, a click, or @bind:after — Blazor re-renders when it finishes and again at each await. No StateHasChanged is needed here.
  • Disposing the token source after cancelling it releases what it holds. In Blazor Server the circuit can be long-lived, so a component that leaks on every navigation leaks for hours.

Summary

  • Get HttpClient from IHttpClientFactory, usually as a typed client, so handlers are pooled and configuration lives in one place
  • An API call takes time and can fail, so the component needs four branches: loading, failed, empty, and loaded
  • The empty state is a success with nothing to show, and conflating it with failure misleads the reader in both directions
  • Log the exception with its detail and show the reader a plain sentence plus a way to retry
  • Own a CancellationTokenSource, pass its token into every call, and cancel it in Dispose so abandoned requests end predictably

Practice

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

Try it yourself

Prove the cancellation works

Add a deliberate three-second delay to the API endpoint the list calls. Open the list, and before it finishes, navigate to another page.

Do it first with no CancellationToken passed, then with the token from this lesson. Watch the server log both times, and write down what changed.

Show solution

Without the token the request runs to completion and the continuation executes in a component that no longer exists. What you see in the log varies: sometimes an ObjectDisposedException, sometimes a warning about a render after disposal, sometimes nothing. That variability is the point — the same code produces different symptoms depending on the exact moment the reader navigated.

With the token, Dispose cancels the source, the HTTP call ends, the catch for OperationCanceledException returns, and the log stays silent. The work also stops, which on a slow endpoint is real server capacity given back.

The habit worth taking from this: a component that starts asynchronous work should own a token source and cancel it in Dispose. It is five lines, it is the same five lines every time, and it removes a whole category of bug that is unpleasant to diagnose after the fact.

C#
@implements IDisposable

@code {
    private readonly CancellationTokenSource cts = new();

    protected override async Task OnInitializedAsync()
    {
        try
        {
            employees = await Api.SearchAsync(null, cts.Token);
        }
        catch (OperationCanceledException)
        {
            return;
        }
    }

    public void Dispose()
    {
        cts.Cancel();
        cts.Dispose();
    }
}

Think about it

Is a 404 an error or an empty result?

Two calls. One asks for the employee list and the API answers 404. One asks for employee 8821 and the API answers 404.

Which branch should each go to, and why are they not the same answer?

Show solution

For the list, a 404 is a defect. A collection endpoint that exists returns 200 with an empty array when there is nothing to send. A 404 means the address is wrong, the route changed, or you are pointed at the wrong environment — none of which the reader can do anything about, and all of which you want in a log. It belongs in the error branch.

For the single employee, a 404 is information. It means that person is not there: deleted, never existed, or a mistyped id in a link somebody shared. Sending that to the generic error branch tells the reader something is broken when the honest message is "there is no employee with that reference".

So the single-employee call should not use EnsureSuccessStatusCode. Check for NotFound, return null, and let the component render a third state that says the record is not there and offers a way back to the list.

The general principle is worth more than the specific answer. A status code is not automatically an error state; it is information about which outcome you are in. Deciding that per endpoint, once, is what keeps an interface from either hiding real faults or inventing faults that do not exist.

C#
public async Task<Employee?> GetAsync(int id, CancellationToken token)
{
    using var response = await http.GetAsync($"api/employees/{id}", token);

    if (response.StatusCode == HttpStatusCode.NotFound)
    {
        return null;
    }

    response.EnsureSuccessStatusCode();

    return await response.Content.ReadFromJsonAsync<Employee>(token);
}

Knowledge check

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

Why should a component pass a CancellationToken from its own CancellationTokenSource into an API call, and cancel that source in Dispose?
A search request completes successfully and the API returns an empty array. What should the component show?

Saved in this browser only.