Project: GitHub Profile Viewer
Checking access...
This project brings together everything from the React module — components, props, state, effects, routing, context, custom hooks, and performance — to build a complete GitHub profile viewer.
Project Overview
You’ll build an app that:
- Searches for GitHub users by username
- Displays user profile information (avatar, name, bio, stats)
- Shows a list of the user’s repositories
- Navigates between search and profile pages
- Handles loading, error, and empty states
- Uses GitHub’s public API (no authentication required)
Step 1: Project Setup
npm create vite@latest github-profile-viewer -- --template reactcd github-profile-viewernpm install react-router-domnpm run devStep 2: Project Structure
src/├── App.jsx├── main.jsx├── index.css├── contexts/│ └── ThemeContext.jsx├── hooks/│ ├── useFetch.js│ └── useLocalStorage.js├── pages/│ ├── SearchPage.jsx│ └── ProfilePage.jsx├── components/│ ├── SearchForm.jsx│ ├── UserCard.jsx│ ├── RepositoryList.jsx│ ├── LoadingSpinner.jsx│ └── ErrorMessage.jsx└── utils/ └── api.jsStep 3: API Utility
const BASE_URL = "https://api.github.com";
export async function searchUsers(query) { const response = await fetch( `${BASE_URL}/search/users?q=${encodeURIComponent(query)}&per_page=10` );
if (!response.ok) { if (response.status === 403) { throw new Error("API rate limit exceeded. Please try again later."); } throw new Error(`Search failed (${response.status})`); }
const data = await response.json(); return data.items;}
export async function getUserProfile(username) { const response = await fetch(`${BASE_URL}/users/${encodeURIComponent(username)}`);
if (!response.ok) { if (response.status === 404) { throw new Error(`User "${username}" not found`); } throw new Error(`Failed to load profile (${response.status})`); }
return response.json();}
export async function getUserRepos(username) { const response = await fetch( `${BASE_URL}/users/${encodeURIComponent(username)}/repos?sort=updated&per_page=20` );
if (!response.ok) { throw new Error(`Failed to load repositories (${response.status})`); }
return response.json();}Step 4: Custom Hooks
import { useState, useEffect, useCallback } from "react";
export function useFetch(fetchFn, immediate = false) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null);
const execute = useCallback(async (...args) => { setLoading(true); setError(null);
try { const result = await fetchFn(...args); setData(result); return result; } catch (err) { setError(err.message); throw err; } finally { setLoading(false); } }, [fetchFn]);
return { data, loading, error, execute };}import { useState } from "react";
export function useLocalStorage(key, initialValue) { const [storedValue, setStoredValue] = useState(() => { try { const item = localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch { return initialValue; } });
const setValue = (value) => { try { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); localStorage.setItem(key, JSON.stringify(valueToStore)); } catch (err) { console.warn(`Failed to save ${key}:`, err); } };
return [storedValue, setValue];}Step 5: Theme Context
import { createContext, useContext, useMemo } from "react";import { useLocalStorage } from "../hooks/useLocalStorage";
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) { const [theme, setTheme] = useLocalStorage("theme", "light");
const toggleTheme = () => { setTheme((prev) => (prev === "light" ? "dark" : "light")); };
const value = useMemo(() => ({ theme, toggleTheme }), [theme]);
return ( <ThemeContext.Provider value={value}> <div className={`app theme-${theme}`}> {children} </div> </ThemeContext.Provider> );}
export function useTheme() { const context = useContext(ThemeContext); if (!context) { throw new Error("useTheme must be used within a ThemeProvider"); } return context;}Step 6: Components
export function LoadingSpinner({ message = "Loading..." }) { return ( <div className="loading-container"> <div className="spinner" /> <p>{message}</p> </div> );}export function ErrorMessage({ message, onRetry }) { return ( <div className="error-container"> <p className="error-icon">⚠️</p> <p className="error-message">{message}</p> {onRetry && ( <button className="retry-btn" onClick={onRetry}> Try Again </button> )} </div> );}import { useState } from "react";
export function SearchForm({ onSearch, loading }) { const [query, setQuery] = useState("");
const handleSubmit = (e) => { e.preventDefault(); if (query.trim()) { onSearch(query.trim()); } };
return ( <form className="search-form" onSubmit={handleSubmit}> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search GitHub users..." className="search-input" autoFocus /> <button type="submit" className="search-btn" disabled={loading || !query.trim()}> {loading ? "Searching..." : "Search"} </button> </form> );}import { memo } from "react";import { Link } from "react-router-dom";
export const UserCard = memo(function UserCard({ user }) { return ( <Link to={`/user/${user.login}`} className="user-card"> <img src={user.avatar_url} alt={`${user.login}'s avatar`} className="user-avatar" /> <div className="user-info"> <h3 className="user-name">{user.login}</h3> {user.type && <span className="user-type">{user.type}</span>} </div> <span className="user-arrow">→</span> </Link> );});import { memo } from "react";
export const RepositoryList = memo(function RepositoryList({ repos }) { if (repos.length === 0) { return <p className="empty-state">No public repositories</p>; }
return ( <div className="repo-list"> <h2>Repositories ({repos.length})</h2> {repos.map((repo) => ( <div key={repo.id} className="repo-card"> <div className="repo-header"> <a href={repo.html_url} target="_blank" rel="noopener noreferrer" className="repo-name" > {repo.name} </a> {repo.fork && <span className="repo-badge">Fork</span>} </div> {repo.description && ( <p className="repo-description">{repo.description}</p> )} <div className="repo-meta"> {repo.language && ( <span className="repo-language"> <span className="lang-dot" /> {repo.language} </span> )} <span>⭐ {repo.stargazers_count}</span> <span>⑂ {repo.forks_count}</span> <span>Updated: {new Date(repo.updated_at).toLocaleDateString()}</span> </div> </div> ))} </div> );});Step 7: Pages
import { useCallback } from "react";import { SearchForm } from "../components/SearchForm";import { UserCard } from "../components/UserCard";import { LoadingSpinner } from "../components/LoadingSpinner";import { ErrorMessage } from "../components/ErrorMessage";import { useFetch } from "../hooks/useFetch";import { searchUsers } from "../utils/api";
export function SearchPage() { const { data: users, loading, error, execute } = useFetch(searchUsers);
const handleSearch = useCallback((query) => { execute(query); }, [execute]);
return ( <div className="search-page"> <h1 className="page-title">GitHub Profile Viewer</h1> <SearchForm onSearch={handleSearch} loading={loading} />
<div className="results"> {loading && <LoadingSpinner message="Searching users..." />}
{error && <ErrorMessage message={error} onRetry={() => users && execute()} />}
{!loading && !error && users && users.length === 0 && ( <p className="empty-state">No users found. Try a different search.</p> )}
{!loading && !error && users && users.length > 0 && ( <div className="user-list"> <p className="results-count">Found {users.length} users</p> {users.map((user) => ( <UserCard key={user.id} user={user} /> ))} </div> )} </div> </div> );}import { useEffect } from "react";import { useParams, Link } from "react-router-dom";import { LoadingSpinner } from "../components/LoadingSpinner";import { ErrorMessage } from "../components/ErrorMessage";import { RepositoryList } from "../components/RepositoryList";import { useFetch } from "../hooks/useFetch";import { getUserProfile, getUserRepos } from "../utils/api";
export function ProfilePage() { const { username } = useParams();
const { data: profile, loading: profileLoading, error: profileError, execute: fetchProfile, } = useFetch(getUserProfile);
const { data: repos, loading: reposLoading, error: reposError, execute: fetchRepos, } = useFetch(getUserRepos);
useEffect(() => { fetchProfile(username); fetchRepos(username); }, [username, fetchProfile, fetchRepos]);
if (profileLoading) { return <LoadingSpinner message="Loading profile..." />; }
if (profileError) { return <ErrorMessage message={profileError} />; }
if (!profile) return null;
return ( <div className="profile-page"> <Link to="/" className="back-link">← Back to Search</Link>
<div className="profile-header"> <img src={profile.avatar_url} alt={`${profile.login}'s avatar`} className="profile-avatar" /> <div className="profile-info"> <h1>{profile.name || profile.login}</h1> {profile.login !== profile.name && ( <p className="profile-username">@{profile.login}</p> )} {profile.bio && <p className="profile-bio">{profile.bio}</p>} {profile.company && <p className="profile-company">🏢 {profile.company}</p>} {profile.location && <p className="profile-location">📍 {profile.location}</p>} {profile.blog && ( <a href={profile.blog.startsWith("http") ? profile.blog : `https://${profile.blog}`} target="_blank" rel="noopener noreferrer" className="profile-blog" > 🔗 {profile.blog} </a> )} <div className="profile-stats"> <div className="stat"> <span className="stat-value">{profile.public_repos}</span> <span className="stat-label">Repos</span> </div> <div className="stat"> <span className="stat-value">{profile.followers}</span> <span className="stat-label">Followers</span> </div> <div className="stat"> <span className="stat-value">{profile.following}</span> <span className="stat-label">Following</span> </div> </div> <a href={profile.html_url} target="_blank" rel="noopener noreferrer" className="github-link" > View on GitHub → </a> </div> </div>
<div className="profile-repos"> {reposLoading && <LoadingSpinner message="Loading repositories..." />} {reposError && <ErrorMessage message={reposError} />} {repos && !reposLoading && <RepositoryList repos={repos} />} </div> </div> );}Step 8: App and Routing
import { BrowserRouter, Routes, Route } from "react-router-dom";import { ThemeProvider, useTheme } from "./contexts/ThemeContext";import { SearchPage } from "./pages/SearchPage";import { ProfilePage } from "./pages/ProfilePage";
function ThemeToggle() { const { theme, toggleTheme } = useTheme(); return ( <button className="theme-toggle" onClick={toggleTheme}> {theme === "light" ? "🌙" : "☀️"} </button> );}
function AppContent() { return ( <div className="app-container"> <ThemeToggle /> <Routes> <Route path="/" element={<SearchPage />} /> <Route path="/user/:username" element={<ProfilePage />} /> </Routes> </div> );}
export default function App() { return ( <BrowserRouter> <ThemeProvider> <AppContent /> </ThemeProvider> </BrowserRouter> );}import React from "react";import ReactDOM from "react-dom/client";import App from "./App";import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render( <React.StrictMode> <App /> </React.StrictMode>);Step 9: CSS
Add styles to src/index.css — here’s a functional minimal theme:
* { margin: 0; padding: 0; box-sizing: border-box; }body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.theme-light { --bg: #f6f8fa; --card: #fff; --text: #24292e; --border: #e1e4e8; --accent: #0366d6; }.theme-dark { --bg: #0d1117; --card: #161b22; --text: #c9d1d9; --border: #30363d; --accent: #58a6ff; }
.app { background: var(--bg); color: var(--text); min-height: 100vh; }.app-container { max-width: 800px; margin: 0 auto; padding: 2rem 1rem; position: relative; }
.theme-toggle { position: fixed; top: 1rem; right: 1rem; font-size: 1.5rem; background: none; border: none; cursor: pointer; }
/* Search */.search-form { display: flex; gap: 0.5rem; margin: 2rem 0; }.search-input { flex: 1; padding: 0.75rem 1rem; border: 2px solid var(--border); border-radius: 8px; background: var(--card); color: var(--text); font-size: 1rem; }.search-btn { padding: 0.75rem 1.5rem; background: var(--accent); color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; }.search-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* User card */.user-card { display: flex; align-items: center; gap: 1rem; padding: 1rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; text-decoration: none; color: inherit; margin-bottom: 0.5rem; transition: border-color 0.2s; }.user-card:hover { border-color: var(--accent); }.user-avatar { width: 48px; height: 48px; border-radius: 50%; }.user-info { flex: 1; }.user-name { font-size: 1.1rem; }.user-type { font-size: 0.85rem; color: #666; }
/* Profile */.profile-header { display: flex; gap: 2rem; margin: 2rem 0; align-items: flex-start; }.profile-avatar { width: 120px; height: 120px; border-radius: 50%; }.profile-stats { display: flex; gap: 2rem; margin: 1rem 0; }.stat { text-align: center; }.stat-value { display: block; font-size: 1.25rem; font-weight: bold; }.stat-label { font-size: 0.85rem; color: #666; }
/* Repos */.repo-card { padding: 1rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.5rem; }.repo-name { font-size: 1.1rem; font-weight: 600; color: var(--accent); text-decoration: none; }.repo-meta { display: flex; gap: 1rem; margin-top: 0.5rem; font-size: 0.85rem; color: #666; }
/* States */.loading-container, .error-container, .empty-state { text-align: center; padding: 3rem; }.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 1rem; }@keyframes spin { to { transform: rotate(360deg); } }
.error-message { color: #f85149; margin-bottom: 1rem; }.retry-btn { padding: 0.5rem 1rem; background: var(--accent); color: #fff; border: none; border-radius: 6px; cursor: pointer; }
.back-link { display: inline-block; margin-bottom: 1rem; color: var(--accent); text-decoration: none; }Step 10: Run It
npm run devOpen the app, search for a GitHub username (try “facebook”, “vercel”, or your own username), and click through to see their profile and repositories.
Extension Ideas
- Pagination: Add “Load More” for repositories
- Favourites: Save favourite users using localStorage
- Compare users: Select two users and compare their stats side by side
- Commit activity: Show the user’s contribution graph
- Organisations: Display the organisations the user belongs to
- Readme viewer: Fetch and render the user’s profile README