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
- A React 18+ project (Create React App, Vite, Next.js, or similar).
- No additional dependencies. All API calls use native
fetch.
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} · {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
- Add pagination with a "Load more" button using
offset.
- Use the /api/v1/post endpoint to show full details when a user clicks a result.
- Style the components to match your app's design.