Updating and Deleting Users

User Update and Deletion API Exploration

Welcome back! Now that you can create users with POST, it’s time to explore what it looks like to modify and remove users from an API. Updating and deleting are two of the most common backend operations—and they come with some new considerations like “What if the user doesn’t exist?” and “What should we return when deletion succeeds?”

In this lesson, you’ll work with the dynamic user route app/routes/api.users.$id.tsx, which handles requests like /api/users/3. You’ll see how Remix uses loader() to fetch a single user by ID, and how action() can support multiple mutation methods—in this case, PUT (replace/update) and DELETE (remove). Finally, you’ll connect that behavior to the UI in app/routes/_index.tsx, focusing on handleReplace() and handleDelete().

The dynamic user route: /api/users/:id

This project uses a separate file for “single-user” operations: app/routes/api.users.$id.tsx.

  • The $id in the filename is Remix’s dynamic segment syntax.

  • A request to /api/users/5 will land in this file, and Remix will provide params.id to your route handlers.

  • This route supports:

    • GET /api/users/:id via loader()
    • PUT /api/users/:id via action()
    • DELETE /api/users/:id via action()

Fetching a user by ID with the loader

This loader() is responsible for looking up one user by ID and returning either the user object or a 404 if it doesn’t exist. The code below lives in app/routes/api.users.$id.tsx.

// app/routes/api.users.$id.tsx
import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from '@remix-run/node';
import { users } from '~/lib/data';

export async function loader({ params }: LoaderFunctionArgs) {
  const userId = parseInt(params.id!, 10);
  const user = users.find((u) => u.id === userId);

  if (!user) {
    return json({ error: 'User not found' }, { status: 404 });
  }
  return json(user);
}
  • params.id comes from the URL segment in /api/users/:id. The parseInt(..., 10) converts it into a number so it can be compared against numeric user.id values.
  • users.find(...) searches the in-memory array for the first matching ID. This is a simple stand-in for what would be a database query in a real app.
  • Returning a 404 with { error: 'User not found' } is important: it tells clients the resource doesn’t exist (as opposed to a generic “bad request”).
  • When the user exists, the response is json(user)—again, this route returns the raw object, not a wrapped { user: ... } payload.

One action, multiple methods: PUT and DELETE in action()

In Remix, action() can handle any non-GET method, so this route branches based on request.method. The action() below lives in app/routes/api.users.$id.tsx.

// app/routes/api.users.$id.tsx
/**
 * Handles PUT requests to /api/users/:id.
 * Updates an existing user's details.
 */
/**
 * Handles DELETE requests to /api/users/:id.
 * Deletes a user from the data store.
 */
export async function action({ request, params }: ActionFunctionArgs) {
  const userId = parseInt(params.id!, 10);
  • This route starts by parsing params.id once, and then reuses userId for both PUT and DELETE paths. That’s a small but helpful pattern: parse shared inputs up front.
  • The method branching (coming next) is what allows a single route file to support multiple behaviors cleanly.
  • Notice that there is no explicit validation around params.id being missing or invalid—params.id! asserts it exists. In this project, the UI always supplies an ID, and invalid IDs will simply fail to match any user and return 404.

Replacing a user with PUT

This chunk handles the PUT case. In the UI, this corresponds to “PUT replace user,” and on the server it replaces the stored user object for that ID.

// app/routes/api.users.$id.tsx (PUT branch)
  if (request.method === 'PUT') {
    const idx = users.findIndex(u => u.id === userId);
    if (idx === -1) {
      return json({ error: 'User not found' }, { status: 404 });
    }

    const updatedData = await request.json();
    users[idx] = {
      ...updatedData,
      id: userId
    };
    return json(users[idx]);
  }
  • findIndex(...) is used instead of find(...) because we need the array index to overwrite the existing entry. If the user isn’t present, findIndex returns -1, and we respond with a 404.
  • await request.json() reads the replacement payload. In this project, the UI always sends a full user-shaped payload (name, email, isActive, and optionally role).
  • users[idx] = { id: userId, ...updatedData } performs a full replacement while ensuring the ID is controlled by the URL, not the client body. Even if a client tries to send an id in updatedData, placing id: userId first ensures the server uses the URL ID.
  • The server returns the updated user object with a standard 200 OK response (the default when you don’t pass a status). This makes it easy for the UI to show what the record looks like after replacement.

Concept reminder: This implementation behaves like a replace (classic PUT semantics), not a partial update. If a field is omitted from the body, it will be missing from the stored record afterward. The UI avoids this by requiring name and email before sending the request.

Deleting a user with DELETE and returning 204

This chunk handles the DELETE case, removing the user from the array and returning a 204 No Content.

// app/routes/api.users.$id.tsx (DELETE branch)
  if (request.method === 'DELETE') {
    const userIndex = users.findIndex((u) => u.id === userId);

    if (userIndex === -1) {
      return json({ error: 'User not found' }, { status: 404 });
    }

    users.splice(userIndex, 1);

    return new Response(null, { status: 204 });
  }
  • Just like PUT, DELETE first checks whether the user exists. If not, it returns a 404, which helps clients distinguish “didn’t exist” from “deleted successfully.”
  • users.splice(userIndex, 1) mutates the in-memory array by removing exactly one item at the found index. In a database-backed system, this would be a delete query.
  • return new Response(null, { status: 204 }) is a key detail: 204 means success with no response body. That’s why the UI’s request handler checks if (response.status !== 204) before trying to parse a body.
  • This is a common REST pattern: after deletion, the server doesn’t need to send the deleted object back unless you specifically want that behavior.

Method fallback: rejecting unsupported methods

Finally, if a request method is neither PUT nor DELETE, the route returns a 405.

// app/routes/api.users.$id.tsx (fallback)
  return json({ error: 'Method not allowed' }, { status: 405 });
}
  • This makes the route’s contract explicit: it supports PUT and DELETE (and GET via loader), but not other methods like POST at this URL.
  • Returning 405 is especially useful during debugging because it immediately tells you “you hit the right route, but used the wrong method.”

How the UI triggers PUT and DELETE

The playground UI in app/routes/_index.tsx is designed to test these endpoints without Postman or curl. You don’t need to deeply study the entire component again—what matters here is how handleReplace() and handleDelete() build correct requests for /api/users/:id.

The UI relies on the shared helper handleRequest(method, path, body?) to actually send the HTTP call; handleReplace and handleDelete are mainly responsible for validation and choosing the correct endpoint.

PUT from the UI: handleReplace()

DELETE from the UI: handleDelete()

What to look for when you test in the playground

When you click PUT replace user:

  • If the ID doesn’t exist, the server returns 404 with { error: "User not found" }.
  • If it exists, the response body will be the updated user object and status will be 200.

When you click DELETE user:

  • If the ID doesn’t exist, you’ll see a 404.
  • If it succeeds, the response status will be 204 and the UI will show body: null (because there’s intentionally no content to parse).

Recap

In this lesson, you explored update and deletion through the dynamic user route app/routes/api.users.$id.tsx:

  • loader() handles GET /api/users/:id to fetch a single user (or return 404).

  • action() branches by method:

    • PUT replaces a user record for the given ID and returns the updated user.
    • DELETE removes the user and returns 204 No Content.
  • The playground UI in app/routes/_index.tsx triggers these behaviors using:

    • handleReplace() to validate inputs and send PUT to /api/users/:id
    • handleDelete() to validate the ID and send DELETE to /api/users/:id

With GET + POST + PUT + DELETE, you now have the full basic CRUD workflow for users—implemented with simple, testable Remix routes and an in-browser playground.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal