JavaScript Essentials

Working with REST APIs

45 min Lesson 43 of 60

What is a REST API?

REST stands for Representational State Transfer. It is an architectural style for building web services that allow different software applications to communicate over the internet using standard HTTP methods. A REST API exposes resources -- such as users, posts, products, or orders -- at specific URLs called endpoints. Each resource can be created, read, updated, or deleted using HTTP methods, forming the foundation of modern web applications.

REST APIs follow a set of principles that make them predictable and easy to work with. They are stateless, meaning each request contains all the information needed to process it. They use uniform interfaces, meaning resources are identified by URLs and manipulated through standard HTTP methods. They return representations of resources, typically in JSON format, rather than the resources themselves.

REST Principles and Resources

In REST, everything revolves around resources. A resource is any piece of data your API manages. Resources are organized into collections and individual items. Consider a blog API: the collection of all posts lives at /posts, while a single post lives at /posts/1. This URL structure is called a resource path, and it should be intuitive and consistent.

Key REST principles include:

  • Client-Server Separation -- The frontend and backend are independent. The client handles the user interface, while the server handles data storage and business logic.
  • Statelessness -- Each request from client to server must contain all information needed to understand and process that request. The server does not store any client context between requests.
  • Uniform Interface -- Resources are identified by URLs and manipulated using HTTP methods. Responses include enough information for the client to modify or delete the resource.
  • Resource-Based URLs -- URLs should represent nouns, not verbs. Use /users instead of /getUsers. Use /posts/5 instead of /getPostById?id=5.

HTTP Methods for CRUD Operations

REST APIs map the four fundamental data operations -- Create, Read, Update, and Delete (CRUD) -- to specific HTTP methods. Understanding these mappings is essential for working with any API.

  • GET -- Retrieves data from the server. Should never modify data. Used for reading resources.
  • POST -- Creates a new resource on the server. Sends data in the request body.
  • PUT -- Replaces an entire resource with new data. Requires sending the complete updated resource.
  • PATCH -- Partially updates a resource. Only sends the fields that need to change.
  • DELETE -- Removes a resource from the server.

Example: CRUD Operations Overview

// REST API endpoint patterns for a "posts" resource:
//
// GET    /posts       -- Retrieve all posts (collection)
// GET    /posts/1     -- Retrieve a single post (item)
// POST   /posts       -- Create a new post
// PUT    /posts/1     -- Replace post with ID 1 entirely
// PATCH  /posts/1     -- Update specific fields of post 1
// DELETE /posts/1     -- Delete post with ID 1

HTTP Status Codes

Every API response includes a status code that tells you what happened with your request. Status codes are grouped into ranges, each indicating a category of response. Knowing these codes helps you handle API responses correctly in your application.

  • 2xx -- Success: The request was successful.
    • 200 OK -- Standard success response for GET, PUT, and PATCH requests.
    • 201 Created -- A new resource was successfully created (typically after POST).
    • 204 No Content -- Success with no response body (typically after DELETE).
  • 4xx -- Client Error: Something was wrong with your request.
    • 400 Bad Request -- The request data was invalid or malformed.
    • 401 Unauthorized -- Authentication is required but was not provided.
    • 403 Forbidden -- You are authenticated but lack permission for this action.
    • 404 Not Found -- The requested resource does not exist.
    • 429 Too Many Requests -- You have exceeded the rate limit.
  • 5xx -- Server Error: Something went wrong on the server side.
    • 500 Internal Server Error -- A generic server error occurred.
    • 503 Service Unavailable -- The server is temporarily down or overloaded.

Making GET Requests with Fetch

The fetch() function is the modern, built-in way to make HTTP requests in JavaScript. It returns a Promise that resolves to the Response object. Let us start with the simplest operation -- fetching data from a public API. We will use JSONPlaceholder, a free REST API for testing and prototyping.

Example: Fetching a List of Users

// Fetch all users from JSONPlaceholder API
async function fetchUsers() {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');

    // Check if the response was successful
    if (!response.ok) {
        throw new Error('Failed to fetch users: ' + response.status);
    }

    // Parse the JSON response body
    const users = await response.json();

    console.log('Total users:', users.length);
    console.log('First user:', users[0].name);

    return users;
}

// Call the function
fetchUsers().then(users => {
    users.forEach(user => {
        console.log(user.name + ' - ' + user.email);
    });
});
Note: The response.ok property is true when the status code is in the 200-299 range. Always check this before parsing the response body, because fetch() only rejects the Promise on network failures, not on HTTP error status codes like 404 or 500.

Fetching a Single Resource

To fetch a specific resource, append its ID to the endpoint URL. This is the standard REST pattern for retrieving individual items from a collection.

Example: Fetching a Single Post by ID

async function fetchPost(postId) {
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts/' + postId
    );

    if (!response.ok) {
        if (response.status === 404) {
            console.error('Post not found with ID:', postId);
            return null;
        }
        throw new Error('HTTP Error: ' + response.status);
    }

    const post = await response.json();
    console.log('Title:', post.title);
    console.log('Body:', post.body);

    return post;
}

// Fetch post with ID 1
fetchPost(1);

// Fetch a non-existent post
fetchPost(9999); // Will log "Post not found"

Making POST Requests -- Creating Data

To create a new resource, send a POST request with the data in the request body. You must specify the Content-Type header so the server knows how to parse the body, and you must serialize your data with JSON.stringify().

Example: Creating a New Post

async function createPost(title, body, userId) {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            title: title,
            body: body,
            userId: userId,
        }),
    });

    if (!response.ok) {
        throw new Error('Failed to create post: ' + response.status);
    }

    const newPost = await response.json();
    console.log('Created post with ID:', newPost.id);

    return newPost;
}

// Create a new post
createPost(
    'Understanding REST APIs',
    'REST APIs are the backbone of modern web applications...',
    1
);

Making PUT and PATCH Requests -- Updating Data

PUT replaces the entire resource, while PATCH updates only the specified fields. Choose the right method based on whether you are sending a complete replacement or a partial update.

Example: Updating a Post with PUT and PATCH

// PUT -- Replace the entire post
async function replacePost(postId, postData) {
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts/' + postId,
        {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(postData),
        }
    );

    if (!response.ok) {
        throw new Error('Failed to replace post: ' + response.status);
    }

    return await response.json();
}

// PATCH -- Update only the title
async function updatePostTitle(postId, newTitle) {
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts/' + postId,
        {
            method: 'PATCH',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ title: newTitle }),
        }
    );

    if (!response.ok) {
        throw new Error('Failed to update post: ' + response.status);
    }

    return await response.json();
}

// Replace entire post
replacePost(1, {
    title: 'Complete Replacement',
    body: 'This is a completely new body.',
    userId: 1,
});

// Update only the title
updatePostTitle(1, 'Updated Title Only');

Making DELETE Requests

DELETE requests remove a resource from the server. They typically do not require a request body. A successful deletion usually returns a 200 or 204 status code.

Example: Deleting a Post

async function deletePost(postId) {
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts/' + postId,
        {
            method: 'DELETE',
        }
    );

    if (!response.ok) {
        throw new Error('Failed to delete post: ' + response.status);
    }

    console.log('Post ' + postId + ' deleted successfully');
    return true;
}

deletePost(1);

Query Parameters and URL Construction

Query parameters allow you to filter, sort, search, and paginate API results. They are appended to the URL after a ? character. The URLSearchParams class provides a clean, safe way to build query strings without worrying about special character encoding.

Example: Building URLs with URLSearchParams

// Manual approach -- error-prone with special characters
const unsafeUrl = 'https://api.example.com/search?q=hello world&page=1';

// Safe approach -- URLSearchParams handles encoding
function buildApiUrl(baseUrl, params) {
    const url = new URL(baseUrl);
    const searchParams = new URLSearchParams(params);
    url.search = searchParams.toString();
    return url.toString();
}

// Build a search URL with multiple parameters
const searchUrl = buildApiUrl('https://api.example.com/products', {
    q: 'laptop case',
    category: 'electronics',
    min_price: '25',
    max_price: '100',
    sort: 'price_asc',
    page: '1',
});

console.log(searchUrl);
// https://api.example.com/products?q=laptop+case&category=electronics&min_price=25&max_price=100&sort=price_asc&page=1

// Fetch with query parameters
async function searchProducts(query, page) {
    const params = new URLSearchParams({
        q: query,
        page: page.toString(),
        limit: '20',
    });

    const response = await fetch(
        'https://api.example.com/products?' + params.toString()
    );

    if (!response.ok) {
        throw new Error('Search failed: ' + response.status);
    }

    return await response.json();
}
Pro Tip: Always use URLSearchParams or the URL constructor to build query strings. They automatically handle special characters like spaces, ampersands, and question marks that would break a manually constructed URL. This prevents subtle bugs that are difficult to debug.

Filtering and Querying with JSONPlaceholder

Many REST APIs support filtering via query parameters. JSONPlaceholder supports filtering by any field on the resource. This is a common pattern you will encounter in real APIs.

Example: Filtering Posts by User ID

// Get all posts by a specific user
async function fetchPostsByUser(userId) {
    const params = new URLSearchParams({ userId: userId.toString() });
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts?' + params.toString()
    );

    if (!response.ok) {
        throw new Error('Failed to fetch posts: ' + response.status);
    }

    const posts = await response.json();
    console.log('User ' + userId + ' has ' + posts.length + ' posts');

    return posts;
}

// Get comments for a specific post
async function fetchCommentsForPost(postId) {
    const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts/' + postId + '/comments'
    );

    if (!response.ok) {
        throw new Error('Failed to fetch comments: ' + response.status);
    }

    return await response.json();
}

// Nested resource: get comments for post 1
fetchCommentsForPost(1).then(comments => {
    comments.forEach(comment => {
        console.log(comment.name + ': ' + comment.body.substring(0, 50) + '...');
    });
});

Pagination Handling

Most APIs do not return all records at once. Instead, they split results into pages. Pagination is critical for performance -- imagine loading 10,000 products at once. Common pagination patterns include page-based, offset-based, and cursor-based pagination.

Example: Page-Based Pagination

// Pagination controller
class PaginatedFetcher {
    constructor(baseUrl, pageSize) {
        this.baseUrl = baseUrl;
        this.pageSize = pageSize || 10;
        this.currentPage = 1;
        this.totalPages = null;
        this.isLoading = false;
    }

    async fetchPage(page) {
        if (this.isLoading) {
            console.log('Already loading, please wait...');
            return null;
        }

        this.isLoading = true;

        try {
            const params = new URLSearchParams({
                _page: page.toString(),
                _limit: this.pageSize.toString(),
            });

            const response = await fetch(
                this.baseUrl + '?' + params.toString()
            );

            if (!response.ok) {
                throw new Error('Failed to fetch page: ' + response.status);
            }

            // Many APIs include total count in headers
            const totalCount = response.headers.get('X-Total-Count');
            if (totalCount) {
                this.totalPages = Math.ceil(
                    parseInt(totalCount) / this.pageSize
                );
            }

            const data = await response.json();
            this.currentPage = page;

            return {
                data: data,
                page: this.currentPage,
                totalPages: this.totalPages,
                hasNext: this.totalPages ? page < this.totalPages : data.length === this.pageSize,
                hasPrevious: page > 1,
            };
        } finally {
            this.isLoading = false;
        }
    }

    async nextPage() {
        return await this.fetchPage(this.currentPage + 1);
    }

    async previousPage() {
        if (this.currentPage > 1) {
            return await this.fetchPage(this.currentPage - 1);
        }
        return null;
    }
}

// Usage
const postFetcher = new PaginatedFetcher(
    'https://jsonplaceholder.typicode.com/posts',
    5
);

// Fetch the first page
postFetcher.fetchPage(1).then(result => {
    console.log('Page', result.page, 'of', result.totalPages);
    console.log('Posts:', result.data.length);
    console.log('Has next page:', result.hasNext);
});

Fetching All Pages Automatically

Sometimes you need to collect data from all pages. This requires making sequential requests until no more data is available. Be careful with this pattern -- always respect rate limits and consider if you truly need all the data at once.

Example: Collecting All Pages

async function fetchAllPages(baseUrl, pageSize) {
    const allData = [];
    let page = 1;
    let hasMore = true;

    while (hasMore) {
        const params = new URLSearchParams({
            _page: page.toString(),
            _limit: pageSize.toString(),
        });

        const response = await fetch(baseUrl + '?' + params.toString());

        if (!response.ok) {
            throw new Error('Failed on page ' + page + ': ' + response.status);
        }

        const data = await response.json();
        allData.push(...data);

        // If we got fewer items than the page size, we are on the last page
        hasMore = data.length === pageSize;
        page++;

        console.log('Fetched page ' + (page - 1) + ', total items: ' + allData.length);
    }

    return allData;
}

// Fetch all posts in batches of 10
fetchAllPages('https://jsonplaceholder.typicode.com/posts', 10)
    .then(allPosts => {
        console.log('Total posts fetched:', allPosts.length);
    });

Authentication: API Keys and Bearer Tokens

Most real-world APIs require authentication to identify who is making requests. The two most common methods are API keys (sent as a query parameter or header) and Bearer tokens (sent in the Authorization header). Understanding both patterns is essential for working with production APIs.

Example: Authentication Methods

// Method 1: API Key in query parameter
async function fetchWithApiKey(endpoint) {
    const params = new URLSearchParams({
        api_key: 'your_api_key_here',
    });

    const response = await fetch(endpoint + '?' + params.toString());
    return await response.json();
}

// Method 2: API Key in custom header
async function fetchWithHeaderKey(endpoint) {
    const response = await fetch(endpoint, {
        headers: {
            'X-API-Key': 'your_api_key_here',
        },
    });

    return await response.json();
}

// Method 3: Bearer Token in Authorization header
async function fetchWithBearerToken(endpoint, token) {
    const response = await fetch(endpoint, {
        headers: {
            'Authorization': 'Bearer ' + token,
            'Content-Type': 'application/json',
        },
    });

    if (response.status === 401) {
        console.error('Token expired or invalid. Please log in again.');
        // Redirect to login or refresh the token
        return null;
    }

    if (!response.ok) {
        throw new Error('Request failed: ' + response.status);
    }

    return await response.json();
}

// Method 4: Storing and refreshing tokens
class AuthenticatedClient {
    constructor(baseUrl) {
        this.baseUrl = baseUrl;
        this.accessToken = null;
        this.refreshToken = null;
    }

    async login(username, password) {
        const response = await fetch(this.baseUrl + '/auth/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ username, password }),
        });

        if (!response.ok) {
            throw new Error('Login failed: ' + response.status);
        }

        const data = await response.json();
        this.accessToken = data.accessToken;
        this.refreshToken = data.refreshToken;

        return data;
    }

    async request(endpoint, options) {
        if (!options) options = {};
        if (!options.headers) options.headers = {};

        options.headers['Authorization'] = 'Bearer ' + this.accessToken;

        const response = await fetch(this.baseUrl + endpoint, options);

        // If token expired, try refreshing
        if (response.status === 401 && this.refreshToken) {
            await this.refreshAccessToken();
            options.headers['Authorization'] = 'Bearer ' + this.accessToken;
            return await fetch(this.baseUrl + endpoint, options);
        }

        return response;
    }

    async refreshAccessToken() {
        const response = await fetch(this.baseUrl + '/auth/refresh', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ refreshToken: this.refreshToken }),
        });

        if (!response.ok) {
            throw new Error('Token refresh failed. Please log in again.');
        }

        const data = await response.json();
        this.accessToken = data.accessToken;
    }
}
Security Warning: Never expose API keys or tokens in client-side JavaScript code that runs in the browser. Anyone can view your source code and steal your credentials. In production, proxy API calls through your own backend server that stores the keys securely. For learning purposes we show the pattern here, but real applications must keep secrets server-side.

Rate Limiting

APIs limit how many requests you can make in a given time period to prevent abuse and ensure fair usage. When you hit a rate limit, the API returns a 429 Too Many Requests status. The response headers often tell you when you can retry. Handling rate limits gracefully is essential for building reliable applications.

Example: Rate Limit Handler

class RateLimitedFetcher {
    constructor(requestsPerSecond) {
        this.minInterval = 1000 / requestsPerSecond;
        this.lastRequestTime = 0;
        this.queue = [];
        this.processing = false;
    }

    async fetch(url, options) {
        return new Promise((resolve, reject) => {
            this.queue.push({ url, options, resolve, reject });
            this.processQueue();
        });
    }

    async processQueue() {
        if (this.processing || this.queue.length === 0) return;
        this.processing = true;

        while (this.queue.length > 0) {
            const now = Date.now();
            const timeSinceLastRequest = now - this.lastRequestTime;

            if (timeSinceLastRequest < this.minInterval) {
                await new Promise(resolve =>
                    setTimeout(resolve, this.minInterval - timeSinceLastRequest)
                );
            }

            const { url, options, resolve, reject } = this.queue.shift();

            try {
                this.lastRequestTime = Date.now();
                const response = await fetch(url, options);

                // Handle rate limit response
                if (response.status === 429) {
                    const retryAfter = response.headers.get('Retry-After');
                    const waitTime = retryAfter
                        ? parseInt(retryAfter) * 1000
                        : 5000;

                    console.log('Rate limited. Waiting ' + waitTime + 'ms...');
                    await new Promise(r => setTimeout(r, waitTime));

                    // Retry the request
                    this.lastRequestTime = Date.now();
                    const retryResponse = await fetch(url, options);
                    resolve(retryResponse);
                } else {
                    resolve(response);
                }
            } catch (error) {
                reject(error);
            }
        }

        this.processing = false;
    }
}

// Allow maximum 2 requests per second
const limiter = new RateLimitedFetcher(2);

// These requests will be automatically spaced out
async function fetchMultipleUsers() {
    const userIds = [1, 2, 3, 4, 5];
    const users = [];

    for (const id of userIds) {
        const response = await limiter.fetch(
            'https://jsonplaceholder.typicode.com/users/' + id
        );
        const user = await response.json();
        users.push(user);
        console.log('Fetched user:', user.name);
    }

    return users;
}

Loading States and UI Feedback

Users need visual feedback when data is being loaded from an API. A well-designed application shows loading indicators, handles empty states, and displays errors clearly. This prevents confusion and makes the application feel responsive.

Example: Managing Loading States

class UIStateManager {
    constructor(containerId) {
        this.container = document.getElementById(containerId);
    }

    showLoading(message) {
        this.container.innerHTML =
            '<div class="loading-state">' +
            '  <div class="spinner"></div>' +
            '  <p>' + (message || 'Loading...') + '</p>' +
            '</div>';
    }

    showError(message, retryCallback) {
        this.container.innerHTML =
            '<div class="error-state">' +
            '  <p class="error-message">' + message + '</p>' +
            '  <button class="retry-button">Try Again</button>' +
            '</div>';

        if (retryCallback) {
            this.container
                .querySelector('.retry-button')
                .addEventListener('click', retryCallback);
        }
    }

    showEmpty(message) {
        this.container.innerHTML =
            '<div class="empty-state">' +
            '  <p>' + (message || 'No data available.') + '</p>' +
            '</div>';
    }

    showContent(html) {
        this.container.innerHTML = html;
    }
}

// Usage with API call
async function loadUserList() {
    const ui = new UIStateManager('user-list');
    ui.showLoading('Loading users...');

    try {
        const response = await fetch(
            'https://jsonplaceholder.typicode.com/users'
        );

        if (!response.ok) {
            throw new Error('Server returned ' + response.status);
        }

        const users = await response.json();

        if (users.length === 0) {
            ui.showEmpty('No users found.');
            return;
        }

        let html = '<ul class="user-list">';
        users.forEach(user => {
            html += '<li class="user-card">';
            html += '  <h3>' + user.name + '</h3>';
            html += '  <p>' + user.email + '</p>';
            html += '  <p>' + user.company.name + '</p>';
            html += '</li>';
        });
        html += '</ul>';

        ui.showContent(html);
    } catch (error) {
        ui.showError(
            'Failed to load users: ' + error.message,
            loadUserList
        );
    }
}

Building a Complete API Client

For any real project, you should create a reusable API client class that handles common tasks like base URL management, default headers, error handling, and response parsing. This avoids repeating the same boilerplate code in every API call.

Example: Reusable API Client Class

class ApiClient {
    constructor(baseUrl, defaultHeaders) {
        this.baseUrl = baseUrl;
        this.defaultHeaders = Object.assign(
            { 'Content-Type': 'application/json' },
            defaultHeaders || {}
        );
    }

    async request(endpoint, options) {
        const url = this.baseUrl + endpoint;

        const config = {
            headers: Object.assign({}, this.defaultHeaders, options.headers || {}),
            method: options.method || 'GET',
        };

        if (options.body) {
            config.body = JSON.stringify(options.body);
        }

        if (options.params) {
            const searchParams = new URLSearchParams(options.params);
            const fullUrl = url + '?' + searchParams.toString();
            const response = await fetch(fullUrl, config);
            return await this.handleResponse(response);
        }

        const response = await fetch(url, config);
        return await this.handleResponse(response);
    }

    async handleResponse(response) {
        if (!response.ok) {
            const errorBody = await response.text();
            let errorMessage;

            try {
                const errorJson = JSON.parse(errorBody);
                errorMessage = errorJson.message || errorBody;
            } catch (e) {
                errorMessage = errorBody;
            }

            const error = new Error(errorMessage);
            error.status = response.status;
            error.statusText = response.statusText;
            throw error;
        }

        // Handle 204 No Content
        if (response.status === 204) {
            return null;
        }

        return await response.json();
    }

    async get(endpoint, params) {
        return await this.request(endpoint, { method: 'GET', params: params });
    }

    async post(endpoint, body) {
        return await this.request(endpoint, { method: 'POST', body: body });
    }

    async put(endpoint, body) {
        return await this.request(endpoint, { method: 'PUT', body: body });
    }

    async patch(endpoint, body) {
        return await this.request(endpoint, { method: 'PATCH', body: body });
    }

    async delete(endpoint) {
        return await this.request(endpoint, { method: 'DELETE' });
    }
}

// Usage
const api = new ApiClient('https://jsonplaceholder.typicode.com');

// GET all posts
api.get('/posts').then(posts => {
    console.log('Posts:', posts.length);
});

// GET posts with query parameters
api.get('/posts', { userId: '1' }).then(posts => {
    console.log('User 1 posts:', posts.length);
});

// POST a new post
api.post('/posts', {
    title: 'New Post',
    body: 'This is the content.',
    userId: 1,
}).then(post => {
    console.log('Created:', post.id);
});

// DELETE a post
api.delete('/posts/1').then(() => {
    console.log('Post deleted');
});

Real-World Data Display: User List Application

Let us build a practical example that fetches users from the JSONPlaceholder API and displays them in a dynamic, interactive list with search filtering. This demonstrates how to combine API calls with DOM manipulation to build a real feature.

Example: Interactive User List with Search

class UserListApp {
    constructor(containerId) {
        this.container = document.getElementById(containerId);
        this.users = [];
        this.filteredUsers = [];
        this.api = new ApiClient('https://jsonplaceholder.typicode.com');
    }

    async initialize() {
        this.renderSearchBar();
        await this.loadUsers();
    }

    renderSearchBar() {
        const searchHtml =
            '<div class="search-container">' +
            '  <input type="text" id="user-search"' +
            '    placeholder="Search users by name or email...">' +
            '</div>' +
            '<div id="user-results"></div>';

        this.container.innerHTML = searchHtml;

        document.getElementById('user-search')
            .addEventListener('input', (e) => {
                this.filterUsers(e.target.value);
            });
    }

    async loadUsers() {
        const resultsDiv = document.getElementById('user-results');
        resultsDiv.innerHTML = '<p>Loading users...</p>';

        try {
            this.users = await this.api.get('/users');
            this.filteredUsers = this.users;
            this.renderUsers();
        } catch (error) {
            resultsDiv.innerHTML =
                '<p class="error">Failed to load users: ' +
                error.message + '</p>';
        }
    }

    filterUsers(query) {
        const lowerQuery = query.toLowerCase();
        this.filteredUsers = this.users.filter(user => {
            return (
                user.name.toLowerCase().includes(lowerQuery) ||
                user.email.toLowerCase().includes(lowerQuery) ||
                user.company.name.toLowerCase().includes(lowerQuery)
            );
        });
        this.renderUsers();
    }

    renderUsers() {
        const resultsDiv = document.getElementById('user-results');

        if (this.filteredUsers.length === 0) {
            resultsDiv.innerHTML = '<p>No users match your search.</p>';
            return;
        }

        let html = '<div class="user-grid">';
        this.filteredUsers.forEach(user => {
            html += '<div class="user-card" data-user-id="' + user.id + '">';
            html += '  <h3>' + user.name + '</h3>';
            html += '  <p class="email">' + user.email + '</p>';
            html += '  <p class="company">' + user.company.name + '</p>';
            html += '  <p class="city">' + user.address.city + '</p>';
            html += '  <button onclick="app.viewUserPosts(' + user.id + ')">';
            html += '    View Posts';
            html += '  </button>';
            html += '</div>';
        });
        html += '</div>';

        resultsDiv.innerHTML = html;
    }

    async viewUserPosts(userId) {
        const user = this.users.find(u => u.id === userId);

        try {
            const posts = await this.api.get('/posts', {
                userId: userId.toString()
            });

            let html = '<button onclick="app.renderUsers()">Back to Users</button>';
            html += '<h2>Posts by ' + user.name + '</h2>';

            posts.forEach(post => {
                html += '<div class="post-card">';
                html += '  <h4>' + post.title + '</h4>';
                html += '  <p>' + post.body + '</p>';
                html += '</div>';
            });

            document.getElementById('user-results').innerHTML = html;
        } catch (error) {
            console.error('Failed to load posts:', error);
        }
    }
}

// Initialize the application
const app = new UserListApp('app');
app.initialize();

Post Management: Full CRUD Interface

Let us build a complete post management interface that demonstrates all four CRUD operations together. This represents a realistic admin panel where you can create, view, edit, and delete resources through a REST API.

Example: Complete Post Manager

class PostManager {
    constructor(api) {
        this.api = api;
        this.posts = [];
    }

    async loadPosts(page, limit) {
        const params = {
            _page: (page || 1).toString(),
            _limit: (limit || 10).toString(),
        };

        this.posts = await this.api.get('/posts', params);
        return this.posts;
    }

    async createPost(title, body, userId) {
        const newPost = await this.api.post('/posts', {
            title: title,
            body: body,
            userId: userId,
        });

        this.posts.unshift(newPost);
        console.log('Post created with ID:', newPost.id);
        return newPost;
    }

    async updatePost(postId, updates) {
        const updatedPost = await this.api.patch(
            '/posts/' + postId,
            updates
        );

        const index = this.posts.findIndex(p => p.id === postId);
        if (index !== -1) {
            this.posts[index] = Object.assign(this.posts[index], updatedPost);
        }

        console.log('Post ' + postId + ' updated');
        return updatedPost;
    }

    async deletePost(postId) {
        await this.api.delete('/posts/' + postId);

        this.posts = this.posts.filter(p => p.id !== postId);
        console.log('Post ' + postId + ' deleted');
    }

    async getPostWithComments(postId) {
        const post = await this.api.get('/posts/' + postId);
        const comments = await this.api.get(
            '/posts/' + postId + '/comments'
        );

        post.comments = comments;
        return post;
    }
}

// Usage
const api = new ApiClient('https://jsonplaceholder.typicode.com');
const manager = new PostManager(api);

// Full CRUD workflow
async function demonstrateCrud() {
    // Read -- load first page of posts
    const posts = await manager.loadPosts(1, 5);
    console.log('Loaded', posts.length, 'posts');

    // Create -- add a new post
    const newPost = await manager.createPost(
        'My New Post',
        'This is the content of my new post.',
        1
    );

    // Update -- change the title
    await manager.updatePost(newPost.id, {
        title: 'Updated Title for My Post',
    });

    // Read with details -- get post and its comments
    const detailed = await manager.getPostWithComments(1);
    console.log('Post has', detailed.comments.length, 'comments');

    // Delete -- remove the post
    await manager.deletePost(newPost.id);
    console.log('CRUD workflow complete');
}

demonstrateCrud();
Pro Tip: When building production applications, consider adding request caching to your API client. Store GET responses in a Map with the URL as the key and set a time-to-live (TTL) for each entry. This reduces unnecessary network calls, speeds up your application, and helps you stay within API rate limits.
Note: JSONPlaceholder is a mock API, so POST, PUT, PATCH, and DELETE requests will return simulated responses but will not actually modify data on the server. This is perfect for learning and testing, but keep in mind that refreshing the page will always show the original data.

Practice Exercise

Build a complete Todo application using the JSONPlaceholder API (https://jsonplaceholder.typicode.com/todos). Your application should include the following features: a reusable API client class with methods for GET, POST, PATCH, and DELETE operations; a function that loads the first 20 todos and displays them in the page with checkboxes showing their completion status; a form to create new todos using a POST request; the ability to toggle a todo's completion status using a PATCH request; a delete button on each todo that removes it with a DELETE request; pagination controls that let the user navigate through pages of 20 todos each; and a filter that allows viewing all todos, only completed todos, or only incomplete todos. Implement proper loading states with a spinner while data loads, error messages when requests fail, and a retry button that reloads the data. Use URLSearchParams for all query parameter construction. Test your application by creating, updating, and deleting multiple todos in sequence.