nico.fyi
    Published on

    How to create infinite scroll with server action and useActionState in Next.js

    No fetching, no useState, and no API endpoint

    Authors

    Let's imagine we want to show a list of posts. At the bottom of the list, we want to show a button that allows the user to load more posts. Before React Server Components and Server Actions, we would have to

    • store the posts in a state using useState
    • fetch the posts from an API endpoint using fetch
    • store the loading state in a state using useState
    • create the API end point

    But Server Actions and useActionState simplify all of this. First, let's create a page component that starts the work of getting the posts from the database and stream the promise to the client as shown in the following code:

    app/infinite-scroll/page.tsx
    import { getPostsFromDbOrSomething } from "./db";
    import Posts from "./posts-list";
    import { Suspense } from "react";
    
    export default async function Page() {
      const posts = getPostsFromDbOrSomething(0);
      return (
        <div className="mx-auto max-w-md w-full p-4">
          <h1 className="text-xl font-bold">Infinite Scroll Demo</h1>
          <Suspense fallback={<div>Loading...</div>}>
            <Posts work={posts} />
          </Suspense>
        </div>
      );
    }
    

    Then we have the client component that will be suspended when the page is first loaded until the promise is resolved as shown in the following code:

    app/infinite-scroll/posts-list.tsx
    "use client";
    
    import { use, useActionState } from "react";
    import { getPostsFromDbOrSomething } from "./db";
    import { PostComponent } from "@/components/custom-posts";
    import { Button } from "@/components/ui/button";
    import { getPostsAction } from "./actions";
    
    export default function PostsList({
      work,
    }: {
      work: ReturnType<typeof getPostsFromDbOrSomething>;
    }) {
      const posts = use(work); // suspend the component until the promise is resolved
    
      const [state, loadMore, isPending] = useActionState(getPostsAction, posts);
    
      const lastId = state.data.at(-1)?.id;
    
      return (
        <div className="space-y-2">
          {state.data.map((t) => {
            return <PostComponent key={t.id} {...t} />;
          })}
          {state.hasMore ? (
            <form action={loadMore}>
              <input type="hidden" name="lastId" value={lastId} />
              <Button
                disabled={isPending}
                type="submit"
                className="w-full"
                variant={"outline"}
              >
                {isPending ? "Loading..." : "Load more"}
              </Button>
            </form>
          ) : null}
        </div>
      );
    }
    

    We use the upcoming useActionState hook to get the returned value of the server action and the loading state of the action (line 16). The useActionState hook also returns a function that we have to use to trigger the server action instead of calling the server action directly.

    The server action itself receives the previous state of the action and the new data from the client as shown in the following code:

    app/infinite-scroll/actions.tsx
    "use server";
    
    import { PostsProps } from "@/components/custom-posts";
    import { getPostsFromDbOrSomething } from "./db";
    
    export const getPostsAction = async (
      prev: {
        hasMore: boolean;
        data: PostsProps[];
      },
      formData: FormData
    ) => {
      // Before continuing, check here if the user is authorized to get the posts.
      // Remember, server action is technically a public end point by default so you should treat it as such.
      // Read about how to secure your server action here: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#security
      const lastId = parseInt(formData.get("lastId") as string) || 0;
      const { data: posts, hasMore } = await getPostsFromDbOrSomething(lastId);
    
      return {
        hasMore,
        data: prev.data.concat(posts),
      };
    };
    

    Since the server action receive both the previous state and the new data from the client, we can simply concatenate the new data to the previous state and return it as the new state (line 21). You can check out the demo here. However, you can also store the posts in the client instead using useState and concatenate with the data returned by the server action.

    The nice thing about useActionState is that it automatically handles the loading state, unlike the current useFormState hook which will be deprecated in the future. If you still cannot upgrade the React you use in your project, you can use the useFormState hook instead as shown in the following code:

    app/infinite-scroll/posts-list-with-form-state-hook.tsx
    "use client";
    
    import { use } from "react";
    import { getPostsFromDbOrSomething } from "./db";
    import { PostComponent } from "@/components/custom-posts";
    import { Button } from "@/components/ui/button";
    import { getPostsAction } from "./actions";
    import { useFormState, useFormStatus } from "react-dom";
    
    export default function PostsList({
      work,
    }: {
      work: ReturnType<typeof getPostsFromDbOrSomething>;
    }) {
      const posts = use(work);
    
      const [state, loadMore] = useFormState(getPostsAction, posts);
    
      const lastId = state.data.at(-1)?.id;
    
      return (
        <div className="space-y-2">
          {state.data.map((t) => {
            return <PostComponent key={t.id} {...t} />;
          })}
          {state.hasMore ? (
            <form action={loadMore}>
              <input type="hidden" name="lastId" value={lastId} />
              <FormButton />
            </form>
          ) : null}
        </div>
      );
    }
    
    const FormButton = () => {
      const { pending } = useFormStatus();
      return (
        <Button
          disabled={pending}
          type="submit"
          className="w-full"
          variant={"outline"}
        >
          {pending ? "Loading..." : "Load more"}
        </Button>
      );
    };
    

    Unfortunately, since useFormState doesn't return the loading state, we need to use another hook called useFormStatus to get the loading state. But useFormStatus doesn't work when it's used in the component that renders the form. It should be in a component that is a children of the form. That's why we have to create a separate component called FormButton that will read the loading state from useFormStatus and render the button accordingly.

    Closing

    If you've been using Next.js App router, you have most likely used or at least heard about Server Actions. Server Actions are basically functions that are executed on the server and can be called "directly" from the client. React actually converts the functions into end points under the hood and when the "function" is called in the client, it's actually making an HTTP request to the synthesized end point. That's why you have to treat it as a public-facing end point and secure it properly!

    When a form receives a server action as an action prop in React, React will automatically send a POST request to the synthesized end point when the form is submitted. Since it's a POST request, naturally most people would expect data mutation to happen on the server. But that's not obligatory. You can just return data from the server action without any mutation. And that's how we could implement the infinite scroll

    • without manually creating an API end point
    • without manually sending the request to the end point
    • without using additional state to concatenate the new data to the existing data

    By the way, I have a book about Pull Requests Best Practices. Check it out!