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:
"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.
