Adding asc/desc sort ingto columns

Hi Mosh,

Happy holidays!

Your tutorial has been incredibly helpful—I’ve learned a lot from it!

I’m currently working on improving the IssuesPage by adding ascending/descending sorting to columns. However, I’ve run into an issue where useState is restricted to client components, and I need it to manage the sorting state. To address this, I’m considering transforming IssuesPage into a client component. Would this be the right approach?

Also, I have a couple of questions related to this topic:

  • I’ve noticed that IssuesPage directly accesses prisma for fetching issues without using an API. While it works for a backend component, I’m curious if this is a recommended practice in terms of application architecture.
  • The table in IssuesPage seems interactive—it displays an <ArrowUpIcon> when a column is clicked. Despite this dynamic behavior, I’m wondering why the table isn’t considered a client component.

Appreciate your insights,
Sawfiz

Add asc/desc sort into columns

When I set out to implement the ability to sort columns in asc/desc orders in the application, I anticipated a straightforward process. However, what seemed simple turned into a challenging endeavor that took me three days to complete.

Challenges Encountered:

  1. Initially, I believed it would involve simply adding a state variable in the IssuesPage to maintain the sorting order. However, I soon realized that useState could only be used within client-side components. This means, IssuesPage, which was a dynamically rendered server-side component, needs to become a client side component.
  2. Data fetching in the IssuesPage was initially directly performed using prisma, bypassing the need for an API call. This required implementing a dedicated API to enable data retrieval.
  3. Navigation within the application was facilitated using href in the <Link> component. However, after converting IssuePage into a client-side component, I encountered an issue where the links updated but needed a manual refresh (by pressing the enter key in the URL bar) to re-render the page.
  4. I changed searchParams to states, switched to using useRouter for navigation. and useQuery for fetching data. However, despite all these changes, the IssuePage failed to automatically refetch data upon changes in status and sort order. The switching to using stats and passing the params down the components especially made the code seem clunky.
  5. I also needed to figure out how to retrieve search parameters in the backend and validate them before using prisma to fetch data.

IssuesPage

Make searchParams a state variable

  • Add sort to the interface
  • Use the interface searchparams as the type of the state variable
  • All the undefined seem clunky to me
"use client";
import { IssueToolBar } from "@/app/components";
import { Issue, Status } from "@prisma/client";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { useState } from "react";
import IssuesPageSkeleton from "./_components/IssuesPageSkeleton";
import IssuesTable from "./_components/IssuesTable";

interface searchParams {
  status: Status | undefined;
  orderBy: keyof Issue | undefined;
  sort: "asc" | "desc";
}

const IssuesPage = () => {
  const [searchParams, setSearchParams] = useState<searchParams>({
    status: undefined,
    orderBy: undefined,
    sort: "asc",
  });

The handle change functions

There are to be passed down to the table and filter components for them to change sort and filter parameters.
This was originally written as a single function, but split into two to reduce complexity.

  const handleChangeStatus = ({ status }: { status: Status | undefined }) => {
    setSearchParams((prevSearchParams) => ({
      ...prevSearchParams,
      status,
    }));
  };

  const handleChangeSort = ({
    orderBy,
  }: {
    orderBy: keyof Issue | undefined;
  }) => {
    setSearchParams((prevSearchParams) => {
      const newSortDir = prevSearchParams.sort === "asc" ? "desc" : "asc";

      return {
        ...prevSearchParams,
        orderBy,
        sort: newSortDir,
      };
    });
  };

The states and functions are passed down to the components.
In the case of handleChangeStatus, the state and function need to be passed one layer further by <IssueToolBar> to <IssueStatusFilter>

  return (
    <div>
      <IssueToolBar
        status={searchParams.status}
        handleChangeStatus={handleChangeStatus}
      />
      <IssuesTable
        orderBy={searchParams.orderBy}
        sort={searchParams.sort}
        issues={issues}
        handleChangeSort={handleChangeSort}
      />
    </div>
  );

IssueStatusFilter

I learned how to type define a function in the interface.

interface IssueToolBarProps {
  status: Status | undefined;
  handleChangeStatus: ({ status }: { status: Status | undefined }) => void;
}

const IssueStatusFilter = ({
  status,
  handleChangeStatus,
}: IssueToolBarProps) => {
  const router = useRouter();
  return (
    <Select.Root
      defaultValue={status}
      onValueChange={(selectedStatus: "ALL" | Status) => {
        const query: undefined | Status =
          selectedStatus === "ALL" ? undefined : selectedStatus;
        handleChangeStatus({status: query});
      }}
    >

IssueTable

Add conditionally render of the sort order arrow icons

interface IssuesTableProps {
  orderBy: keyof Issue | undefined;
  sort: "asc" | "desc";
  issues: Issue[];
  handleChangeSort: ({ orderBy }: { orderBy: keyof Issue | undefined }) => void;
}

export default function IssuesTable({
  orderBy,
  sort,
  issues,
  handleChangeSort,
}: IssuesTableProps) {
  return (
    <Table.Root variant="surface">
      <Table.Header>
        <Table.Row>
          {columns.map((col) => (
            <Table.ColumnHeaderCell key={col.value} className={col.className}>
              <button
                onClick={() => {
                  handleChangeSort({ orderBy: col.value });
                }}
              >
                {col.label}
              </button>
              {/* Show an indicator if a column header is clicked */}
              {col.value === orderBy &&
                (sort === "asc" ? (
                  <ArrowUpIcon className="inline" />
                ) : (
                  <ArrowDownIcon className="inline" />
                ))}
            </Table.ColumnHeaderCell>
          ))}
        </Table.Row>
      </Table.Header>

Refetch data on state change

I was expect a state varible change to trigger a re-fetching of the issues and rerender of the IssuesPage, however, it did not work.

I found out there is refetch variable in useQuery, and tried to use it in useEffect, but could not get it working as I got a compliant saying useEffect can not be ran conditionally.

When I did get refreshing data working (I can not remember how), the refetch was lagging one state behind the current. e.g. if I change filter to “OPEN”, I need to change it again to see the “OPEN” issues.

The solution is in fact simple. I just needed to add searchParams into the queryKey. I should spend more time learning useQuery.

  const {
    isPending,
    error,
    data: issues,
  } = useQuery<Issue[]>({
    // By passing an array ["issues", searchParams] as the queryKey,
    // the useQuery hook will properly identify changes in searchParams
    // and trigger a refetch whenever searchParams change.
    queryKey: ["issues", searchParams],
    queryFn: () =>
      axios
        .get("/api/issues", {
          params: searchParams,
        })
        .then((res) => res.data),
  });

  if (isPending) return <IssuesPageSkeleton />;

  if (error) {
    return;
  }

Thoughts

I do not like the combersome passing of params down the components.
I wonder if it is still possible to get to passing the searchParams using href. And if that is possible, would the change of searchParams trigger useQuery to re-fetch data? Perhaps yes, because the searchParams will come in as params of IssuesPage.
To be investigated.

GET Issues API

Retrieving search parameters

In POST and PATCH api calls, we use get the body of a request using
const body = await request.json() where request: NextRequest
But this does not work for GET.

Instead, I need to use

export async function GET(request: NextRequest) {
  // Extract search parameters
  const searchParams = request.nextUrl.searchParams;

I can then destruct the searchParams and use the variables for validation

  const { status, orderBy, sort } = {
    status: searchParams.get('status'),
    orderBy: searchParams.get('orderBy'),
    sort: searchParams.get('sort'),
  };
  
  // Valid status
  const validation = getIssuesSchema.safeParse({
    status,
    orderBy,
    sort,
  });

  if (!validation.success)
    return NextResponse.json(
      { error: "Invalid key for sorting." },
      { status: 404 }
    );

Validation schema

The schema is defined in validationSechme.ts

  • Using enum and z.nativeEum gets rid of type errors.
  • Using nullable also helps with type errors
import { Status } from "@prisma/client";

// Validation schema for getting all issues
// [ 'OPEN', 'IN_PROGRESS', 'CLOSED' ]
enum orderByValues {
  Title = 'title',
  Status = 'status',
  UpdatedAt = 'updatedAt',
}
enum sortOrders {
  Asc = 'asc',
  Desc = 'desc'
}

export const getIssuesSchema = z.object({
  status: z.nativeEnum(Status).optional().nullable(),
  orderBy: z.nativeEnum(orderByValues).optional().nullable(),
  sort: z.nativeEnum(sortOrders).optional().nullable(),
})

Fetching data

I still have a type error with respect to where in the code below.
where in the prisma call should be of type IssueWhereInput | undefined
the where I am passing in is of type {status: stirng} | {status?: undefined}
==How do I rectify this issue?==

  // Validate and handle null or undefined values for status and orderBy
  // Only add status condition if it exists
  const where = status ? { status } : {};
  // Only add orderBy if it exists
  const orderByParam = orderBy ? { [orderBy]: sort } : undefined;

  try {
    const issues = await prisma.issue.findMany({
      where,
      orderBy: orderByParam,
    });
    return NextResponse.json(issues, { status: 200 });
  } catch (error) {
    console.log(error);
  }

Following up on trying to simplify and remove the states.

Remove the states

If I put searchParams back in IssuesPage as params, I can remove the states and handle change functions.
And useQuery can still refetch the data upon searchParams change
Somehow I am also able to get rid of the | undefined in the interfaces
The interface IssueQuery was defined in IssuesTable and imported here to simplify code.
This greatly simplifies the code.

"use client";
import { IssueToolBar } from "@/app/components";
import { Issue, Status } from "@prisma/client";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import IssuesPageSkeleton from "./_components/IssuesPageSkeleton";
import IssuesTable,{ IssueQuery }  from "./_components/IssuesTable";

const IssuesPage = ({ searchParams }: { searchParams: IssueQuery }) => {
  const {
    isPending,
    error,
    data: issues,
  } = useQuery<Issue[]>({
    // By passing an array ["issues", searchParams] as the queryKey,
    // the useQuery hook will properly identify changes in searchParams
    // and trigger a refetch whenever searchParams change.
    queryKey: ["issues", searchParams],
    queryFn: () =>
      axios
        .get("/api/issues", {
          params: searchParams,
        })
        .then((res) => res.data),
  });

  if (isPending) return <IssuesPageSkeleton />;

  if (error) {
    return;
  }

  return (
    <div>
      <IssueToolBar />
      <IssuesTable
        searchParams={searchParams}
        issues={issues}
      />
    </div>
  );
};

export default IssuesPage;

Add asc/desc sort

I do need to add a sortOrder state in IssuesTable
I followed the approach in IssueStatusFilter to construct a new URL and use router.push() to navigate

"use client";
import { IssueStatusBadge } from "@/app/components";
import { Issue, Status } from "@prisma/client";
import { ArrowDownIcon, ArrowUpIcon } from "@radix-ui/react-icons";
import { Table } from "@radix-ui/themes";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";

const columns: { label: string; value: keyof Issue; className?: string }[] = [
  { label: "Issue", value: "title" },
  { label: "Status", value: "status", className: "hidden md:table-cell" },
  { label: "Updated", value: "updatedAt", className: "hidden md:table-cell" },
];

export interface IssueQuery {
  status: Status;
  orderBy: keyof Issue;
  sort: "asc" | "desc";
}

interface IssuesTableProps {
  searchParams: IssueQuery;
  issues: Issue[];
}

export default function IssuesTable({
  searchParams,
  issues,
}: IssuesTableProps) {
  const router = useRouter();
  const [sortOrder, setSortOrder] = useState("asc");
  return (
    <Table.Root variant="surface">
      <Table.Header>
        <Table.Row>
          {columns.map((col) => (
            <Table.ColumnHeaderCell key={col.value} className={col.className}>
              <button
                onClick={() => {
                  const newSortOrder = sortOrder === "asc" ? "desc" : "asc";
                  setSortOrder(newSortOrder);

                  const params = new URLSearchParams();
                  if (searchParams.status)
                    params.append("status", searchParams.status);
                  params.append("orderBy", col.value);
                  params.append("sort", sortOrder);
                  const query = params.toString();

                  router.push("/issues?" + query);
                }}
              >
                {col.label}
              </button>
              {/* Show an indicator if a column header is clicked */}
              {col.value === searchParams.orderBy &&
                (sortOrder === "asc" ? (
                  <ArrowDownIcon className="inline" />
                ) : (
                  <ArrowUpIcon className="inline" />
                ))}
            </Table.ColumnHeaderCell>
          ))}
        </Table.Row>
      </Table.Header>
      <Table.Body>
        {issues.map((issue) => (
          <Table.Row key={issue.id}>
            <Table.RowHeaderCell>
              <Link href={`/issues/${issue.id}`}>{issue.title}</Link>
              <div className="block md:hidden">
                <IssueStatusBadge status={issue.status} />
              </div>
            </Table.RowHeaderCell>
            <Table.Cell className="hidden md:table-cell">
              <IssueStatusBadge status={issue.status} />
            </Table.Cell>
            <Table.Cell className="hidden md:table-cell">
              {new Date(issue.updatedAt).toDateString()}
            </Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
    </Table.Root>
  );
}

After all, adding asc/desc turned out to be quite straight forward. Took me 3 days, and I can full circle around, but I do not regret the time spent, as I learn many valuable lessons.

Please refer to my other post, Lost on data fetching in a server component

I will switch back to follow Mosh’s approach to fetch data using prisma directly.