advanced Step 19 of 20

Working with APIs

JavaScript Programming

Working with APIs

Integrating with external APIs is a core skill for modern web development. Whether you are fetching data from a REST API, submitting forms, uploading files, or authenticating users with OAuth, you need to understand how to make HTTP requests, handle responses, manage authentication tokens, and implement robust error handling and retry logic. This lesson brings together everything you have learned about async/await and fetch to build production-quality API integrations.

Building a Robust API Client

class ApiClient {
    #baseURL;
    #token;

    constructor(baseURL) {
        this.#baseURL = baseURL;
        this.#token = localStorage.getItem("auth_token");
    }

    setToken(token) {
        this.#token = token;
        localStorage.setItem("auth_token", token);
    }

    async #request(endpoint, options = {}) {
        const url = `${this.#baseURL}${endpoint}`;
        const headers = {
            "Content-Type": "application/json",
            ...options.headers,
        };

        if (this.#token) {
            headers["Authorization"] = `Bearer ${this.#token}`;
        }

        const config = { ...options, headers };
        if (config.body && typeof config.body === "object") {
            config.body = JSON.stringify(config.body);
        }

        const response = await fetch(url, config);

        if (response.status === 401) {
            this.setToken(null);
            throw new Error("Authentication expired. Please log in again.");
        }

        if (!response.ok) {
            const error = await response.json().catch(() => ({}));
            throw new Error(error.message || `HTTP ${response.status}`);
        }

        if (response.status === 204) return null;
        return response.json();
    }

    get(endpoint)              { return this.#request(endpoint); }
    post(endpoint, body)       { return this.#request(endpoint, { method: "POST", body }); }
    put(endpoint, body)        { return this.#request(endpoint, { method: "PUT", body }); }
    patch(endpoint, body)      { return this.#request(endpoint, { method: "PATCH", body }); }
    delete(endpoint)           { return this.#request(endpoint, { method: "DELETE" }); }
}

const api = new ApiClient("https://api.example.com");

Pagination

// Fetch all pages of paginated data
async function fetchAllPages(endpoint) {
    let page = 1;
    let allData = [];
    let hasMore = true;

    while (hasMore) {
        const response = await api.get(`${endpoint}?page=${page}&limit=50`);
        allData = [...allData, ...response.data];
        hasMore = response.data.length === 50;
        page++;
    }

    return allData;
}

// Infinite scroll implementation
class InfiniteLoader {
    #page = 1;
    #loading = false;
    #hasMore = true;

    constructor(endpoint, renderFn) {
        this.endpoint = endpoint;
        this.render = renderFn;
        this.#setupScrollListener();
    }

    async loadMore() {
        if (this.#loading || !this.#hasMore) return;
        this.#loading = true;

        try {
            const response = await api.get(
                `${this.endpoint}?page=${this.#page}&limit=20`
            );
            this.render(response.data);
            this.#hasMore = response.data.length === 20;
            this.#page++;
        } finally {
            this.#loading = false;
        }
    }

    #setupScrollListener() {
        window.addEventListener("scroll", () => {
            const nearBottom = window.innerHeight + window.scrollY
                >= document.body.offsetHeight - 500;
            if (nearBottom) this.loadMore();
        });
    }
}

Search with Debounce

// Debounce — wait for user to stop typing before making API call
function debounce(fn, delay) {
    let timer;
    return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => fn.apply(this, args), delay);
    };
}

const searchInput = document.querySelector("#search");
const resultsList = document.querySelector("#results");

const searchAPI = debounce(async (query) => {
    if (query.length < 2) {
        resultsList.innerHTML = "";
        return;
    }

    try {
        resultsList.innerHTML = '
  • Searching...
  • '; const results = await api.get(`/search?q=${encodeURIComponent(query)}`); resultsList.innerHTML = results.data .map(item => `
  • ${item.title}
  • `) .join(""); } catch (error) { resultsList.innerHTML = `
  • ${error.message}
  • `; } }, 300); searchInput.addEventListener("input", (e) => searchAPI(e.target.value));

    File Upload

    // Upload files with progress tracking
    async function uploadFile(file, onProgress) {
        return new Promise((resolve, reject) => {
            const xhr = new XMLHttpRequest();
            const formData = new FormData();
            formData.append("file", file);
    
            xhr.upload.addEventListener("progress", (e) => {
                if (e.lengthComputable) {
                    const percent = Math.round((e.loaded / e.total) * 100);
                    onProgress?.(percent);
                }
            });
    
            xhr.addEventListener("load", () => {
                if (xhr.status >= 200 && xhr.status < 300) {
                    resolve(JSON.parse(xhr.responseText));
                } else {
                    reject(new Error(`Upload failed: ${xhr.status}`));
                }
            });
    
            xhr.addEventListener("error", () => reject(new Error("Upload failed")));
            xhr.open("POST", "https://api.example.com/upload");
            xhr.send(formData);
        });
    }
    
    // Usage
    const fileInput = document.querySelector("#file-input");
    fileInput.addEventListener("change", async (e) => {
        const file = e.target.files[0];
        try {
            const result = await uploadFile(file, (percent) => {
                console.log(`Upload: ${percent}%`);
            });
            console.log("Uploaded:", result);
        } catch (error) {
            console.error("Upload failed:", error.message);
        }
    });
    
    Pro tip: Always debounce search inputs to avoid flooding the API with requests on every keystroke. A 300ms delay is typically a good balance between responsiveness and efficiency. Also, consider implementing request cancellation with AbortController so that outdated requests do not overwrite newer results.

    Key Takeaways

    • Build a reusable API client class that handles authentication, headers, and error responses consistently.
    • Implement pagination for large datasets — either page-based or cursor-based depending on the API.
    • Use debounce for search inputs to reduce API calls while maintaining a responsive user experience.
    • Handle authentication token storage and expiry gracefully, redirecting to login when tokens expire.
    • Use FormData and XMLHttpRequest (or the newer fetch with ReadableStream) for file uploads with progress tracking.