Skip to main content

Skillber v1.0 is here!

Learn more

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

Terminal window
npm create vite@latest github-profile-viewer -- --template react
cd github-profile-viewer
npm install react-router-dom
npm run dev

Step 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.js

Step 3: API Utility

src/utils/api.js
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

src/hooks/useFetch.js
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 };
}
src/hooks/useLocalStorage.js
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

src/contexts/ThemeContext.jsx
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

src/components/LoadingSpinner.jsx
export function LoadingSpinner({ message = "Loading..." }) {
return (
<div className="loading-container">
<div className="spinner" />
<p>{message}</p>
</div>
);
}
src/components/ErrorMessage.jsx
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>
);
}
src/components/SearchForm.jsx
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>
);
}
src/components/UserCard.jsx
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>
);
});
src/components/RepositoryList.jsx
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

src/pages/SearchPage.jsx
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>
);
}
src/pages/ProfilePage.jsx
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

src/App.jsx
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>
);
}
src/main.jsx
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

Terminal window
npm run dev

Open 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

  1. Pagination: Add “Load More” for repositories
  2. Favourites: Save favourite users using localStorage
  3. Compare users: Select two users and compare their stats side by side
  4. Commit activity: Show the user’s contribution graph
  5. Organisations: Display the organisations the user belongs to
  6. Readme viewer: Fetch and render the user’s profile README