Dodatne mogućnosti
Klijentski prikaz

Klijentski prikaz

Već smo spomenuli kako nam nedostaje stranica na ruti /pokemon na kojoj bi prikazali sve Pokemone. Iskoristiti ćemo tu priliku kako bi napravili stranicu koja se renderira na strani klijenta, te ćemo u njoj implementirati mogućnost dodatnog učitavanja (load more) na zahtjev korisnika.

Za početak, napravimo datoteku /pokemon/page.js te ćemo početi sa izradom komponente (stranice). Kao što smo već spominjali, u trenutnoj verziji "Next.js" (verzija 15 u trenutku pisanja) sve stranice koje radimo su zadano ("po defaultu") postavljene kao server-side stranice tj. stranice se renderiraju na strani poslužitelja i poslužuju u gotovom obliku.

Ukoliko želimo zadatak renderiranja prebaciti na klijenta (tj. njegov preglednik), na početku takve stranice moramo eksplicitno navesti kako se radi o klijentskoj varijanti stranice. To radimo jednostavno navodeći "use client na samom vrhu stranice. Iako korištenje client stranice može imati svojih nedostataka, svakako je prednost što možemo opet koristiti React hook-ove sa kojima smo se upoznali na prvom dijelu tečaja.

Prvo ćemo napraviti "kostur" stranice, definirati ćemo jedan state u kojem ćemo čuvati trenutni niz Pokemona te će nam također trebati jedno stanje kako bi pamtili do kojeg broja smo došli sa učitavanjem:

app/pokemon/page.js
"use client";
 
import { useState, useEffect } from "react";
 
export default function PokemonLista() {
  const [pokemons, setPokemons] = useState([]);
  const [offset, setOffset] = useState(0);
  const limit = 20;
 
  return <></>;
}

Imamo sve spremno za implementaciju učitavanja Pokemona. Dodati ćemo mogućnost automatskog učitavanja prvih 20 Pokemona pri prvom renderiranju, te ćemo pri svakoj promjeni offset stanja poslati novi zahtjev i dodati novo učitane Pokemone postojećem nizu:

  useEffect(() => {
    fetch(`https://pokeapi.co/api/v2/pokemon?offset=${offset}&limit=${limit}`)
      .then(res => res.json())
      .then(data => {
        setPokemons(prev => [...prev, ...data.results]);
      });
  }, [offset]);
}

Sve iz prethodnog primjera bi nam trebalo biti več poznato - koristimo useEffect kojeg smo vezali uz offset stanje. Pozivom efekta šaljemo GET zahtjev sa offset i limit vrijednostima, te rezultat zahtjeva dodajemo u postojeći niz Pokemona koristeći updater funkciju i spread operator.

Sada se možemo fokusirati na prikaz. Pokemoni su spremljeni u nizu - već smo dva puta radili gotovo istu stvar, pa nam prikaz ne bi trebao predstavljati nikakav problem:

return (
  <main className='p-4'>
    <h1 className='text-2xl mb-4'>Pokémon List (Load More)</h1>
    <ul className='space-y-1 mb-4'>
      {pokemons.map(p => (
        <li key={p.name}>{p.name}</li>
      ))}
    </ul>
  </main>
)

Ono što nam nedostaje je promjena offset vrijednosti koja bi onda uzrokovala i učitavanje idućeg skupa Pokemona. S obzirom da radimo slijedno učitavanje, sve što moramo napraviti je dodati button element koji će trenutnu vrijednost offset-a uvećati za 20.

<main className='p-4'>
  <h1 className='text-2xl mb-4'>Pokémon List (Load More)</h1>
  <ul className='space-y-1 mb-4'>
    {pokemons.map(p => (
      <li key={p.name}>{p.name}</li>
    ))}
  </ul>
  <button
    onClick={() => {off => off + 20}}
    className="bg-blue-600 text-white px-3 py-1 rounded"
  >
    Učitaj još...
  </button>
</main>

Pretraga

Prethodno implementirana funkcionalnost učitavanja velikog broja Pokemona je idealna prilika za dodavanje još jedne mogućnosti u našu aplikaciju. Radi se o pretraživanju tj. da budemo precizniji filtriranju rezultata - može nam se dogoditi da u jednom trenutku lista učitanih Pokemona bude jednostavno predugačka pa bi bilo dobro korisniku omogućiti filtriranje rezultata.

Općenito, funkcionalnost pretrage može biti implementirana na dva načina - može se odvijati na klijentu ili na poslužitelju. Kod klijentske pretrage, radi se zapravo samo o jednostavnoj pretrazi ili filtriranju dostupnih (učitanih podataka) te prikazu tih filtriranih podataka. Poslužiteljska pretraga je malo složenija - API nam mora dozvoljavati slanje parametara pretrage (npr. pretraga po djelomičnom imenu), te također moramo voditi računa o nekim naprednijim konceptima (npr. debounce slanje zahtjeva).

U našem primjeru ćemo se držati ove jednostavnije opcije (svakako nam je stranica sa opcijom dodatnog učitavanja implementirana kao klijentska stranica). Ponovno ćemo problem podijeliti na dva dijela - logika i prikaz.

Krenimo sa logikom stranice/komponente, potrebno nam je:

  • stanje za spremanje izraza pretrage
  • proces filtriranja podataka
  • reset (prikaz svih podataka)

Implementacija ne bi trebala biti previše zahtjevna:

"use client";
import { useState, useEffect } from "react";
 
export default function PokemonLista() {
 const [pokemons, setPokemons] = useState([]);
 const [offset, setOffset] = useState(0);
 const [pretraga, setPretraga] = useState("");
 const limit = 20;
 
 useEffect(() => {
   fetch(`https://pokeapi.co/api/v2/pokemon?offset=${offset}&limit=${limit}`)
     .then((res) => res.json())
     .then((data) => {
       setPokemons((prev) => [...prev, ...data.results]);
     });
 }, [offset]);
 
 const filterPokemoni = pokemons.filter((el) =>
   el.name.toLowerCase().includes(pretraga.toLowerCase())
 );
 
 function handleReset() {
   setPretraga("");
 }
 
 return (
   <!-- Izgled stranice>
 );
}

Napravili smo tri modifikacije u odnosu na prethodno stanje:

  • dodali state pretraga u koji ćemo spremati vrijednost <input> elementa
  • napisali smo logiku filtriranja niza prema tome sadrže li elementi upisani izraz
  • napisali funkciju za brisanje izraza pretrage (to će automatski poništiti filter)

Još nam je preostalo modificirati izgled stranice, a tu imamo samo dva zadatka - dodati (kontrolirani) input element i tipku za reset pretrage. Pokušajte to samostalno napraviti, a prijedlog rješenja možete vidjeti u primjeru ispod:

"use client";
 
import { useState, useEffect } from "react";
 
export default function PokemonLista() {
 const [pokemons, setPokemons] = useState([]);
 const [pretraga, setPretraga] = useState("");
 const [offset, setOffset] = useState(0);
 const limit = 20;
 
 useEffect(() => {
   fetch(`https://pokeapi.co/api/v2/pokemon?offset=${offset}&limit=${limit}`)
     .then(res => res.json())
     .then(data => {
       setPokemons(prev => [...prev, ...data.results]);
     });
 }, [offset]);
 
 
 const filterPokemoni = pokemons.filter(p =>
   p.name.toLowerCase().includes(pretraga.toLowerCase())
 );
 
 function handleReset() {
   setPretraga("");
 }
 
 return (
   <main className='p-4'>
     <h1 className='text-2xl mb-4'>Pokémon List (Load More)</h1>
     <div className="mb-4 flex gap-2">
       <input
         type="text"
         placeholder="Traži Pokémona..."
         className="border border-gray-300 rounded p-2"
         value={pretraga}
         onChange={(e) => setPretraga(e.target.value)}
       />
       <button
         onClick={handleReset}
         className="bg-gray-200 px-3 py-1 rounded"
       >
         Reset
       </button>
     </div>
     <ul className='space-y-1 mb-4'>
       {filterPokemoni.map(p => (
         <li key={p.name}>{p.name}</li>
       ))}
     </ul>
     <button
       onClick={() => {
         setOffset(off => off + limit);
       }}
       className='bg-blue-600 text-white px-3 py-1 rounded hover:text-red-400'
     >
       Učitaj još...
     </button>
   </main>
 );
}

Sada imamo implementirane opcije i za dodatno učitavanje i filtriranje. Postoji još samo jedna sitnica o kojoj bi mogli razmisliti - u trenutnoj verziji aplikacije, pritiskom na "Učitaj još", filter ostaje aktivan i novi rezultati će se prikazati samo ako sadrže traženi pojam (koji je upisan u input). Ovo može potencijalno zbuniti korisnika, pa ne bi bilo loše u tom slučaju također isključiti filter.

Naravno, to je trivijalno za implementirati (dodamo setPretraga("") na onClick u kojem već pozivamo setOffset) ali je važno za naglasiti kako prilikom implementacije bilo koje funkcionalnosti moramo voditi računa i o UX (user experience) dijelu aplikacije.

Last updated on April 9, 2025
Digitalna Dalmacija