Guides / React Search UI

React Search UI

Build a search component that queries the Lagoon API with debounced input, displays parsed filters, and renders results. Then add a tag autocomplete dropdown. Both components target React 18+.

Prerequisites

API helper

A shared helper for API calls. Centralizes the base URL and error handling.

// src/lagoon.js const BASE = "https://lagoon.io/api/v1"; export async function lagoonFetch(endpoint, params = {}) { const url = new URL(`${BASE}/${endpoint}`); for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== "") { url.searchParams.set(k, String(v)); } } const resp = await fetch(url); const data = await resp.json(); if (!data.ok) { throw new Error(data.error || `HTTP ${resp.status}`); } return data; }

Search component

A text input that sends the user's query to the nl (natural language) endpoint after a 400ms debounce. Displays parsed filters and results.

// src/LagoonSearch.jsx import { useState, useEffect } from "react"; import { lagoonFetch } from "./lagoon"; export default function LagoonSearch() { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [filters, setFilters] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!query.trim()) { setResults([]); setFilters(null); setError(null); return; } const controller = new AbortController(); const timer = setTimeout(async () => { setLoading(true); setError(null); try { const data = await lagoonFetch("search", { nl: query, limit: 20, }); if (!controller.signal.aborted) { setResults(data.results); setFilters(data.filters || null); } } catch (err) { if (!controller.signal.aborted) { setError(err.message); setResults([]); } } finally { if (!controller.signal.aborted) { setLoading(false); } } }, 400); return () => { clearTimeout(timer); controller.abort(); }; }, [query]); return ( <div> <input type="text" value={query} onChange={e => setQuery(e.target.value)} placeholder="Search art metadata..." /> {filters && ( <div className="parsed-filters"> Parsed: {Object.entries(filters) .filter(([k]) => k !== "sfw") .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(", ") : v}` ) .join(" | ")} </div> )} {loading && <div>Searching...</div>} {error && <div className="error">{error}</div>} <ul> {results.map(post => ( <li key={post.id}> <strong>{post.title || `Post #${post.id}`}</strong> <span> {" "}by {post.artist_handle} on {post.platform_name} </span> <div className="tags"> {post.tags .filter(t => !t.startsWith("rating:")) .slice(0, 8) .join(", ")} </div> {post.source_url && ( <a href={post.source_url} target="_blank" rel="noopener"> View source </a> )} </li> ))} </ul> </div> ); }

Tag autocomplete

A dropdown that queries the /tags endpoint as the user types. Returns matching tags sorted by post count. Minimum 2 characters to trigger a search.

// src/TagAutocomplete.jsx import { useState, useEffect, useRef } from "react"; import { lagoonFetch } from "./lagoon"; export default function TagAutocomplete({ onSelect }) { const [input, setInput] = useState(""); const [suggestions, setSuggestions] = useState([]); const [open, setOpen] = useState(false); const wrapperRef = useRef(null); useEffect(() => { if (input.length < 2) { setSuggestions([]); return; } const timer = setTimeout(async () => { try { const data = await lagoonFetch("tags", { q: input, limit: 8, }); setSuggestions(data.tags); setOpen(true); } catch { setSuggestions([]); } }, 300); return () => clearTimeout(timer); }, [input]); // Close dropdown on outside click useEffect(() => { function handleClick(e) { if (wrapperRef.current && !wrapperRef.current.contains(e.target)) { setOpen(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, []); function handleSelect(tag) { setInput(""); setSuggestions([]); setOpen(false); onSelect(tag.name); } return ( <div ref={wrapperRef} style={{ position: "relative" }}> <input value={input} onChange={e => setInput(e.target.value)} onFocus={() => suggestions.length && setOpen(true)} placeholder="Search tags..." /> {open && suggestions.length > 0 && ( <ul className="autocomplete-dropdown"> {suggestions.map(tag => ( <li key={tag.name} onClick={() => handleSelect(tag)}> <span className="tag-name">{tag.name}</span> <span className="tag-meta"> {tag.category} &middot; {tag.post_count.toLocaleString()} posts </span> </li> ))} </ul> )} </div> ); }

Putting it together

Combine the search and tag autocomplete in a parent component. When the user selects a tag, run a tag-based search.

// src/App.jsx import { useState } from "react"; import LagoonSearch from "./LagoonSearch"; import TagAutocomplete from "./TagAutocomplete"; import { lagoonFetch } from "./lagoon"; export default function App() { const [tagResults, setTagResults] = useState([]); async function handleTagSelect(tagName) { const data = await lagoonFetch("search", { tag: tagName, limit: 20, }); setTagResults(data.results); } return ( <div> <h2>Natural Language Search</h2> <LagoonSearch /> <h2>Browse by Tag</h2> <TagAutocomplete onSelect={handleTagSelect} /> <ul> {tagResults.map(post => ( <li key={post.id}> {post.title || `Post #${post.id}`} by {post.artist_handle} </li> ))} </ul> </div> ); }

Add an API key

To authenticate, add your key to the fetch headers. In a browser app, proxy through your backend to avoid exposing the key in client-side code.

// For server-rendered apps (Next.js API routes, Remix loaders): const headers = {}; if (process.env.LAGOON_API_KEY) { headers["X-API-Key"] = process.env.LAGOON_API_KEY; } const resp = await fetch(url, { headers });

Next steps