ReactJS #30: Proyek Akhir – Aplikasi Portal Berita


ReactJS #30: Proyek Akhir

Membuat Aplikasi Portal Berita dengan React dan API

Halo, jurnalis digital! 📸

Selamat datang di puncak petualangan React-mu! Setelah mempelajari banyak hal dari komponen, state, efek, routing, custom hooks, hingga optimasi, kini saatnya menggabungkan semua ilmu dalam satu proyek besar: Aplikasi Portal Berita yang mengambil data dari API berita sungguhan. Aplikasi ini akan memiliki halaman utama dengan daftar berita terkini, pencarian, filter kategori, halaman detail, dan tentu saja penanganan loading/error.

💡 Analogi: Bayangkan aplikasi ini adalah kios koran digital. API berita adalah para wartawan yang mengirimkan berita dari seluruh dunia. React adalah mesin cetak yang menyusun berita-berita itu menjadi halaman-halaman menarik yang bisa dibaca semua orang.

📋 Fitur Aplikasi

  • Menampilkan berita terbaru dari API (NewsAPI).
  • Filter berita berdasarkan kategori (umum, bisnis, teknologi, olahraga, dll).
  • Pencarian berita berdasarkan kata kunci.
  • Navigasi antar halaman dengan React Router (daftar berita → detail berita).
  • Pagination (halaman sebelumnya/berikutnya).
  • Custom hook untuk mengambil data.
  • Loading spinner dan pesan error yang ramah.

🔑 Langkah 0: Dapatkan API Key

Kita akan menggunakan NewsAPI (newsapi.org). Ini gratis untuk pengembangan (dengan batasan 100 permintaan/hari). Ikuti langkah berikut:

  1. Buka newsapi.org/register dan daftar dengan email.
  2. Setelah login, buka dashboard dan salin API Key (terlihat seperti kumpulan huruf dan angka).
  3. Simpan API key tersebut; kita akan gunakan nanti. Jangan bagikan ke publik! Untuk latihan lokal, aman.

⚙️ Langkah 1: Setup Proyek

Buka terminal dan buat proyek React baru menggunakan Vite (lebih cepat) atau CRA:

npm create vite@latest news-portal -- --template react cd news-portal npm install npm install axios react-router-dom npm run dev

Atau jika pakai Create React App:

npx create-react-app news-portal cd news-portal npm install axios react-router-dom npm start

Bersihkan file App.js dan App.css dari kode default.

📁 Langkah 2: Struktur Folder

Buat folder di dalam src seperti ini:

src/ ├── components/ │ ├── NewsCard.jsx │ ├── LoadingSpinner.jsx │ ├── ErrorMessage.jsx │ └── CategoryTabs.jsx ├── pages/ │ ├── Home.jsx │ └── ArticleDetail.jsx ├── hooks/ │ └── useFetchNews.js ├── services/ │ └── newsApi.js ├── context/ │ └── ThemeContext.js (opsional, untuk tema) ├── App.jsx └── main.jsx

🔌 Langkah 3: Service untuk API (newsApi.js)

Buat file src/services/newsApi.js untuk mengatur koneksi ke NewsAPI:

import axios from 'axios'; // Ganti dengan API key milikmu! const API_KEY = 'YOUR_API_KEY_HERE'; const BASE_URL = 'https://newsapi.org/v2'; export const fetchNews = async ({ category = 'general', query = '', page = 1, pageSize = 10 }) => { try { let endpoint = ''; if (query) { endpoint = `${BASE_URL}/everything?q=${query}&page=${page}&pageSize=${pageSize}&apiKey=${API_KEY}`; } else { endpoint = `${BASE_URL}/top-headlines?country=us&category=${category}&page=${page}&pageSize=${pageSize}&apiKey=${API_KEY}`; } const response = await axios.get(endpoint); return { articles: response.data.articles, totalResults: response.data.totalResults, }; } catch (error) { console.error('Error fetching news:', error); throw error; } };

Catatan: Untuk top-headlines kita menggunakan negara us (Amerika) sebagai contoh. Kamu bisa ganti dengan kode negara lain (id untuk Indonesia) jika ingin berita lokal.

🎣 Langkah 4: Custom Hook useFetchNews

Buat src/hooks/useFetchNews.js untuk menangani pengambilan data, loading, error, dan pagination:

import { useState, useEffect } from 'react'; import { fetchNews } from '../services/newsApi'; export const useFetchNews = (category, query, page) => { const [news, setNews] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [totalResults, setTotalResults] = useState(0); useEffect(() => { const getNews = async () => { setLoading(true); setError(null); try { const data = await fetchNews({ category, query, page }); setNews(data.articles); setTotalResults(data.totalResults); } catch (err) { setError(err.message); } finally { setLoading(false); } }; getNews(); }, [category, query, page]); // dijalankan ulang jika kategori, query, atau halaman berubah return { news, loading, error, totalResults }; };

🃏 Langkah 5: Komponen NewsCard

Buat src/components/NewsCard.jsx untuk menampilkan satu berita dalam bentuk kartu:

import { Link } from 'react-router-dom'; function NewsCard({ article }) { const { title, description, urlToImage, publishedAt, source, author } = article; // Format tanggal agar lebih ramah const formatDate = (dateString) => { const options = { year: 'numeric', month: 'long', day: 'numeric' }; return new Date(dateString).toLocaleDateString('id-ID', options); }; return ( <div className="news-card"> {urlToImage && ( <img src={urlToImage} alt={title} style={{ width: '100%', height: '200px', objectFit: 'cover', borderRadius: '30px' }} /> )} <h3>{title}</h3> <p>{description || 'Tidak ada deskripsi'}</p> <div style={{ display: 'flex', justifyContent: 'space-between', color: '#0369a1' }}> <span>📰 {source.name}</span> <span>📅 {formatDate(publishedAt)}</span> </div> <Link to="/article" state={{ article }}> <button style={{ marginTop: '15px' }}>Baca Selengkapnya</button> </Link> </div> ); } export default NewsCard;

Perhatikan Link mengirim data artikel melalui state. Ini memungkinkan halaman detail mengakses artikel tanpa perlu fetch ulang.

📄 Langkah 6: Halaman Home (Daftar Berita)

Buat src/pages/Home.jsx yang menampilkan berita, kategori tabs, pencarian, dan pagination:

import { useState, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useFetchNews } from '../hooks/useFetchNews'; import NewsCard from '../components/NewsCard'; import LoadingSpinner from '../components/LoadingSpinner'; import ErrorMessage from '../components/ErrorMessage'; const categories = [ 'general', 'business', 'technology', 'entertainment', 'sports', 'science', 'health' ]; function Home() { const [searchParams, setSearchParams] = useSearchParams(); const category = searchParams.get('category') || 'general'; const query = searchParams.get('q') || ''; const page = parseInt(searchParams.get('page') || '1'); const [searchInput, setSearchInput] = useState(query); const { news, loading, error, totalResults } = useFetchNews(category, query, page); // Update input ketika query berubah dari URL useEffect(() => { setSearchInput(query); }, [query]); const handleCategoryChange = (newCategory) => { setSearchParams({ category: newCategory, page: 1 }); }; const handleSearch = (e) => { e.preventDefault(); setSearchParams({ q: searchInput, page: 1 }); }; const handleNextPage = () => { setSearchParams({ ...Object.fromEntries(searchParams), page: page + 1 }); }; const handlePrevPage = () => { setSearchParams({ ...Object.fromEntries(searchParams), page: page - 1 }); }; const totalPages = Math.ceil(totalResults / 10); // asumsi pageSize=10 if (loading) return <LoadingSpinner />; if (error) return <ErrorMessage message={error} />; return ( <div> <h1>📰 Portal Berita Terkini</h1> {/* Form Pencarian */} <form onSubmit={handleSearch} style={{ marginBottom: '30px' }}> <input type="text" placeholder="Cari berita..." value={searchInput} onChange={(e) => setSearchInput(e.target.value)} style={{ padding: '15px', width: '70%', borderRadius: '50px', border: '3px solid #38bdf8', fontSize: '1.2em' }} /> <button type="submit" style={{ padding: '15px 30px', marginLeft: '10px' }}>🔍 Cari</button> </form> {/* Kategori Tabs */} <div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginBottom: '30px' }}> {categories.map(cat => ( <button key={cat} onClick={() => handleCategoryChange(cat)} style={{ background: cat === category ? '#38bdf8' : '#e0f2fe', color: cat === category ? 'white' : '#0369a1', padding: '10px 20px', borderRadius: '40px', border: '2px solid #38bdf8', cursor: 'pointer', fontSize: '1.1em' }} > {cat.charAt(0).toUpperCase() + cat.slice(1)} </button> ))} </div> {/* Daftar Berita */} {news.length === 0 ? ( <p>😢 Tidak ada berita ditemukan.</p> ) : ( news.map((article, index) => <NewsCard key={index} article={article} />) )} {/* Pagination */} {totalPages > 1 && ( <div style={{ display: 'flex', gap: '20px', justifyContent: 'center', marginTop: '30px' }}> <button onClick={handlePrevPage} disabled={page === 1}>⬅️ Sebelumnya</button> <span>Halaman {page} dari {totalPages}</span> <button onClick={handleNextPage} disabled={page === totalPages}>Berikutnya ➡️</button> </div> )} </div> ); } export default Home;

📖 Langkah 7: Halaman Detail Berita

Buat src/pages/ArticleDetail.jsx yang menerima data artikel dari location.state:

import { useLocation, Link, useNavigate } from 'react-router-dom'; import { useEffect } from 'react'; function ArticleDetail() { const location = useLocation(); const navigate = useNavigate(); const article = location.state?.article; // Jika tidak ada state (misal diakses langsung), redirect ke home useEffect(() => { if (!article) { navigate('/'); } }, [article, navigate]); if (!article) return null; // atau loading const { title, description, content, urlToImage, publishedAt, source, author, url } = article; const formatDate = (dateString) => { const options = { year: 'numeric', month: 'long', day: 'numeric' }; return new Date(dateString).toLocaleDateString('id-ID', options); }; return ( <div> <Link to="/">⬅️ Kembali ke Beranda</Link> <article style={{ marginTop: '30px' }}> {urlToImage && ( <img src={urlToImage} alt={title} style={{ width: '100%', maxHeight: '400px', objectFit: 'cover', borderRadius: '40px' }} /> )} <h1>{title}</h1> <p style={{ color: '#0369a1' }}> 📰 {source.name} | ✍️ {author || 'Anonim'} | 📅 {formatDate(publishedAt)} </p> <p style={{ fontSize: '1.3em', fontStyle: 'italic' }}>{description}</p> <div style={{ fontSize: '1.2em', lineHeight: 1.6 }}> {content ? content : 'Konten lengkap tidak tersedia. Silakan kunjungi sumber asli.'} </div> {url && ( <p><a href={url} target="_blank" rel="noopener noreferrer">🔗 Baca di sumber asli</a></p> )} </article> </div> ); } export default ArticleDetail;

⏳ Langkah 8: Komponen Loading dan Error

Buat src/components/LoadingSpinner.jsx:

function LoadingSpinner() { return ( <div style={{ textAlign: 'center', padding: '40px' }}> <div className="spinner" style={{ border: '8px solid #f3f3f3', borderTop: '8px solid #38bdf8', borderRadius: '50%', width: '60px', height: '60px', animation: 'spin 1s linear infinite', margin: '20px auto' }}></div> <p>⏳ Memuat berita...</p> </div> ); } export default LoadingSpinner;

dan src/components/ErrorMessage.jsx:

function ErrorMessage({ message }) { return ( <div style={{ backgroundColor: '#fee2e2', border: '3px solid #ef4444', borderRadius: '50px', padding: '30px', textAlign: 'center' }}> <p style={{ color: '#b91c1c', fontSize: '1.5em' }}>😵 {message}</p> </div> ); } export default ErrorMessage;

🧭 Langkah 9: Routing di App.jsx

Buka src/App.jsx dan atur router:

import { BrowserRouter, Routes, Route } from 'react-router-dom'; import Home from './pages/Home'; import ArticleDetail from './pages/ArticleDetail'; import './App.css'; function App() { return ( <BrowserRouter> <div className="App" style={{ maxWidth: '900px', margin: '0 auto', padding: '20px' }}> <Routes> <Route path="/" element={<Home />} /> <Route path="/article" element={<ArticleDetail />} /> </Routes> </div> </BrowserRouter> ); } export default App;

🎨 Langkah 10: Tambahkan Gaya Global

Di src/App.css atau src/index.css, tambahkan aturan CSS dasar:

* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Comic Sans MS', 'Chalkboard', 'Arial', sans-serif; background: linear-gradient(135deg, #e0f2fe, #bae6fd); min-height: 100vh; } .App { background: rgba(255, 255, 255, 0.9); border-radius: 80px; padding: 30px; margin: 20px; box-shadow: 0 20px 40px rgba(0, 150, 200, 0.3); border: 5px solid #38bdf8; } button { background-color: #f0a07c; border: none; color: #1e3a1e; padding: 12px 25px; font-size: 1.2em; border-radius: 60px; font-weight: bold; cursor: pointer; box-shadow: 0 8px 0 #b94e4e; transition: 0.1s; } button:hover { background-color: #f5b895; transform: translateY(-2px); box-shadow: 0 10px 0 #b94e4e; } button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: 0 5px 0 #7f5f2e; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }

🧪 Uji Coba

Jalankan aplikasi dengan npm run dev (atau npm start). Buka browser, kamu akan melihat portal berita dengan kategori dan pencarian. Klik salah satu berita untuk melihat detailnya. Pagination akan muncul jika total berita lebih dari 10.

💡 Catatan: NewsAPI memiliki batasan 100 permintaan/hari untuk akun gratis. Jika kehabisan, kamu bisa mendaftar beberapa akun atau menggunakan API alternatif. Untuk pengembangan, cobalah dengan kategori yang berbeda-beda.

🧩 Fitur Bonus yang Bisa Ditambahkan

  • Tema Gelap/Terang – gunakan useContext untuk menyimpan tema dan mengubah warna global.
  • Bookmark – simpan artikel favorit ke localStorage.
  • Filter berdasarkan negara – tambahkan pilihan negara selain AS.
  • Animasi – gunakan Framer Motion untuk transisi halaman.

🚀 Kesimpulan

Selamat! Kamu telah berhasil membuat aplikasi portal berita yang lengkap dan interaktif dengan React. Proyek ini menggabungkan:

  • Komponen fungsional dan state
  • useEffect untuk mengambil data dari API
  • Custom hook untuk memisahkan logika
  • React Router untuk navigasi dan pengiriman state
  • Pagination, pencarian, dan filter kategori
  • Penanganan loading dan error
  • Styling dengan CSS

Sekarang kamu siap untuk membuat aplikasi React yang lebih besar dan kompleks. Jangan berhenti di sini, teruslah berkarya! 🌟

Lebih baru Lebih lama

نموذج الاتصال