Back to Blog
Understanding Action and Loader in Remix
RemixActionLoader

Understanding Action and Loader in Remix

Ujjwal Singh Basnet
1/15/2025
8 min read,

According to official Remix documentation:

Remix is a full-stack web framework that lets you focus on the user interface and work through web standards to deliver a fast, slick, and resilient user experience. People are going to love using your stuff.

Remix.js emerges as a robust framework offering a fresh perspective on web development, emphasizing server-rendered React applications with an intuitive approach to routing, data management, and performance optimization. In this article, we'll explore Action and Loader in Remix by building a simple todo application, which makes it more exciting and powerful.

Let’s start with a fresh Remix project. Create a Remix project running the given command:

npx create-remix@latest --template remix-run/remix/templates/vite

This is how its root.tsx file actually looks:

import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";
import tailwindStyles from "./tailwind.css";
import type { LinksFunction } from "@remix-run/node";

export const links: LinksFunction = () => {
  return [{ rel: "stylesheet", href: tailwindStyles }];
};

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

Note: I have configured Tailwind in this project; you should not worry about this in this tutorial.

You can see the index page under the /app/routes folder as _index.tsx:

import type { MetaFunction } from "@remix-run/node";

export const meta: MetaFunction = () => {
  return [
    { title: "Todo Application" },
    {
      name: "description",
      content: "Let's build awesome todo application in Remix!",
    },
  ];
};

export default function Index() {
  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
      <h1 className="text-center">TODO</h1>
    </div>
  );
}

Now, let’s dive into the topic.

Loader

Loaders in Remix.js serve as the foundation for fetching data on the server side before rendering a page. Unlike traditional client-side data fetching methods, loaders execute only on the server during the routing phase, ensuring that data is available when rendering components. They support asynchronous operations and provide a clear separation of concerns between data fetching and presentation logic. A loader is called whenever a page loads or some mutation occurs.

Let’s add a loader in our _index.tsx file:

export async function loader() {
  try {
    const response = await axios.get(
      "https://jsonplaceholder.typicode.com/todos"
    );
    const data: todoType[] = response?.data;
    return data?.slice(0, 10);
  } catch (err) {
    // there's better way of handling error
    // but for now let's focus on loader itself
    console.log(err);
  }
}

export default function Index() {
  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
      <h1 className="text-center">TODO</h1>
    </div>
  );
}

I have declared a loader function outside the Index component. It simply returns an API response to the component. To use the result returned by the loader, Remix provides a special hook called useLoaderData. Let’s see it in action:

export default function Index() {
  // can even infer types from the return type of loader. Cool right? 😎
  const todoData = useLoaderData<typeof loader>();

  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
      <h1 className="text-center my-4 font-bold text-4xl">TODO</h1>
      <div className="w-fit mx-auto">
        {todoData?.map((todo) => (
          <div
            key={todo.id}
            className="flex mb-2 items-center p-4 shadow-md rounded-md gap-8"
          >
            <p className="w-full text-grey-darkest">{todo?.title}</p>
            <div className="flex gap-4">
              {todo?.completed ? (
                <Icon
                  icon="lets-icons:done-ring-round"
                  width="24"
                  height="24"
                  style={{ color: "green" }}
                />
              ) : (
                <Icon
                  icon="material-symbols:pending-actions"
                  width="24"
                  height="24"
                  style={{ color: "orange" }}
                />
              )}
              <Icon
                icon="clarity:remove-solid"
                width="24"
                height="24"
                style={{ color: "red" }}
              />
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

The loader provides data to the HTML document on the initial server render. On navigations in the browser, Remix will call the loader function via fetch from the browser.

Since we now understand loader and its usage, let’s talk about the action.

Action

According to the Remix documentation:

A route action is a server-only function to handle data mutations and other actions. If a non-GET request is made to your route (DELETE, PATCH, POST, or PUT) then the action is called before the loader.

Action complements the loader by providing an effective mechanism for handling user interaction and triggering server-side data mutations. In Remix, we generally make use of Form to trigger the action method.

export const addTodo = async (todo: todoType) => {
  try {
    const response = await axios.post(
      "https://jsonplaceholder.typicode.com/todos",
      todo
    );
    return response?.data;
  } catch (err) {
    console.log(err);
  }
};

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const { _action, ...values } = Object.fromEntries(formData);

  switch (_action) {
    case "add_todo":
      const todo = {
        id: Math.floor(Math.random() * 500),
        title: values?.todo_title as string,
        completed: false,
        userId: 1,
      };
      return await addTodo(todo);
  }
}

Now let’s understand the code. Here, I have defined an action method with request as a destructured parameter. The request object represents the incoming HTTP request made by the client to the server. It contains the action name and actual request body as well. Whenever we post something using Form, the action is triggered.

Now let’s see the client side of this as well:

export default function Index() {
  const todoData = useLoaderData<typeof loader>();

  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
      <h1 className="text-center my-4 font-bold text-4xl">TODO</h1>
      <div className="w-fit mx-auto">
        <Input />
        {todoData?.map((todo) => (
          <TodoList key={todo?.id} todo={todo} />
        ))}
      </div>
    </div>
  );
}

// and in /components/Input.tsx

import { Form } from "@remix-run/react";

const Input = () => {
  return (
    <Form method="post">
      <div className="flex my-8">
        <input
          className="shadow appearance-none border rounded w-full py-2 px-3 mr-4 text-grey-darker"
          name="todo_title"
          placeholder="Add Todo"
        />
        <button
          className="flex-no-shrink py-4 px-8 border-2 rounded text-teal border-teal hover:text-green-500 hover:bg-teal"
          type="submit"
          name="_action"
          value="add_todo"
        >
          Add
        </button>
      </div>
    </Form>
  );
};

export default Input;

I have refactored the index page to make it more readable. Here, the Input component has a Form component that Remix itself provides. Did you notice that the button has two attributes called name and value? The _action field represents the action identifier submitted by the client. It specifies the type of action to be performed, such as adding a todo, updating a todo, deleting a todo, etc.

When the form is submitted, it submits both the form values and the _action value. So, the request object gets the type of action to be performed (_action) and the form values.

switch (_action) {
  case "add_todo":
    const todo = {
      id: Math.floor(Math.random() * 500),
      title: values?.todo_title as string,
      completed: false,
      userId: 1,
    };
    return await addTodo(todo);
}

This block is run whenever a button with action value add_todo is submitted.

const { _action, ...values } = Object.fromEntries(formData);

values holds the request body, and we can access it as values?.todo_title here because we had an input with the name todo_title in the Input component. After the request is complete, the loader is called again and thus fresh, mutated data is presented to the client.

Note: Forms without an action prop (<Form method="post">) will automatically post to the same route within which they are rendered. Using the ?index param to disambiguate between parent and index routes is only useful if you're posting to an index route from somewhere besides the index route itself.

This is how action and loader work in Remix. Now, you can add the update and delete features similarly. It would be homework for you. But let me give you some outlines and hints:

// add this in each todo item for delete button
<Form method="post">
  <input type="hidden" value={todo?.id} name="todo_id" />
  <button type="submit" name="_action" value="delete_todo">
    X
  </button>
</Form>

// and in action, add another case as:
switch (_action) {
  case "add_todo":
    const todo = {
      id: Math.floor(Math.random() * 500),
      title: values?.todo_title as string,
      completed: false,
      userId: 1,
    };
    return await addTodo(todo);

  case "delete_todo":
    const todoId = values?.todo_id;
    return await deleteTodo(todoId);
}

In this way, you can pass the ID along with a hidden input and then perform the action for any kind of user interaction, including likes, updates, and many more. You do not have to stress over updating the state or mutating the data manually. Perform updates using a similar technique.

Bonus Tips

If you think action and loader methods pollute the presentation component and want to separate them, they can’t be defined in other components except route pages themselves, but you can refactor them as shown below:

// Create a utils folder in the /app directory and
// create a file as action.server.ts
// and add the following

import type { ActionFunction, ActionFunctionArgs } from "@remix-run/node";
import { addTodo, deleteTodo } from "~/data/todoMethods";

export const TodoAction: ActionFunction = async ({
  request,
}: ActionFunctionArgs) => {
  const formData = await request.formData();
  const { _action, ...values } = Object.fromEntries(formData);

  switch (_action) {
    case "add_todo":
      const todo = {
        id: Math.floor(Math.random() * 500),
        title: values?.todo_title as string,
        completed: false,
        userId: 1,
      };
      return await addTodo(todo);

    case "delete_todo":
      const todoId = values?.todo_id;
      return await deleteTodo(todoId);
  }
};

// and then in the _index.tsx

export { TodoAction as action } from "@utils/action.server";
// do similar with loader
export { TodoLoader as loader } from "@utils/loader.server";

export default function Index() {
  const todoData = useLoaderData<typeof loader>();

  return (
    // ...
  );
}

In this way, you can separate the action and loader from the UI logic. action.server.ts simply means the code only runs on the server. You can also place these files in a .server directory and normally name them action.ts.

Conclusion

Remix.js's loaders and actions offer an interesting and intuitive approach to data management and user interaction handling. By leveraging the power of loaders to fetch data on the server and actions to manipulate or mutate the data in response to user interactions, we can build dynamic web applications without worrying much about revalidation and mutation.