Anmelden API-Schlüssel holen
Tutorials

How to Build a Universal Search Bar for Teams and Players

Illustration of a search bar showing combined team and player results

Learn how to build a single debounced search bar that returns both teams and players as the user types, using Live Football API.

Auf dieser Seite

Most football apps eventually need one search box that finds both teams and players — think of the search bar at the top of any major sports app. This guide builds that experience: debounced input, combined results, and keyboard-friendly result navigation.

Live Football API has separate /team_search and /player_search endpoints — there's no single combined search endpoint. A universal search bar means firing both in parallel and merging the results client-side.

Step 1: Fetching Both Result Types in Parallel

async function universalSearch(query) {
  const [teamsRes, playersRes] = await Promise.all([
    fetch(`https://live-football-api.com/api/v1/team_search?api_key=YOUR_KEY&q=${encodeURIComponent(query)}`),
    fetch(`https://live-football-api.com/api/v1/player_search?api_key=YOUR_KEY&q=${encodeURIComponent(query)}`)
  ]);

  const [teams, players] = await Promise.all([teamsRes.json(), playersRes.json()]);

  return {
    teams: teams.data.teams.map(t => ({ ...t, type: 'team' })),
    players: players.data.players.map(p => ({ ...p, type: 'player' }))
  };
}

Tagging each result with a type field up front makes rendering and routing much simpler downstream.

Step 2: Debouncing Input

Firing two API calls on every keystroke wastes credits fast. Debounce so a call only fires once the user pauses typing:

function debounce(fn, delay = 300) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

const debouncedSearch = debounce(async (query) => {
  if (query.length < 2) {
    clearResults();
    return;
  }
  const results = await universalSearch(query);
  renderResults(results);
}, 300);

searchInput.addEventListener('input', (e) => debouncedSearch(e.target.value));

Step 3: Rendering Grouped Results

Group teams and players into separate labeled sections rather than one flat list — this is the pattern most users already expect from search UIs:

function renderResults({ teams, players }) {
  const html = `
    ${teams.length > 0 ? `
      <div class="search-group">
        <h4>Teams</h4>
        ${teams.map(t => `
          <div class="result-item" data-type="team" data-id="${t.id}">
            <img src="${t.logo}" alt="" />
            <span>${t.name}</span>
          </div>
        `).join('')}
      </div>
    ` : ''}

    ${players.length > 0 ? `
      <div class="search-group">
        <h4>Players</h4>
        ${players.map(p => `
          <div class="result-item" data-type="player" data-id="${p.id}">
            <img src="${p.photo}" alt="" />
            <span>${p.name}</span>
          </div>
        `).join('')}
      </div>
    ` : ''}
  `;

  resultsContainer.innerHTML = html || '<p class="no-results">No results found</p>';
}

Step 4: Handling Result Selection

Route based on the data-type attribute set during rendering — teams and players lead to different detail pages:

resultsContainer.addEventListener('click', (e) => {
  const item = e.target.closest('.result-item');
  if (!item) return;

  const { type, id } = item.dataset;
  if (type === 'team') {
    navigateTo(`/team/${id}`);
  } else {
    navigateTo(`/player/${id}`);
  }
});

Step 5: Adding Keyboard Navigation

A search bar isn't complete without arrow-key navigation and Enter-to-select — a small addition that makes the feature feel production-ready:

let activeIndex = -1;

searchInput.addEventListener('keydown', (e) => {
  const items = resultsContainer.querySelectorAll('.result-item');
  if (!items.length) return;

  if (e.key === 'ArrowDown') {
    activeIndex = Math.min(activeIndex + 1, items.length - 1);
  } else if (e.key === 'ArrowUp') {
    activeIndex = Math.max(activeIndex - 1, 0);
  } else if (e.key === 'Enter' && activeIndex >= 0) {
    items[activeIndex].click();
    return;
  } else {
    return;
  }

  items.forEach((el, i) => el.classList.toggle('active', i === activeIndex));
});
  • Debounce aggressively — 300-400ms is a reasonable default; shorter feels snappier but costs more credits
  • Set a minimum query length — skip searching on 1-character inputs, which return too many results to be useful anyway
  • Cache recent queries client-side for a session — if a user retypes something they searched moments ago, serve from memory instead of re-calling the API
const searchCache = new Map();

async function cachedSearch(query) {
  if (searchCache.has(query)) return searchCache.get(query);
  const results = await universalSearch(query);
  searchCache.set(query, results);
  return results;
}

Frequently Asked Questions

Is there a single endpoint that searches both teams and players at once?

No, /team_search and /player_search are separate endpoints — combining them into one UI requires calling both in parallel as shown above.

How many credits does one universal search use?

2 credits per completed search (1 for team_search, 1 for player_search), before any debouncing or caching optimizations.

Yes, add a third parallel call to /league_search (available on some sibling APIs) or filter locally from a cached /leagues response if you only need league name matching.

250-400ms is typical — short enough to feel responsive, long enough to avoid firing a request on every single keystroke.

Artikel teilen:
← Zurück zum Blog