# Using the fetch API

Catch the pager's clicks with one listener on the pager, cancel them with preventDefault, fetch the partial's HTML with fetch and response.text, and put it in the page with innerHTML. The links keep working when JavaScript doesn't run.

Journeyman · JavaScript · ASP.NET Core · Updated November 3, 2026

## After this lesson you can

- Fetch a partial view's HTML with fetch and response.text, and swap it into the page
- Intercept link clicks with one listener and preventDefault, keeping the links working without JavaScript

## Key takeaways

- `response.text()` reads the body as HTML, and `grid.innerHTML = ...` replaces what's inside the grid with it.
- One listener on the pager hears every link's click as it bubbles up, and `event.target.closest('a[data-page]')` finds the link. That's event delegation.
- `event.preventDefault()` stops the browser following the link; if the fetch fails, fall back to `window.location.href = link.href`.
- `data-` attributes hold your own values, and JavaScript reads them through `dataset`: `data-page="2"` is `link.dataset.page`, the string `'2'`.

The last piece is the JavaScript that ties the pager to the new `grid` action.

## Marking up the pager

The pager in `Views/Products/Index.cshtml` gets an id, and each item and link a **`data-page`** attribute saying which page it shows:

```cshtml
<nav aria-label="Product pages" class="mt-4">
    <ul class="pagination" id="product-pager">
        @for (int i = 1; i <= pageCount; i++)
        {
            <li class="page-item @(i == page ? "active" : "")" data-page="@i">
                <a class="page-link" asp-action="Index" asp-route-page="@i" data-page="@i">@i</a>
            </li>
        }
    </ul>
</nav>

@section Scripts {
    <script src="~/js/products.js" asp-append-version="true"></script>
}
```

- Attributes starting with **`data-`** are for your own use. JavaScript reads them through **`dataset`**: `data-page="2"` is `link.dataset.page`, the string `'2'`.
- The links' `href` is still `/products?page=2`, so they work without JavaScript.
- The **`Scripts`** section puts the script at the end of the layout, after the page's HTML, so the elements exist by the time it runs. `asp-append-version` adds a version number to the address, so browsers fetch the new file whenever it changes.

## The script

`wwwroot/js/products.js`:

```javascript
const grid = document.getElementById('product-grid');
const pager = document.getElementById('product-pager');

async function showPage(page) {
    try {
        const response = await fetch(`/products/grid?page=${page}`);
        if (!response.ok) {
            return false;
        }
        grid.innerHTML = await response.text();

        for (const item of pager.querySelectorAll('.page-item')) {
            item.classList.toggle('active', item.dataset.page === String(page));
        }
        return true;
    } catch (error) {
        return false;
    }
}

pager.addEventListener('click', async event => {
    const link = event.target.closest('a[data-page]');
    if (link === null) {
        return;
    }
    event.preventDefault();
    const shown = await showPage(link.dataset.page);
    if (!shown) {
        window.location.href = link.href;
    }
});
```

## How it works

**One listener for the whole pager.** Rather than a listener on every link, there's one on the pager. A click on a link **bubbles up** to its parents, so the pager hears it, and **`event.target.closest('a[data-page]')`** finds the link that was clicked (or `null`, for a click between links). Listening on a parent like this is called **event delegation**; it works however many links there are.

**`event.preventDefault()`** stops the browser following the link, so the page stays put.

**`showPage`** does the fetching:

- `fetch('/products/grid?page=2')` asks the new action for the partial.
- **`response.text()`** reads the body as **text**, the HTML, where an API's `response.json()` read JSON.
- **`grid.innerHTML = ...`** replaces everything inside `#product-grid` with the new cards. `innerHTML` is right here because the HTML comes from your own server's Razor, which already encoded every product name safely.
- **`classList.toggle('active', condition)`** adds the class when the condition is true and removes it when it's false, so exactly one page is marked active.

**If the fetch doesn't work**, whether the server answers with an error (`response.ok` is `false`) or doesn't answer at all (the `catch`), `showPage` returns `false`, and the script falls back to following the link the ordinary way. The user still gets their page.

## The result

Click page 2: the cards change, the pager marks 2 as active, and nothing else on the page moves. The navigation bar, the heading and the *Recently viewed* line never reload. Press **F12** and open the **Network** tab while you click: each click is one small request for `grid?page=2`, a fraction of the size of a whole page.

(The address bar still says `/products`. Making **Back** and bookmarks remember the page is possible with the browser's History API, but that's a topic for another day.)

## Check yourself

2 questions to try on the lesson page.

## So what

A partial view, an action that returns it, and a dozen lines of JavaScript turn a page that reloads into one that updates in place. And because the links still work on their own, nothing breaks when the JavaScript doesn't run.

## Key terms

- [Custom data attribute](https://www.ask-codey.com/glossary/custom-data-attribute)
- [Event Bubbling](https://www.ask-codey.com/glossary/event-bubbling)
- [Event Delegation](https://www.ask-codey.com/glossary/event-delegation)

## Cheat sheets

- [Using the fetch API](https://www.ask-codey.com/cheat-sheets/mvc-partials-with-fetch-using-the-fetch-api-cheat-sheet)

Source: https://www.ask-codey.com/lessons/mvc-partials-with-fetch-using-the-fetch-api
