react

Fetch Data In React As User Types Or Clicks

10 min read
Fetch Data In React As User Types Or Clicks
Pokemon Search (Click)

Open this example in our interactive code editor to experiment with it.

Try the Editor

Let’s dive into a bit more complex examples of real-world scenarios. Fetching data from an API as a user types or clicks is a common pattern in modern web applications. We’ll build two practical examples: a search-as-you-type feature and a fetch-on-click pattern.

If you’re new to React, you might want to check out some fundamentals first. This post assumes you’re familiar with React hooks like useState and useEffect.

Fetch data as we type

The search-as-you-type pattern is everywhere - from Google’s search bar to GitHub’s repository search. Let’s build one step by step.

Prepare data fetching

First, let’s set up the basic data fetching logic. We’ll use the Pokemon API as our data source since it’s free and doesn’t require authentication:

import React, { useState, useEffect } from "react";

const App = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const fetchPokemon = async (name) => {
    try {
      setLoading(true);
      setError(null);

      const response = await fetch(
        `https://pokeapi.co/api/v2/pokemon/${name.toLowerCase()}`
      );

      if (!response.ok) {
        throw new Error("Pokemon not found");
      }

      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err.message);
      setData(null);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <h1>Pokemon Search</h1>
      {loading && <p>Loading...</p>}
      {error && <p style={{ color: "red" }}>{error}</p>}
      {data && (
        <div>
          <h2>{data.name}</h2>
          <img src={data.sprites.front_default} alt={data.name} />
        </div>
      )}
    </div>
  );
};

export default App;

This gives us the foundation - state management for data, loading, and error states, plus an async function to fetch Pokemon data.

Adding search field

Now let’s add a search input so the user can type a Pokemon name:

import React, { useState, useEffect } from "react";

const App = () => {
  const [search, setSearch] = useState("");
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const fetchPokemon = async (name) => {
    if (!name.trim()) return;

    try {
      setLoading(true);
      setError(null);

      const response = await fetch(
        `https://pokeapi.co/api/v2/pokemon/${name.toLowerCase()}`
      );

      if (!response.ok) {
        throw new Error("Pokemon not found");
      }

      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err.message);
      setData(null);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchPokemon(search);
  }, [search]);

  return (
    <div>
      <h1>Pokemon Search</h1>
      <input
        type="text"
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search for a Pokemon..."
      />
      {loading && <p>Loading...</p>}
      {error && <p style={{ color: "red" }}>{error}</p>}
      {data && (
        <div>
          <h2>{data.name}</h2>
          <img src={data.sprites.front_default} alt={data.name} />
        </div>
      )}
    </div>
  );
};

export default App;

We added a search state, an input field, and a useEffect that triggers the fetch whenever the search value changes. But there’s a problem…

Adding missing pieces

The current implementation fires an API call on every single keystroke. If the user types “pikachu”, that’s 7 API calls! We need debouncing - waiting until the user stops typing before making the request.

import React, { useState, useEffect, useRef } from "react";

const useDebouncedEffect = (callback, delay, deps) => {
  const firstRender = useRef(true);

  useEffect(() => {
    if (firstRender.current) {
      firstRender.current = false;
      return;
    }

    const handler = setTimeout(() => {
      callback();
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [...deps, delay]);
};

const App = () => {
  const [search, setSearch] = useState("");
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const fetchPokemon = async (name) => {
    if (!name.trim()) return;

    try {
      setLoading(true);
      setError(null);

      const response = await fetch(
        `https://pokeapi.co/api/v2/pokemon/${name.toLowerCase()}`
      );

      if (!response.ok) {
        throw new Error("Pokemon not found");
      }

      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err.message);
      setData(null);
    } finally {
      setLoading(false);
    }
  };

  useDebouncedEffect(
    () => {
      fetchPokemon(search);
    },
    500,
    [search]
  );

  return (
    <div>
      <h1>Pokemon Search</h1>
      <input
        type="text"
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search for a Pokemon..."
      />
      {loading && <p>Loading...</p>}
      {error && <p style={{ color: "red" }}>{error}</p>}
      {data && (
        <div>
          <h2>{data.name}</h2>
          <img src={data.sprites.front_default} alt={data.name} />
        </div>
      )}
    </div>
  );
};

export default App;

The useDebouncedEffect custom hook waits for the specified delay (500ms) after the last change before executing the callback. This means the API is only called once the user stops typing.

Key features of this hook:

  • It skips the first render to avoid an unnecessary API call on mount
  • It clears the previous timeout on each new keystroke
  • It uses a cleanup function to prevent memory leaks

Are we hitting the API too much?

Even with debouncing, there are additional optimizations you can consider:

  • Caching - Store previous results so searching for the same term twice doesn’t make a second API call
  • Minimum query length - Don’t search until the user has typed at least 3 characters
  • AbortController - Cancel pending requests when a new search starts

Here’s the final polished version with these improvements:

import React, { useState, useEffect, useRef, useCallback } from "react";

const useDebouncedEffect = (callback, delay, deps) => {
  const firstRender = useRef(true);

  useEffect(() => {
    if (firstRender.current) {
      firstRender.current = false;
      return;
    }

    const handler = setTimeout(() => {
      callback();
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [...deps, delay]);
};

const App = () => {
  const [search, setSearch] = useState("");
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const cache = useRef({});
  const abortControllerRef = useRef(null);

  const fetchPokemon = useCallback(async (name) => {
    if (!name.trim() || name.length < 3) return;

    // Check cache first
    if (cache.current[name]) {
      setData(cache.current[name]);
      return;
    }

    // Cancel previous request
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();

    try {
      setLoading(true);
      setError(null);

      const response = await fetch(
        `https://pokeapi.co/api/v2/pokemon/${name.toLowerCase()}`,
        { signal: abortControllerRef.current.signal }
      );

      if (!response.ok) {
        throw new Error("Pokemon not found");
      }

      const result = await response.json();
      cache.current[name] = result;
      setData(result);
    } catch (err) {
      if (err.name !== "AbortError") {
        setError(err.message);
        setData(null);
      }
    } finally {
      setLoading(false);
    }
  }, []);

  useDebouncedEffect(
    () => {
      fetchPokemon(search);
    },
    500,
    [search]
  );

  return (
    <div>
      <h1>Pokemon Search</h1>
      <input
        type="text"
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search for a Pokemon (min 3 chars)..."
      />
      {loading && <p>Loading...</p>}
      {error && <p style={{ color: "red" }}>{error}</p>}
      {data && (
        <div>
          <h2>{data.name}</h2>
          <img src={data.sprites.front_default} alt={data.name} />
        </div>
      )}
    </div>
  );
};

export default App;

Fetch data on click

Sometimes you don’t want to fetch data as the user types. Instead, you want the user to explicitly trigger the fetch by clicking a button. This pattern is simpler to implement:

import React, { useState } from "react";

const App = () => {
  const [search, setSearch] = useState("");
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const handleSearch = async () => {
    if (!search.trim()) return;

    try {
      setLoading(true);
      setError(null);

      const response = await fetch(
        `https://pokeapi.co/api/v2/pokemon/${search.toLowerCase()}`
      );

      if (!response.ok) {
        throw new Error("Pokemon not found");
      }

      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err.message);
      setData(null);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <h1>Pokemon Search</h1>
      <div>
        <input
          type="text"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          placeholder="Enter a Pokemon name..."
          onKeyPress={(e) => e.key === "Enter" && handleSearch()}
        />
        <button onClick={handleSearch} disabled={loading}>
          {loading ? "Searching..." : "Search"}
        </button>
      </div>
      {error && <p style={{ color: "red" }}>{error}</p>}
      {data && (
        <div>
          <h2>{data.name}</h2>
          <img src={data.sprites.front_default} alt={data.name} />
          <p>Height: {data.height}</p>
          <p>Weight: {data.weight}</p>
          <p>
            Types: {data.types.map((t) => t.type.name).join(", ")}
          </p>
        </div>
      )}
    </div>
  );
};

export default App;

The click-based approach is simpler because:

  • No debouncing needed
  • No useEffect dependency management
  • The user has full control over when requests are made
  • It works well for expensive API calls or actions with side effects

We also added onKeyPress support so the user can press Enter to search, which improves the user experience.

Summary

We covered two common patterns for fetching data in React:

Search as you type:

  • Uses useEffect to react to input changes
  • Requires debouncing to avoid excessive API calls
  • Can be enhanced with caching and request cancellation
  • Best for: autocomplete, live search, filtering

Fetch on click:

  • Uses an event handler triggered by user action
  • Simpler to implement, no debouncing needed
  • Best for: form submissions, explicit searches, expensive operations

Both patterns share the same fundamentals: managing loading, error, and data states. The choice between them depends on your use case and the user experience you want to provide.

Remember to always handle loading and error states - your users will thank you for it.

Ready to level up your coding skills?

Build real projects and grow your portfolio with BigDevSoon.

Start 7-Day Free Trial
Adrian Bigaj
Adrian Bigaj

Creator of BigDevSoon

Full-stack developer and educator passionate about helping developers build real-world skills through hands-on projects. Creator of BigDevSoon, a vibe coding platform with 21 projects, 100 coding challenges, 40+ practice problems, and Merlin AI.