Search any GitHub user's public repos, with real caching, background refetch, loading/error states, and infinite-scroll-style pagination — powered by TanStack Query.
- React 19 + TanStack Query (
@tanstack/react-query) - Vite (dev server + build)
- Vitest (unit tests)
- Plain CSS (no framework, no CSS-in-JS)
- GitHub REST API (unauthenticated — no token, no backend)
- Install Node.js 22 or newer.
- Clone this repo.
npm install
None required to run. See .env.example:
VITE_GITHUB_API_BASE_URL— optionally overrides the GitHub API base URL (defaults tohttps://api.github.com). Mainly useful for pointing at a mock server in tests/CI, not needed for normal use.
npm run dev— start the dev server (prints a local URL).npm test— run the Vitest suite.npm run build— production build todist/.npm run preview— serve the production build locally.
Live at github-dashboard-react-query.vercel.app. Deployed via vercel CLI (not the dashboard), so the deploy history stays in version control. vercel.json rewrites every path to index.html so a direct link or refresh on a repo detail page (/owner/repo) works correctly instead of 404ing. The GitHub repo is connected for automatic redeploys on push to main.
Type a GitHub username, see their public repos, sorted by most recently updated, loaded 10 at a time with a "Load more" button. The search box is live — there's no Search button. Typing updates a debounced value (src/hooks/useDebouncedValue.js, 400ms) and only the settled result feeds the query, so pausing while typing triggers one fetch instead of one per keystroke. Clearing the box shows a plain "type a username" prompt rather than firing a request with an empty username. The part that actually matters here isn't the UI — it's that every fetch goes through TanStack Query (useInfiniteQuery) instead of a plain useEffect + fetch. That gets several things for free that would otherwise be hand-rolled and easy to get subtly wrong:
- Caching — searching the same username again within a minute (
staleTimeinsrc/config/queryClient.js) reuses every page already fetched instead of spending another request. This matters because GitHub's unauthenticated REST API only allows 60 requests/hour per IP. - Loading vs. refetching —
isLoadingis only true on the very first fetch for a given query key;isFetchingcovers every fetch including silent background refetches;isFetchingNextPageis specifically true while a "Load more" click is in flight, so the button can say "Loading more…" without disturbing the rest of the page. - Structured error state —
isError/errorcome straight from the query, no manual try/catch state juggling in the component. - Page accumulation without hand-rolled state —
useInfiniteQuery'sdata.pagesarray holds every page fetched so far under one cache entry; the component just flattens it. Switching usernames gets a brand-new query key, so there's no stale "page 3" left over from a previous search to reset by hand. - No loading flash on a new search —
placeholderData: keepPreviousDatakeeps the previous username's results on screen (dimmed, viaisPlaceholderData) while a brand-new username's first page loads, instead of the list going blank between one search and the next.
There's deliberately no GitHub API token anywhere: this is a static client-side app, and any token embedded in it would be visible to anyone who opens dev tools — not actually secret. The trade-off is GitHub's public rate limit (60 requests/hour/IP), which src/services/githubService.js treats as a real, user-facing error ("GitHub API rate limit exceeded. Try again in a few minutes.") rather than a silent failure or a generic "something went wrong."
"Load more" doesn't guess whether a next page exists from the current page count — it reads the real answer from GitHub's Link response header (src/utils/parseLinkHeader.js), so the button is replaced with "That's every public repository." exactly when the API says there's nothing more to fetch.
Each card shows more than just a name and description: language, stars, forks, open issues, license, and last-updated date, plus topic pills and a small "Fork"/"Archived" badge when applicable. None of this needs an extra request — GitHub's repo-list endpoint already returns every one of these fields on the same response fetchUserRepos() already fetches, so showing more here is purely a rendering change in RepoCard.jsx.
Clicking a repo's name goes to a dedicated page fetching three things in parallel: full repo details, a language breakdown, and the 5 most recent commits — three independent useQuery calls, each with its own loading/error state, rather than one big combined fetch. That independence matters: if GitHub's commits endpoint is slow or rate-limited, the repo details and language bar still render — a failed/slow query never blocks the rest of the page. The language breakdown converts raw byte counts into percentages (src/utils/computeLanguagePercentages.js) and assigns each language a consistent color via a deterministic hash (src/utils/colorForLanguage.js), so the same language always renders the same color without hardcoding a lookup table for every language GitHub recognizes.
src/services/githubService.js— every GitHub API call:fetchUserRepos(),fetchRepoDetails(),fetchRepoLanguages(),fetchRepoCommits(). All share one internalassertGithubResponseOk()helper so the 404/403/other-error handling only has to be right in one place. Takes an injectablefetchFnparameter so tests never make real network calls.src/utils/parseLinkHeader.js— parses GitHub'sLinkheader into a{ rel: url }map. Pure function, no dependencies.src/utils/computeLanguagePercentages.js— converts a repo's raw language byte counts into sorted percentages.src/utils/colorForLanguage.js— deterministically maps a language name to a consistent color.src/config/queryClient.js— the sharedQueryClientinstance and its defaults (staleTime, retry behavior).src/hooks/useGithubRepos.js— wrapsfetchUserReposinuseInfiniteQuery, keyed by["github-repos", username, perPage].getNextPageParamreads each page'shasNextPageflag; the next page number is just "however many pages fetched so far, plus one," since pages are always requested in order with no gaps.placeholderData: keepPreviousDatakeeps the previous query key's pages visible (asisPlaceholderData) while a new key's first page is in flight.src/hooks/useDebouncedValue.js— generic hook that returns a value only after it stops changing for a given delay. Used to turn live keystrokes into a settled search term.src/hooks/useRepoDetail.js— three hooks (useRepoDetails,useRepoLanguages,useRepoCommits) for the detail page, each wrapping oneuseQuerycall.src/components/RepoDashboard.jsx— the search page's stateful container. Owns the raw search input, calls the hook, flattensdata.pagesinto one repo list, and renders the right thing for loading/error/success.src/components/RepoDetailPage.jsx— the/:owner/:repoNameroute's container, composing the three detail hooks and the two presentational pieces below.src/components/RepoLanguages.jsx,RepoCommitList.jsx— presentational, render a query's data/loading/error state for the languages bar and commit list respectively.src/components/SearchForm.jsx,RepoList.jsx,RepoCard.jsx,LoadMoreButton.jsx— presentational pieces with no data-fetching logic of their own.SearchFormis a fully controlled live-typing input (the parent owns the raw value and debounces it) with no submit button.LoadMoreButtonrenders "Load more", "Loading more…", or "That's every public repository." purely from thehasNextPage/isFetchingNextPageflags it's given.RepoCard's title links to the internal detail route and shows fork/archived badges, topic pills, and the extended stats row described above; a separate "View on GitHub ↗" link goes to the real repo.
- No tests for the React components themselves, per this project's testing standard: every service/utility function gets tested, UI-only components are exempt unless there's real branching logic worth covering.
useDebouncedValuefollows the same precedent already set byuseGithubRepos/useRepoDetail: it's a thin React-timing wrapper, not independently tested. - A two-segment URL that isn't a real repo (e.g.
/nonsense/nonsense) shows a real "Repository not found" error fromRepoDetailPagerather than the generic 404 page — this is intentional: the URL shape genuinely matchesowner/repo, so attempting the real lookup and showing GitHub's actual answer is more informative than a blanket "page not found." - Switching to a username that's never been searched before no longer shows a loading flash:
placeholderData: keepPreviousData(src/hooks/useGithubRepos.js) keeps the previous username's results on screen, dimmed via the.repo-list-staleclass, until the new username's data arrives.