Suche und Paginierung hinzufügen

Im vorherigen Kapitel haben Sie die Ladeleistung Ihres Dashboards durch Streaming verbessert. Nun wenden wir uns der Seite /invoices zu und lernen, wie man Suche und Paginierung hinzufügt.

Startcode

Fügen Sie folgenden Code in Ihre Datei /dashboard/invoices/page.tsx ein:

/app/dashboard/invoices/page.tsx
import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';
 
export default async function Page() {
  return (
    <div className="w-full">
      <div className="flex w-full items-center justify-between">
        <h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
      </div>
      <div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>
      {/*  <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense> */}
      <div className="mt-5 flex w-full justify-center">
        {/* <Pagination totalPages={totalPages} /> */}
      </div>
    </div>
  );
}

Nehmen Sie sich Zeit, um sich mit der Seite und den Komponenten vertraut zu machen:

  1. <Search/> ermöglicht Benutzern die Suche nach bestimmten Rechnungen.
  2. <Pagination/> erlaubt die Navigation zwischen Rechnungsseiten.
  3. <Table/> zeigt die Rechnungen an.

Die Suchfunktion erstreckt sich über Client und Server. Wenn ein Benutzer eine Rechnung sucht, werden die URL-Parameter aktualisiert, Daten auf dem Server abgerufen und die Tabelle mit den neuen Daten neu gerendert.

Warum URL-Suchparameter verwenden?

Wie erwähnt, werden URL-Suchparameter zur Verwaltung des Suchstatus verwendet. Dieses Muster könnte neu sein, wenn Sie es gewohnt sind, clientseitigen State zu verwenden.

Vorteile der Implementierung mit URL-Parametern:

  • Lesezeichenfreundliche und teilbare URLs: Da die Suchparameter in der URL sind, können Benutzer den aktuellen Zustand der Anwendung inklusive Suchanfragen und Filtern als Lesezeichen speichern oder teilen.
  • Server-seitiges Rendering (SSR): URL-Parameter können direkt auf dem Server verarbeitet werden, um den initialen Zustand zu rendern.
  • Analytics und Tracking: Suchanfragen und Filter in der URL erleichtern die Verfolgung des Benutzerverhaltens ohne zusätzliche clientseitige Logik.

Hinzufügen der Suchfunktion

Folgende Next.js Client-Hooks werden für die Suchfunktion verwendet:

  • useSearchParams - Ermöglicht den Zugriff auf die Parameter der aktuellen URL. Beispielsweise sehen die Suchparameter für diese URL /dashboard/invoices?page=1&query=pending so aus: {page: '1', query: 'pending'}.
  • usePathname - Liest den aktuellen URL-Pfad. Für die Route /dashboard/invoices gibt usePathname '/dashboard/invoices' zurück.
  • useRouter - Ermöglicht die programmatische Navigation zwischen Routen in Client-Komponenten. Es stehen mehrere Methoden zur Verfügung.

Übersicht der Implementierungsschritte:

  1. Benutzereingabe erfassen.
  2. URL mit Suchparametern aktualisieren.
  3. URL mit dem Eingabefeld synchron halten.
  4. Tabelle entsprechend der Suchanfrage aktualisieren.

1. Benutzereingabe erfassen

Gehen Sie zur <Search>-Komponente (/app/ui/search.tsx). Dort sehen Sie:

  • "use client" - Dies ist eine Client-Komponente, daher können Event-Listener und Hooks verwendet werden.
  • <input> - Das Suchfeld.

Erstellen Sie eine neue handleSearch-Funktion und fügen Sie einen onChange-Listener zum <input>-Element hinzu. onChange ruft handleSearch bei Änderungen des Eingabewerts auf.

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
 
export default function Search({ placeholder }: { placeholder: string }) {
  function handleSearch(term: string) {
    console.log(term);
  }
 
  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
        onChange={(e) => {
          handleSearch(e.target.value);
        }}
      />
      <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
    </div>
  );
}

Überprüfen Sie die Funktionsweise in den Browser-Entwicklertools. Bei Eingabe in das Suchfeld sollte der Suchbegriff in der Konsole erscheinen.

Gut! Die Benutzereingabe wird erfasst. Nun muss die URL mit dem Suchbegriff aktualisiert werden.

2. URL mit Suchparametern aktualisieren

Importieren Sie den useSearchParams-Hook aus next/navigation und weisen Sie ihn einer Variable zu:

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
 
  function handleSearch(term: string) {
    console.log(term);
  }
  // ...
}

Erstellen Sie in handleSearch eine neue URLSearchParams-Instanz mit der searchParams-Variable.

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
  }
  // ...
}

URLSearchParams ist eine Web-API zur Manipulation von URL-Abfrageparametern. Anstatt komplexe String-Literale zu erstellen, können Sie damit den Parameter-String wie ?page=1&query=a erhalten.

Setzen Sie den Parameter-String basierend auf der Benutzereingabe. Bei leerer Eingabe sollte er gelöscht werden:

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
  }
  // ...
}

Mit dem Abfrage-String können Sie die URL mit den Next.js-Hooks useRouter und usePathname aktualisieren.

Importieren Sie useRouter und usePathname aus 'next/navigation' und verwenden Sie die replace-Methode von useRouter() in handleSearch:

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }
}

Hier die Erklärung:

  • ${pathname} ist der aktuelle Pfad, z.B. "/dashboard/invoices".
  • params.toString() wandelt die Eingabe in ein URL-freundliches Format um.
  • replace(${pathname}?${params.toString()}) aktualisiert die URL mit den Suchdaten, z.B. /dashboard/invoices?query=lee bei der Suche nach "Lee".
  • Die URL wird ohne Seitenneuladen aktualisiert dank clientseitiger Navigation von Next.js (wie im Kapitel Navigieren zwischen Seiten beschrieben).

3. URL und Eingabe synchron halten

Um sicherzustellen, dass das Eingabefeld mit der URL synchronisiert ist und bei geteilten URLs ausgefüllt wird, können Sie defaultValue mit dem Wert aus searchParams setzen:

/app/ui/search.tsx
<input
  className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
  placeholder={placeholder}
  onChange={(e) => {
    handleSearch(e.target.value);
  }}
  defaultValue={searchParams.get('query')?.toString()}
/>

defaultValue vs. value / Uncontrolled vs. Controlled

Bei State-Verwaltung des Eingabewerts würden Sie das value-Attribut für eine controlled component verwenden. Hier verwaltet React den State.

Da Sie jedoch keinen State verwenden, kann defaultValue genutzt werden. Das native Input-Element verwaltet seinen eigenen State, was in Ordnung ist, da die Suchanfrage in der URL gespeichert wird.

4. Tabelle aktualisieren

Abschließend muss die Tabellenkomponente die Suchanfrage widerspiegeln.

Gehen Sie zurück zur Rechnungsseite.

Page-Komponenten akzeptieren eine searchParams-Prop, sodass Sie die aktuellen URL-Parameter an die <Table>-Komponente übergeben können.

/app/dashboard/invoices/page.tsx
import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { Suspense } from 'react';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
 
export default async function Page(props: {
  searchParams?: Promise<{
    query?: string;
    page?: string;
  }>;
}) {
  const searchParams = await props.searchParams;
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;
 
  return (
    <div className="w-full">
      <div className="flex w-full items-center justify-between">
        <h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
      </div>
      <div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>
      <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense>
      <div className="mt-5 flex w-full justify-center">
        {/* <Pagination totalPages={totalPages} /> */}
      </div>
    </div>
  );
}

In der <Table>-Komponente werden die Props query und currentPage an die Funktion fetchFilteredInvoices() übergeben, die die passenden Rechnungen zurückgibt.

/app/ui/invoices/table.tsx
// ...
export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);
  // ...
}

Testen Sie die Änderungen. Bei einer Suche wird die URL aktualisiert, eine neue Serveranfrage gesendet und nur passende Rechnungen zurückgegeben.

Wann useSearchParams()-Hook vs. searchParams-Prop?

Es wurden zwei Methoden zum Extrahieren von Suchparametern verwendet. Die Wahl hängt davon ab, ob Sie auf Client- oder Server-Seite arbeiten.

  • <Search> ist eine Client-Komponente, daher wurde der useSearchParams()-Hook verwendet.
  • <Table> ist eine Server-Komponente, die eigene Daten abruft, daher können die searchParams von der Page an die Komponente übergeben werden.

Allgemein gilt: Für den Zugriff auf Parameter vom Client aus den useSearchParams()-Hook verwenden, um Server-Roundtrips zu vermeiden.

Best Practice: Debouncing

Glückwunsch! Sie haben die Suche mit Next.js implementiert! Aber es gibt noch etwas, was Sie zur Optimierung tun können.

Fügen Sie in Ihrer handleSearch-Funktion den folgenden console.log hinzu:

/app/ui/search.tsx
function handleSearch(term: string) {
  console.log(`Suche... ${term}`);
 
  const params = new URLSearchParams(searchParams);
  if (term) {
    params.set('query', term);
  } else {
    params.delete('query');
  }
  replace(`${pathname}?${params.toString()}`);
}

Geben Sie dann "Delba" in Ihre Suchleiste ein und überprüfen Sie die Konsole in den Dev-Tools. Was passiert?

Dev Tools Console
Suche... D
Suche... De
Suche... Del
Suche... Delb
Suche... Delba

Sie aktualisieren die URL bei jedem Tastenanschlag und fragen somit bei jedem Tastenanschlag Ihre Datenbank ab! Dies ist kein Problem, da unsere Anwendung klein ist, aber stellen Sie sich vor, Ihre Anwendung hätte Tausende von Nutzern, die jeweils bei jedem Tastenanschlag eine neue Anfrage an Ihre Datenbank senden.

Debouncing ist eine Programmierpraxis, die die Rate begrenzt, mit der eine Funktion ausgelöst werden kann. In unserem Fall möchten Sie die Datenbank nur abfragen, wenn der Benutzer mit der Eingabe aufgehört hat.

So funktioniert Debouncing:

  1. Ereignis auslösen: Wenn ein Ereignis, das gedebounced werden soll (z.B. ein Tastenanschlag im Suchfeld), auftritt, startet ein Timer.
  2. Warten: Wenn ein neues Ereignis auftritt, bevor der Timer abläuft, wird der Timer zurückgesetzt.
  3. Ausführung: Wenn der Timer das Ende seines Countdowns erreicht, wird die gedebouncede Funktion ausgeführt.

Sie können Debouncing auf verschiedene Arten implementieren, einschließlich der manuellen Erstellung Ihrer eigenen Debounce-Funktion. Um die Dinge einfach zu halten, verwenden wir eine Bibliothek namens use-debounce.

Installieren Sie use-debounce:

Terminal
pnpm i use-debounce

Importieren Sie in Ihrer <Search>-Komponente eine Funktion namens useDebouncedCallback:

/app/ui/search.tsx
// ...
import { useDebouncedCallback } from 'use-debounce';
 
// Innerhalb der Search-Komponente...
const handleSearch = useDebouncedCallback((term) => {
  console.log(`Suche... ${term}`);
 
  const params = new URLSearchParams(searchParams);
  if (term) {
    params.set('query', term);
  } else {
    params.delete('query');
  }
  replace(`${pathname}?${params.toString()}`);
}, 300);

Diese Funktion umschließt den Inhalt von handleSearch und führt den Code erst nach einer bestimmten Zeit aus, wenn der Benutzer mit der Eingabe aufgehört hat (300ms).

Geben Sie nun erneut in Ihre Suchleiste ein und öffnen Sie die Konsole in den Dev-Tools. Sie sollten Folgendes sehen:

Dev Tools Console
Suche... Delba

Durch Debouncing können Sie die Anzahl der an Ihre Datenbank gesendeten Anfragen reduzieren und somit Ressourcen sparen.

Pagination hinzufügen

Nachdem Sie die Suchfunktion eingeführt haben, werden Sie feststellen, dass die Tabelle nur 6 Rechnungen gleichzeitig anzeigt. Dies liegt daran, dass die Funktion fetchFilteredInvoices() in data.ts maximal 6 Rechnungen pro Seite zurückgibt.

Durch das Hinzufügen von Pagination können Benutzer durch die verschiedenen Seiten navigieren, um alle Rechnungen anzuzeigen. Lassen Sie uns sehen, wie Sie Pagination mit URL-Parametern implementieren können, genau wie bei der Suche.

Navigieren Sie zur <Pagination/>-Komponente und Sie werden feststellen, dass es sich um eine Client-Komponente handelt. Sie möchten keine Daten auf dem Client abrufen, da dies Ihre Datenbankgeheimnisse preisgeben würde (denken Sie daran, Sie verwenden keine API-Schicht). Stattdessen können Sie die Daten auf dem Server abrufen und sie der Komponente als Prop übergeben.

Importieren Sie in /dashboard/invoices/page.tsx eine neue Funktion namens fetchInvoicesPages und übergeben Sie das query aus searchParams als Argument:

/app/dashboard/invoices/page.tsx
// ...
import { fetchInvoicesPages } from '@/app/lib/data';
 
export default async function Page(
  props: {
    searchParams?: Promise<{
      query?: string;
      page?: string;
    }>;
  }
) {
  const searchParams = await props.searchParams;
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;
  const totalPages = await fetchInvoicesPages(query);
 
  return (
    // ...
  );
}

fetchInvoicesPages gibt die Gesamtanzahl der Seiten basierend auf der Suchanfrage zurück. Wenn es beispielsweise 12 Rechnungen gibt, die der Suchanfrage entsprechen, und jede Seite 6 Rechnungen anzeigt, dann wäre die Gesamtanzahl der Seiten 2.

Übergeben Sie als Nächstes das totalPages-Prop an die <Pagination/>-Komponente:

/app/dashboard/invoices/page.tsx
// ...
 
export default async function Page(props: {
  searchParams?: Promise<{
    query?: string;
    page?: string;
  }>;
}) {
  const searchParams = await props.searchParams;
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;
  const totalPages = await fetchInvoicesPages(query);
 
  return (
    <div className="w-full">
      <div className="flex w-full items-center justify-between">
        <h1 className={`${lusitana.className} text-2xl`}>Rechnungen</h1>
      </div>
      <div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Rechnungen suchen..." />
        <CreateInvoice />
      </div>
      <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense>
      <div className="mt-5 flex w-full justify-center">
        <Pagination totalPages={totalPages} />
      </div>
    </div>
  );
}

Navigieren Sie zur <Pagination/>-Komponente und importieren Sie die Hooks usePathname und useSearchParams. Wir werden diese verwenden, um die aktuelle Seite zu erhalten und die neue Seite festzulegen. Stellen Sie sicher, dass Sie auch den Code in dieser Komponente entkommentieren. Ihre Anwendung wird vorübergehend nicht funktionieren, da Sie die <Pagination/>-Logik noch nicht implementiert haben. Lassen Sie uns das jetzt tun!

/app/ui/invoices/pagination.tsx
'use client';
 
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';
 
export default function Pagination({ totalPages }: { totalPages: number }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;
 
  // ...
}

Erstellen Sie als Nächstes eine neue Funktion innerhalb der <Pagination>-Komponente namens createPageURL. Ähnlich wie bei der Suche verwenden Sie URLSearchParams, um die neue Seitenzahl festzulegen, und pathName, um die URL-Zeichenkette zu erstellen.

/app/ui/invoices/pagination.tsx
'use client';
 
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';
 
export default function Pagination({ totalPages }: { totalPages: number }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;
 
  const createPageURL = (pageNumber: number | string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', pageNumber.toString());
    return `${pathname}?${params.toString()}`;
  };
 
  // ...
}

Hier eine Zusammenfassung dessen, was passiert:

  • createPageURL erstellt eine Instanz der aktuellen Suchparameter.
  • Dann aktualisiert es den "page"-Parameter auf die angegebene Seitenzahl.
  • Schließlich konstruiert es die vollständige URL mit dem Pfadnamen und den aktualisierten Suchparametern.

Der Rest der <Pagination>-Komponente befasst sich mit Styling und verschiedenen Zuständen (erste, letzte, aktive, deaktivierte usw.). Wir werden hier nicht ins Detail gehen, aber Sie können den Code gerne durchsehen, um zu sehen, wo createPageURL aufgerufen wird.

Schließlich möchten Sie, wenn der Benutzer eine neue Suchanfrage eingibt, die Seitenzahl auf 1 zurücksetzen. Sie können dies tun, indem Sie die handleSearch-Funktion in Ihrer <Search>-Komponente aktualisieren:

/app/ui/search.tsx
'use client';
 
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
 
export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams = useSearchParams();
  const { replace } = useRouter();
  const pathname = usePathname();
 
  const handleSearch = useDebouncedCallback((term) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', '1');
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }, 300);
 

Zusammenfassung

Glückwunsch! Sie haben gerade die Suche und Pagination mit URL-Suchparametern und Next.js-APIs implementiert.

Zusammenfassend haben Sie in diesem Kapitel:

  • Suche und Pagination mit URL-Suchparametern anstelle von Client-State behandelt.
  • Daten auf dem Server abgerufen.
  • Den useRouter-Hook für flüssigere Client-seitige Übergänge verwendet.

Diese Muster unterscheiden sich von dem, was Sie möglicherweise gewohnt sind, wenn Sie mit Client-seitigem React arbeiten, aber hoffentlich verstehen Sie jetzt besser die Vorteile der Verwendung von URL-Suchparametern und der Verlagerung dieses States auf den Server.

On this page