skip to Main Content

I’m facing a scenario that I can’t solve.

I’m working on a NextJS 14 application, that is querying data from an external GraphQL API.

I have a Customers page, that display all of our customers.

In order to fetch our Customers, I’m using a server action get-customers.ts :

"use server";

import { RequestResult } from "@/common/types/request.interface";
import { request } from "@/common/utils/http/request";
import {
  CustomersDocument,
  CustomersQuery,
  CustomersQueryVariables,
} from "@/graphql/generated/graphql";

export default async function getCustomers(
  variables: CustomersQueryVariables
): Promise<RequestResult<CustomersQuery>> {
  console.log("About to query customers");
  const { result }: { result: RequestResult<CustomersQuery> } = await request<
    CustomersQuery,
    CustomersQueryVariables
  >(CustomersDocument, variables, ["customers"]);

  return result;
}

In this server action, I’m calling request, which is my helper for fetching, and passing it some tags.

Then I have my page. In order to use my external API’s pagination, filtering and sorting feature, I have to re-call my query when my filters change, so I’m using useEffect, and therefore I need to declare my page as a "use client" page.

Here is my page (with truncated useless code) :

"use client";

export default function Customers() {
  const [filters, setFilters] = useState<FilterInput>({
    search: "",
    orderBy: "id",
    sortDirection: "desc",
  });
  const [paginationInput, setPaginationInput] = useState<PaginationInput>({
    page: 1,
    perPage: 10,
  });

  const handleFilterChange = useCallback((newFilters: FilterInput) => {
    setFilters((prev) => ({ ...prev, ...newFilters }));
  }, []);

  const handlePageChange = useCallback((newPage: number) => {
    setPaginationInput((prev) => ({ ...prev, page: newPage }));
  }, []);

  const [data, setData] = useState<CustomersQuery | null>(null);
  const [errors, setErrors] = useState<AppError[] | null>(null);

  useEffect(() => {
    const fetchData = async () => {
      const result: RequestResult<CustomersQuery> = await getCustomers({
        ...filters,
        ...paginationInput,
      });
      setData(result.data);
      setErrors(result.errors ?? null);
    };

    fetchData();
  }, [filters, paginationInput]);

  if (errors) return <div>An error occured !</div>;
  if (!data) return <div>Loading...</div>;

  return (
    <div className="grow flex flex-col gap-6">
      <CreateCustomerSheet />

      <div className="flex gap-2 items-center justify-between">
        <Input
          placeholder="Filter customers..."
          value={filters.search?.toString()}
          onChange={(event) => {
            setPaginationInput((prev) => ({ ...prev, page: 1 }));
            return handleFilterChange({ search: event.target.value });
          }}
          className="max-w-sm bg-background"
        />
        <CreateCustomerSheet />
      </div>
      <DataTable<Partial<CustomerType>>
        columns={columns}
        data={data.customers.data}
        paginationInput={paginationInput}
        paginationInfo={data.customers.paginationInfo}
        onPageChange={handlePageChange}
        filterInput={filters}
        onSortingChange={handleFilterChange}
      />
    </div>
  );
}

As you can see, this page is using a lot of client stuff.

Now, in my <CreateCustomerSheet />, there is a form, that is calling another server action that creates new customers :

export default async function createCustomer(
  _prevState: FormState,
  data: FormData
): Promise<any> {
  try {
    const formData = Object.fromEntries(data);
    const parsed = schema.safeParse(formData);
    console.log("Parsed", parsed);

    if (!parsed.success) {
      let errors = parsed.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      }));
      return { errors };
    }

    const { result } = await request<
      CreateCustomerMutation,
      CreateCustomerMutationVariables
    >(CreateCustomerDocument, {
      company_name: parsed.data.company_name,
      email: parsed.data.email,
      address1: parsed.data.address1,
      city: parsed.data.city,
      country: parsed.data.country,
    });

    if (result.data?.createCustomer) {
      console.log("revalidating tag");
      revalidateTag("/customers");
    }

    // if (result.errors) return { message: result.errors[0].message };
  } catch (error) {
    return { message: createAppError("UNKNOWN_ERROR").message };
  }
}

When I create a new customer, I can see my logs in my server, telling me that creation is successfull, that it is revalidating tag, but my query is not re-triggered.

What did I try ?

I tried removing all the client stuff in my page.tsx component in order to make it become a server component. Then when creating customer, I can clearly see my data getting updated.

What should I do ?

How could I make it a server component but keeping track of my user input when filtering ? I don’t understand how I could handle it.

In many examples I can find people querying data from server component before to pass it down to a client component, but I can’t manage to handle this case where I need my filters, pagination and sorting to update when I’m clicking on my datatable or filling my searchbar.

2

Answers


  1. Chosen as BEST ANSWER

    I came to a solution using URLSearchParams.

    page.tsx is a server component, getting searchParams :

    export default async function Customers({
      searchParams,
    }: {
      searchParams: { [key: string]: string | string[] | undefined };
    }) {
      const page = Number(searchParams.page) || 1;
      const perPage = Number(searchParams.perPage) || 10;
      const search = searchParams.search?.toString() || "";
      const orderBy = searchParams.orderBy?.toString() || "id";
      const sortDirection = searchParams.sortDirection?.toString() || "desc";
     const result = await getCustomers({
        page,
        perPage,
        search,
        orderBy,
        sortDirection,
      });
    
      if (result.errors) return <div>An error occurred!</div>;
      if (!result.data) return <div>No data available.</div>;
     return (
        <div className="grow flex flex-col gap-6">
          <CreateCustomerSheet />
          <Breadcrumb />
    
          <div className="flex gap-2 items-center justify-between">
            <CustomersList
              filters={filters}
              paginationInput={paginationInput}
              paginationInfo={result.data.customers.paginationInfo}
              data={result.data.customers.data}
            />
          </div>
        </div>
    

    Then CustomersList is declared as a "use client" component, and updates the url params :

    export function CustomersList({
      data,
      paginationInfo,
      filters,
      paginationInput,
    }: CustomersListProps) {
      const router = useRouter();
      const currentSearchParams = useSearchParams();
    
      const handleFilterChange = (newFilters: any) => {
        const params = new URLSearchParams(currentSearchParams);
        Object.entries(newFilters).forEach(([key, value]) => {
          if (value) {
            params.set(key, value.toString());
          } else {
            params.delete(key);
          }
        });
        params.set("page", "1"); // Reset to first page on filter change
        router.push(`/customers?${params.toString()}`);
      };
    
      const handlePageChange = (newPage: number) => {
        const params = new URLSearchParams(currentSearchParams);
        params.set("page", newPage.toString());
        router.push(`/customers?${params.toString()}`);
      };
    
      return (Input and html calling those functions)
    }
    

    This way filtering still works perfectly, and the page component being a server component, when calling revalidateTag("customers"), data is being updated automatically


  2. You can make your page.tsx a server component, and wrap your logic in a client component, and then import it in the page:

    // CustomerFilter.tsx
    "use client"
    import { CustomersQuery } from "@/graphql/generated/graphql"
    interface CustomerFilterProps {
        allCustomers: CustomersQuery
    }
    export default function CustomerFilter({ allCustomers }: CustomerFilterProps) {
      const [filters, setFilters] = useState<FilterInput>({
        search: "",
        orderBy: "id",
        sortDirection: "desc",
      });
      const [paginationInput, setPaginationInput] = useState<PaginationInput>({
        page: 1,
        perPage: 10,
      });
    
      const handleFilterChange = useCallback((newFilters: FilterInput) => {
        setFilters((prev) => ({ ...prev, ...newFilters }));
      }, []);
    
      const handlePageChange = useCallback((newPage: number) => {
        setPaginationInput((prev) => ({ ...prev, page: newPage }));
      }, []);
    
      const [data, setData] = useState<CustomersQuery>(allCustomers);
      const [errors, setErrors] = useState<AppError[] | null>(null);
    
      useEffect(() => {
        // using this type of filter will save you so much time compared to refetching, on the other hand when you use an async function it will take time to refetch customers, which does not satisfy users of your website.
        const filterCustomers = (filter: FilterInput, pagination: PaginationInput): CustomersQuery => {
          // this is where you implement filter logic
       }
       const newCustomers = filterCustomers(filters, paginationInput);
       setData(newCustomers)
      }, [filters, paginationInput]);
    
      if (errors) return <div>An error occured !</div>;
      if (!data) return <div>Loading...</div>;
    
      return (
        <div className="grow flex flex-col gap-6">
          <CreateCustomerSheet />
    
          <div className="flex gap-2 items-center justify-between">
            <Input
              placeholder="Filter customers..."
              value={filters.search?.toString()}
              onChange={(event) => {
                setPaginationInput((prev) => ({ ...prev, page: 1 }));
                return handleFilterChange({ search: event.target.value });
              }}
              className="max-w-sm bg-background"
            />
            <CreateCustomerSheet />
          </div>
          <DataTable<Partial<CustomerType>>
            columns={columns}
            data={data.customers.data}
            paginationInput={paginationInput}
            paginationInfo={data.customers.paginationInfo}
            onPageChange={handlePageChange}
            filterInput={filters}
            onSortingChange={handleFilterChange}
          />
        </div>
      );
    }
    
    // page.tsx
    import CustomerFilter from "@/components/CustomerFilter" // module path
    export default async function Customers() {
        const customers = await getCustomers(); // you call the function without any filters.
        return <CustomerFilter allCustomers={customers} />
    }
    

    this way whenever you revalidateTag is called the data is refetched from page.tsx, so it should be updated.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search