GitHub User Search App

Build a beginner-friendly React app for searching GitHub profiles with API requests, loading states, errors, and debounced input

Time to implement the project: ~ 12-18 hours

  • React Components
  • useEffect
  • Async Fetch
  • GitHub API
  • Loading States
  • Error Handling
  • Debounce
  • Responsive UI

In this beginner-level React project, you will build a GitHub User Search App that allows users to search for GitHub profiles by username and view useful public information about each account. The app should include a search input, a submit or live-search behavior, a loading indicator while data is being requested, clear error messages when a profile is not found, and a clean profile result card with avatar, username, bio, follower count, repository count, location, and profile link.

This project is valuable because it introduces one of the most important frontend skills: working with external data. Instead of displaying only static content, your React components will communicate with the GitHub API, wait for a response, handle different request outcomes, and update the interface based on the result. You will practice useEffect, asynchronous fetch logic, controlled inputs, conditional rendering, debounce, and basic API-driven UI architecture in a realistic but manageable way.

Project Purpose and Learning Outcomes

The main purpose of this project is to teach you how React applications interact with real APIs. Many beginner projects focus only on layout, but professional frontend work usually involves requesting data, waiting for responses, showing intermediate states, and handling failures without breaking the user experience. This project gives you a practical introduction to that workflow using GitHub's public user API.

You will learn how to connect component state with API responses. When a user types a username, the app should decide when to send a request, how to show that the request is in progress, what to render when the user is found, and what to display when the username does not exist. This teaches you to think in UI states: empty state, loading state, success state, error state, and no-result state.

The project also helps you understand why debounce matters in real interfaces. Without debounce, a search input can trigger too many API calls while the user is still typing. By adding a small delay before sending the request, you create a smoother experience, reduce unnecessary network requests, and learn an important pattern used in search forms, autocomplete fields, filters, dashboards, and admin panels.

Recommended Knowledge Before You Start

This is a beginner React project, but it is best started after you already understand components, props, state, and basic event handling. You do not need advanced backend knowledge because the GitHub API already provides public data. However, you should be ready to work with asynchronous code and understand that API responses may arrive successfully, fail, or take longer than expected.

The project should be treated as a practical React exercise, not just a design task. A beautiful card is useful, but the real learning value comes from managing the request lifecycle correctly. Your app should remain stable when the input is empty, when the user searches too quickly, when GitHub returns an error, and when the internet connection is slow or unreliable.

  • Basic understanding of React components, JSX, props, and state
  • Ability to create controlled input fields and update state from user events
  • Introductory knowledge of useEffect and how it reacts to dependency changes
  • Basic understanding of fetch, async/await, JSON responses, and HTTP error status codes
  • Comfort with conditional rendering for loading, error, empty, and success states
  • Basic CSS skills for building a responsive profile card and search interface

Core Features of the GitHub Search App

The app should behave like a small but reliable search tool. A user should be able to enter a GitHub username, see clear feedback while the request is running, and receive a well-structured result when the account exists. If something goes wrong, the interface should explain the problem instead of staying blank or showing a broken layout.

Feature Implementation Focus
Search input Create a controlled input where the value is stored in React state. The input should be easy to use, accessible, and clear about what the user needs to type.
GitHub API request Use fetch with async/await to request public user data from the GitHub API. Convert the response to JSON and store the returned profile data in component state.
useEffect logic Use useEffect to trigger searches when the debounced username changes. Keep the dependency array clean so requests run when needed and do not loop endlessly.
Loading spinner Show a loading indicator while the app waits for the API response. This prevents the interface from feeling frozen and makes the request process understandable.
Error states Display helpful messages for missing users, empty input, request failures, or API limitations. Avoid generic errors that do not explain what the user should do next.
Debounced search Add a short delay before sending the request so the app does not call the API on every single keystroke. This creates a smoother and more professional search experience.
Profile result card Render the user's avatar, name, login, bio, followers, following, public repositories, location, company, blog link, and GitHub profile URL in a clean layout.
Responsive interface Make the search area and profile card comfortable to use on desktop, tablet, and mobile screens. The app should remain readable even when profile data is long.

Implementation Guidance for React Beginners

Start by separating the app into small, understandable pieces. A practical structure can include SearchForm, UserCard, LoadingSpinner, ErrorMessage, and a main container component that owns the API state. This makes the code easier to read and helps you avoid placing all logic, markup, and styles inside one large component.

Keep the request lifecycle explicit. Before each request, clear the previous error, set loading to true, and decide whether the current input is valid enough to search. After the request finishes, store the user data if the response is successful or show an error if the username does not exist. Always turn loading off after the request, even when the request fails, so the spinner does not remain visible forever.

Be careful with useEffect. It is common for beginners to accidentally create repeated requests by placing the wrong values in the dependency array or updating state in a way that retriggers the same effect. The cleanest beginner approach is to store the raw input separately from the debounced value, then run the API request only when the debounced value changes and is not empty.

  • Store the search input value in state and keep it controlled by React
  • Use a debounced value to avoid sending a request after every keypress
  • Check response.ok before treating the API response as a successful result
  • Reset old profile data when a new search starts to avoid confusing results
  • Show different UI states for initial screen, loading, success, and error
  • Use optional rendering for profile fields that may be empty, such as location or company
  • Keep API logic readable instead of hiding too much behavior in one complex function
  • Test usernames that exist, usernames that do not exist, empty input, and very fast typing

Common Mistakes When Building a GitHub User Search App

1. Treating the GitHub API response as if every field is always available

The GitHub user API returns useful profile data, but not every field is guaranteed to contain a value. A user may have no bio, no company, no blog link, no location, or no Twitter username. A common mistake is rendering these values directly without checking for null or empty strings. The UI then shows broken links, empty labels, or text like null, which makes the app feel unfinished.

This project is a good opportunity to practice defensive data mapping. Instead of letting raw API data reach every component, normalize the response once in a service or helper function. The UI should receive a clean profile object where missing values are already converted into display-friendly fallbacks such as “Not available.”

Problematic approach:


          function UserProfile({ user }) {
            return (
              <section>
                <img src={user.avatar_url} />
                <h2>{user.name}</h2>
                <p>{user.bio}</p>
                <a href={user.blog}>{user.blog}</a>
                <p>{user.location}</p>
                <p>@{user.twitter_username}</p>
              </section>
            );
          }

This assumes all fields exist and are meaningful. If bio, blog, or twitter_username is missing, the profile card may look broken.

Better normalized model:


          type GitHubUserApiResponse = {
            login: string;
            avatar_url: string;
            name: string | null;
            bio: string | null;
            company: string | null;
            blog: string | null;
            location: string | null;
            twitter_username: string | null;
            public_repos: number;
            followers: number;
            following: number;
            created_at: string;
            html_url: string;
          };

          type GitHubUserProfile = {
            username: string;
            avatarUrl: string;
            displayName: string;
            bio: string;
            company: string;
            blog: string;
            location: string;
            twitterUsername: string;
            publicRepos: number;
            followers: number;
            following: number;
            joinedAt: string;
            profileUrl: string;
          };

Mapping API data safely:


          function normalizeGitHubUser(user: GitHubUserApiResponse): GitHubUserProfile {
            return {
              username: user.login,
              avatarUrl: user.avatar_url,
              displayName: user.name || user.login,
              bio: user.bio || "This profile has no bio yet.",
              company: user.company || "Not available",
              blog: user.blog || "Not available",
              location: user.location || "Not available",
              twitterUsername: user.twitter_username || "Not available",
              publicRepos: user.public_repos,
              followers: user.followers,
              following: user.following,
              joinedAt: user.created_at,
              profileUrl: user.html_url
            };
          }

Cleaner rendering:


          function UserProfile({ profile }: { profile: GitHubUserProfile }) {
            return (
              <section className="user-card" aria-labelledby="user-name">
                <img
                  src={profile.avatarUrl}
                  alt={`${profile.displayName} GitHub avatar`}
                />

                <h2 id="user-name">{profile.displayName}</h2>
                <p>@{profile.username}</p>
                <p>{profile.bio}</p>
              </section>
            );
          }

Pay attention to: Normalize the GitHub API response before rendering. The UI should not have to guess whether every profile field is available.

2. Handling “user not found” and API failures as one generic error

A GitHub User Search App has several possible failure states. The username may not exist, the user may submit an empty form, the network may fail, or the API may temporarily reject requests. If all of these cases become “Something went wrong,” the app gives poor feedback and users do not know what to do next.

A good search app should distinguish common cases. A 404 response should show “No user found.” A network failure should suggest retrying. A request-limit response should explain that the app is temporarily limited. Validation should prevent empty searches before the request is sent. These small details make the project look much more professional.

Problematic approach:


          async function searchUser(username: string) {
            try {
              const response = await fetch(`https://api.github.com/users/${username}`);
              const data = await response.json();

              setUser(data);
            } catch {
              setError("Something went wrong.");
            }
          }

This does not check response.ok. A failed response can still be parsed as JSON and may accidentally be passed into the profile UI as if it were a valid user.

Better typed result:


          type SearchResult =
            | { status: "success"; user: GitHubUserProfile }
            | { status: "not-found"; message: string }
            | { status: "rate-limited"; message: string }
            | { status: "network-error"; message: string };

          async function searchGitHubUser(username: string): Promise<SearchResult> {
            try {
              const response = await fetch(
                `https://api.github.com/users/${encodeURIComponent(username)}`
              );

              if (response.status === 404) {
                return {
                  status: "not-found",
                  message: "No GitHub user found with this username."
                };
              }

              if (response.status === 403) {
                return {
                  status: "rate-limited",
                  message: "GitHub API limit reached. Try again later."
                };
              }

              if (!response.ok) {
                return {
                  status: "network-error",
                  message: "GitHub profile could not be loaded."
                };
              }

              const data: GitHubUserApiResponse = await response.json();

              return {
                status: "success",
                user: normalizeGitHubUser(data)
              };
            } catch {
              return {
                status: "network-error",
                message: "Network error. Check your connection and try again."
              };
            }
          }

State-aware rendering:


          if (searchState.status === "not-found") {
            return <p role="alert">{searchState.message}</p>;
          }

          if (searchState.status === "rate-limited") {
            return <p role="alert">{searchState.message}</p>;
          }

          if (searchState.status === "network-error") {
            return <p role="alert">{searchState.message}</p>;
          }

          if (searchState.status === "success") {
            return <UserProfile profile={searchState.user} />;
          }

Pay attention to: Error states should be specific. Users should know whether the username does not exist, the request failed, or the API is temporarily unavailable.

3. Creating race conditions when users search quickly

Search apps often behave incorrectly when users submit several searches quickly. For example, the user searches for octocat, then immediately searches for another username. If the first request finishes after the second one, the UI may display the old profile even though the latest search was different. This is a race condition.

This problem is easy to miss during manual testing because network timing changes from request to request. A reliable implementation should either cancel the previous request or ignore outdated responses. This keeps the displayed profile connected to the most recent username submitted by the user.

Problematic approach:


          async function handleSearch(username: string) {
            setLoading(true);

            const result = await searchGitHubUser(username);

            setSearchState(result);
            setLoading(false);
          }

This does not know whether the response belongs to the latest search or an older request.

Better approach with request ID:


          const latestRequestId = useRef(0);

          async function handleSearch(username: string) {
            const trimmedUsername = username.trim();

            if (!trimmedUsername) {
              setSearchState({
                status: "validation-error",
                message: "Enter a GitHub username."
              });

              return;
            }

            const requestId = latestRequestId.current + 1;
            latestRequestId.current = requestId;

            setSearchState({ status: "loading" });

            const result = await searchGitHubUser(trimmedUsername);

            if (requestId !== latestRequestId.current) {
              return;
            }

            setSearchState(result);
          }

Alternative with AbortController:


          const abortControllerRef = useRef<AbortController | null>(null);

          async function searchWithAbort(username: string) {
            abortControllerRef.current?.abort();

            const controller = new AbortController();
            abortControllerRef.current = controller;

            const response = await fetch(
              `https://api.github.com/users/${encodeURIComponent(username)}`,
              {
                signal: controller.signal
              }
            );

            return response.json();
          }

Pay attention to: Search results should always match the latest submitted username. Handle fast repeated searches, slow networks, and outdated responses.

4. Building the search input without proper form behavior

A search bar should behave like a real form. Users should be able to type a username and press Enter. The input should have a label, even if the design hides it visually. The submit button should have clear text, and validation errors should be announced accessibly. A common mistake is using a plain div with an input and a click handler, which works only for mouse users and misses standard form behavior.

This project is small enough that accessibility details are very visible. A polished GitHub User Search App should not only fetch data correctly; it should also be easy to use with keyboard navigation, screen readers, and mobile keyboards.

Problematic approach:


          <div className="search">
            <input
              value={username}
              onChange={(event) => setUsername(event.target.value)}
              placeholder="Search GitHub username..."
            />

            <button onClick={() => handleSearch(username)}>
              Search
            </button>
          </div>

This may work visually, but it does not use native form submission. Pressing Enter may not behave consistently, and the input has no real label.

Better form structure:


          function SearchForm({ onSearch, errorMessage }) {
            const [username, setUsername] = useState("");

            function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
              event.preventDefault();

              onSearch(username);
            }

            return (
              <form className="search-form" onSubmit={handleSubmit}>
                <label className="sr-only" htmlFor="github-username">
                  GitHub username
                </label>

                <input
                  id="github-username"
                  type="search"
                  value={username}
                  onChange={(event) => setUsername(event.target.value)}
                  placeholder="Search GitHub username..."
                  autoComplete="off"
                  aria-describedby={errorMessage ? "search-error" : undefined}
                />

                <button type="submit">Search</button>

                {errorMessage && (
                  <p id="search-error" role="alert">
                    {errorMessage}
                  </p>
                )}
              </form>
            );
          }

Useful visually hidden class:


          .sr-only {
            position: absolute;
            width: 1px;
            height: 1px;
            padding: 0;
            margin: -1px;
            overflow: hidden;
            clip: rect(0, 0, 0, 0);
            white-space: nowrap;
            border: 0;
          }

Pay attention to: Use a real form. Support Enter key submission, labels, validation messages, and accessible error feedback.

5. Implementing dark mode without persistence or system preference

Many GitHub User Search App designs include light and dark themes. A common mistake is adding a button that toggles a CSS class but forgetting to persist the selected theme. The user switches to dark mode, reloads the page, and the app returns to light mode. Another mistake is ignoring the system preference completely, even though many users expect apps to respect their device theme by default.

A cleaner theme system should check saved user preference first. If there is no saved preference, it can fall back to the system color scheme. After the user changes the theme manually, save that choice in localStorage. The UI should also expose the current theme state in the toggle button label.

Problematic approach:


          const [darkMode, setDarkMode] = useState(false);

          function toggleTheme() {
            setDarkMode(!darkMode);
          }

          return (
            <button onClick={toggleTheme}>
              Theme
            </button>
          );

This does not persist the theme and does not explain what the button will do.

Better theme initialization:


          type ThemeMode = "light" | "dark";

          function getInitialTheme(): ThemeMode {
            const savedTheme = localStorage.getItem("theme");

            if (savedTheme === "light" || savedTheme === "dark") {
              return savedTheme;
            }

            const prefersDark = window.matchMedia(
              "(prefers-color-scheme: dark)"
            ).matches;

            return prefersDark ? "dark" : "light";
          }

          const [theme, setTheme] = useState<ThemeMode>(getInitialTheme);

Persisting and applying theme:


          useEffect(() => {
            document.documentElement.dataset.theme = theme;
            localStorage.setItem("theme", theme);
          }, [theme]);

          function toggleTheme() {
            setTheme((currentTheme) => {
              return currentTheme === "dark" ? "light" : "dark";
            });
          }

Accessible toggle:


          <button
            type="button"
            onClick={toggleTheme}
            aria-label={
              theme === "dark"
                ? "Switch to light theme"
                : "Switch to dark theme"
            }
          >
            {theme === "dark" ? "Light" : "Dark"}
          </button>

Pay attention to: Theme switching should be persistent and understandable. Respect saved preference first, system preference second, and make the toggle label clear.

6. Displaying profile links without validating or formatting them

GitHub profiles include fields such as website, company, location, and Twitter username. These fields are user-generated, which means they can be empty, unusual, or not formatted the way your UI expects. The blog field is especially tricky because some users enter a full URL, while others enter a domain without protocol. If you put that value directly into an anchor, the link may behave incorrectly.

A good implementation should format external links before rendering them. If the value is missing, show a disabled or muted “Not available” state. If the value exists but does not include http:// or https://, add https:// for the actual link while keeping the display text clean.

Problematic approach:


          <a href={user.blog} target="_blank">
            {user.blog}
          </a>

If user.blog is empty, this creates a broken link. If it is example.com, the browser may treat it as a relative path instead of an external URL.

Better link formatter:


          function formatExternalUrl(value: string): string | null {
            const trimmedValue = value.trim();

            if (!trimmedValue || trimmedValue === "Not available") {
              return null;
            }

            if (
              trimmedValue.startsWith("https://") ||
              trimmedValue.startsWith("http://")
            ) {
              return trimmedValue;
            }

            return `https://${trimmedValue}`;
          }

Safe profile link component:


          function ProfileLink({
            label,
            value
          }: {
            label: string;
            value: string;
          }) {
            const href = formatExternalUrl(value);

            if (!href) {
              return (
                <p className="profile-link profile-link--disabled">
                  {label}: Not available
                </p>
              );
            }

            return (
              <p className="profile-link">
                {label}:{" "}
                <a href={href} target="_blank" rel="noreferrer">
                  {value.replace(/^https?:\/\//, "")}
                </a>
              </p>
            );
          }

Pay attention to: User profile data is not always ready for display. Format links, handle missing values, and never create empty clickable elements.

After completing this project, you will have a practical beginner React application that demonstrates real API interaction, not just static component rendering. You will show that you can manage asynchronous data, organize loading and error states, use useEffect responsibly, and improve user experience with debounce. This project is a strong early portfolio piece because it reflects everyday frontend work: taking user input, requesting external data, handling uncertain outcomes, and presenting results in a clean, responsive interface.

Reference Implementations Worth Studying

TypeScript and theme-switching reference:
CodyJPerry - GitHub User Search App

This is a strong reference for a typed version of the project. It is built with React.js, TypeScript, and Sass, uses the GitHub API to fetch user data, and includes core features such as username search, profile display, followers, following, repositories, profile details, and dark/light mode.

Pay particular attention to:

  • How TypeScript can define the expected GitHub user profile shape.
  • How the profile card presents API data such as bio, location, repositories, followers, and following.
  • How dark and light mode are integrated into a small search application.
  • How Sass keeps visual structure organized without making component logic more complex.
  • What you would improve with stronger API error handling, request cancellation, and runtime data normalization.

Use this repository as the TypeScript-focused baseline. It is especially useful if the goal is to show that the app is not only visually close to the challenge, but also structured with typed data and maintainable styling.

Polished Vite, Axios, and Sass reference:
SouleymaneSy7 - GitHub User Search App

This implementation is useful as a polished Frontend Mentor-style version. It is built with Vite, React, TypeScript, Axios, Sass, CSS custom properties, Flexbox, CSS Grid, and a mobile-first workflow. The repository also includes screenshots for mobile, desktop, dark mode, error states, and Lighthouse results, which makes it helpful for studying both implementation and presentation quality.

When studying the code, focus on:

  • How Axios is used to organize GitHub API requests.
  • How mobile-first layout decisions affect the search form and profile card.
  • How Sass, CSS custom properties, Flexbox, and Grid work together in a small UI project.
  • How error states are presented visually instead of being treated as an afterthought.
  • How screenshots and Lighthouse results make the repository more useful as a portfolio reference.

Use this repository as the polished UI reference. It is especially helpful for learning how to make a small API project feel complete through layout, theme, error handling, and responsive design.

Simple React and API-fetch reference:
Sloth247 - GitHub User Search App FEM

This repository is useful as a simpler comparison point. It is a Frontend Mentor GitHub user search app built with React and Sass, bootstrapped with Create React App, and it fetches data from the GitHub API. The project keeps the setup familiar for learners who are still practicing React fundamentals before moving to Vite or TypeScript-heavy architecture.

While reviewing this project, examine:

  • How a basic React project can fetch GitHub user data and render a profile screen.
  • How Sass can be used for component styling in a smaller Create React App setup.
  • How the project structure differs from Vite and TypeScript examples.
  • What improvements would make the implementation more robust: typed API models, specific error states, and theme persistence.
  • How a simple baseline can help learners understand the core API flow before adding advanced refinements.

Use this implementation as the beginner-friendly API-fetch reference. It is not the most advanced option, but it helps clarify the basic flow: search username, fetch GitHub data, render profile, and style the result.

© 2026 ReadyToDev.Pro. All rights reserved.

Methodology

Privacy Policy

Terms & Conditions