Appearance
Build a Product Finder
This tutorial walks you through building a product finder app with React and urql, a lightweight GraphQL client. By the end, you'll have a working app that searches LCBO products and checks store inventory.
You'll build:
- A React app that displays LCBO products
- Product search with filters
- Store locator with inventory checking
You'll need:
- Basic React knowledge
- Node.js installed
Time: About 30 minutes
Setup
Create a new React project with Vite:
bash
npm create vite@latest lcbo-finder -- --template react
cd lcbo-finder
npm installThis creates a new React project using Vite's official React template. The npm install command installs the base dependencies.
Now install the GraphQL client library:
bash
npm install urql graphqlAdd Tailwind via the CDN. Open index.html and add the script tag in the <head>:
html
<script src="https://cdn.tailwindcss.com"></script>This is the quickest way to get Tailwind running. For production apps, install it properly via npm. See the Tailwind docs for full setup instructions.
INFO
This tutorial assumes you're using the standard Vite React template created by npm create vite@latest. If you're using a different template or build setup, your CSS file structure might differ. Adapt the instructions accordingly.
Replace src/index.css with just the body styles:
css
body {
background: #0a0a0a;
color: #fafafa;
}Replace src/main.jsx:
jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Client, Provider, cacheExchange, fetchExchange } from 'urql'
import App from './App'
import './index.css'
const client = new Client({
url: 'https://api.lcbo.dev/graphql',
exchanges: [cacheExchange, fetchExchange],
})
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Provider value={client}>
<App />
</Provider>
</React.StrictMode>
)Part 1: Display Products
Replace src/App.jsx with a component that fetches and displays products:
jsx
import { useQuery } from 'urql'
import { useState } from 'react'
const PRODUCTS_QUERY = `
query Products($first: Int!, $after: String, $filters: ProductFilterInput) {
products(
pagination: { first: $first, after: $after }
filters: $filters
sortBy: PRICE
sortDirection: ASC
) {
edges {
node {
sku
name
priceInCents
producerName
primaryCategory
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
`
function ProductCard({ product }) {
const price = (product.priceInCents / 100).toFixed(2)
return (
<div className="bg-neutral-900 rounded-xl p-6 border border-neutral-700 hover:border-red-400 transition-colors">
<h3 className="text-lg font-medium mb-2">{product.name}</h3>
<p className="text-neutral-400 text-sm mb-4">{product.producerName}</p>
<p className="text-2xl font-bold text-amber-400 mb-2">${price}</p>
<span className="inline-block bg-neutral-700 text-neutral-300 px-3 py-1 rounded-full text-xs">
{product.primaryCategory}
</span>
</div>
)
}
function ProductList({ filters }) {
const [result] = useQuery({
query: PRODUCTS_QUERY,
variables: { first: 12, filters },
})
const { data, fetching, error } = result
if (fetching) return <div className="text-center py-12 text-neutral-400">Loading products...</div>
if (error) return <div className="bg-red-950 border border-red-400 p-4 rounded-lg text-red-400">Error: {error.message}</div>
const products = data.products.edges
return (
<>
<p className="text-neutral-400 mb-8">Found {data.products.totalCount} products</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map(({ node }) => (
<ProductCard key={node.sku} product={node} />
))}
</div>
</>
)
}
export default function App() {
const [filters, setFilters] = useState({})
const [category, setCategory] = useState('')
const [maxPrice, setMaxPrice] = useState('')
const handleSearch = () => {
setFilters({
...(category && { category }),
...(maxPrice && { maxPrice: parseFloat(maxPrice) * 100 }),
})
}
return (
<div className="max-w-6xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-2 bg-gradient-to-r from-red-400 to-amber-400 bg-clip-text text-transparent">
LCBO Product Finder
</h1>
<div className="flex gap-4 flex-wrap mb-8">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="px-4 py-3 rounded-lg border border-neutral-700 bg-neutral-900 text-white"
>
<option value="">All Categories</option>
<option value="wine">Wine</option>
<option value="beer">Beer</option>
<option value="spirits">Spirits</option>
<option value="cider">Cider</option>
</select>
<input
type="number"
placeholder="Max price ($)"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className="px-4 py-3 rounded-lg border border-neutral-700 bg-neutral-900 text-white"
/>
<button
onClick={handleSearch}
className="px-6 py-3 rounded-lg bg-gradient-to-r from-red-400 to-amber-400 text-black font-semibold hover:opacity-90 transition-opacity"
>
Search
</button>
</div>
<ProductList filters={filters} />
</div>
)
}Run your app:
bash
npm run devWhat's happening:
- urql's
useQueryhook fetches data and tracks loading/error states for you - The
cacheExchangecaches queries, so repeat requests come back instantly - React re-renders when data arrives. No manual DOM updates needed.
Part 2: Check Store Inventory
Next, add inventory checking so users can see which stores have a product in stock. Update src/App.jsx:
jsx
import { useQuery } from 'urql'
import { useState, useEffect } from 'react'
const PRODUCTS_QUERY = `
query Products($first: Int!, $after: String, $filters: ProductFilterInput) {
products(
pagination: { first: $first, after: $after }
filters: $filters
sortBy: PRICE
sortDirection: ASC
) {
edges {
node {
sku
name
priceInCents
producerName
primaryCategory
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
`
const INVENTORY_QUERY = `
query Inventory($sku: String!, $lat: Float!, $lng: Float!) {
product(sku: $sku) {
name
inventories(
filters: { latitude: $lat, longitude: $lng, radiusKm: 20, minQuantity: 1 }
pagination: { first: 5 }
) {
edges {
node {
quantity
distanceKm
store {
name
address
city
}
}
}
}
}
}
`
function InventoryModal({ product, onClose }) {
const [location, setLocation] = useState(null)
const [locError, setLocError] = useState(null)
useEffect(() => {
navigator.geolocation.getCurrentPosition(
(pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => setLocError('Unable to get location')
)
}, [])
const [result] = useQuery({
query: INVENTORY_QUERY,
variables: { sku: product.sku, lat: location?.lat, lng: location?.lng },
pause: !location,
})
const { data, fetching, error } = result
const inventories = data?.product?.inventories?.edges || []
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-neutral-900 rounded-xl p-8 max-w-md w-full max-h-[80vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<button className="float-right bg-transparent border-none text-neutral-400 text-2xl cursor-pointer" onClick={onClose}>×</button>
<h2 className="mb-6 text-xl font-bold">{product.name}</h2>
{locError && <p className="bg-red-950 border border-red-400 p-4 rounded-lg text-red-400">{locError}</p>}
{!location && !locError && <p className="text-center py-8 text-neutral-400">Getting location...</p>}
{fetching && <p className="text-center py-8 text-neutral-400">Checking inventory...</p>}
{error && <p className="bg-red-950 border border-red-400 p-4 rounded-lg text-red-400">{error.message}</p>}
{data && inventories.length === 0 && (
<p>No stores within 20km have this in stock.</p>
)}
{inventories.map(({ node: inv }, i) => (
<div key={i} className="p-4 border border-neutral-700 rounded-lg mb-4">
<h4 className="font-medium mb-1">{inv.store.name}</h4>
<p className="text-neutral-400 text-sm">{inv.store.address}, {inv.store.city}</p>
<div className="flex justify-between items-center mt-2">
<span className="text-xl font-bold text-green-400">{inv.quantity} in stock</span>
<span className="text-amber-400">📍 {inv.distanceKm.toFixed(1)} km</span>
</div>
</div>
))}
</div>
</div>
)
}
function ProductCard({ product, onCheckInventory }) {
const price = (product.priceInCents / 100).toFixed(2)
return (
<div className="bg-neutral-900 rounded-xl p-6 border border-neutral-700 hover:border-red-400 transition-colors">
<h3 className="text-lg font-medium mb-2">{product.name}</h3>
<p className="text-neutral-400 text-sm mb-4">{product.producerName}</p>
<p className="text-2xl font-bold text-amber-400 mb-2">${price}</p>
<span className="inline-block bg-neutral-700 text-neutral-300 px-3 py-1 rounded-full text-xs">
{product.primaryCategory}
</span>
<button
onClick={() => onCheckInventory(product)}
className="w-full mt-4 py-3 bg-transparent border border-red-400 text-red-400 rounded-lg cursor-pointer text-sm hover:bg-red-400 hover:text-black transition-colors"
>
Check Availability
</button>
</div>
)
}
function ProductList({ filters }) {
const [selectedProduct, setSelectedProduct] = useState(null)
const [result] = useQuery({
query: PRODUCTS_QUERY,
variables: { first: 12, filters },
})
const { data, fetching, error } = result
if (fetching) return <div className="text-center py-12 text-neutral-400">Loading products...</div>
if (error) return <div className="bg-red-950 border border-red-400 p-4 rounded-lg text-red-400">Error: {error.message}</div>
const products = data.products.edges
return (
<>
<p className="text-neutral-400 mb-8">Found {data.products.totalCount} products</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map(({ node }) => (
<ProductCard
key={node.sku}
product={node}
onCheckInventory={setSelectedProduct}
/>
))}
</div>
{selectedProduct && (
<InventoryModal
product={selectedProduct}
onClose={() => setSelectedProduct(null)}
/>
)}
</>
)
}
export default function App() {
const [filters, setFilters] = useState({})
const [category, setCategory] = useState('')
const [maxPrice, setMaxPrice] = useState('')
const handleSearch = () => {
setFilters({
...(category && { category }),
...(maxPrice && { maxPrice: parseFloat(maxPrice) * 100 }),
})
}
return (
<div className="max-w-6xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-2 bg-gradient-to-r from-red-400 to-amber-400 bg-clip-text text-transparent">
LCBO Product Finder
</h1>
<div className="flex gap-4 flex-wrap mb-8">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="px-4 py-3 rounded-lg border border-neutral-700 bg-neutral-900 text-white"
>
<option value="">All Categories</option>
<option value="wine">Wine</option>
<option value="beer">Beer</option>
<option value="spirits">Spirits</option>
<option value="cider">Cider</option>
</select>
<input
type="number"
placeholder="Max price ($)"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className="px-4 py-3 rounded-lg border border-neutral-700 bg-neutral-900 text-white"
/>
<button
onClick={handleSearch}
className="px-6 py-3 rounded-lg bg-gradient-to-r from-red-400 to-amber-400 text-black font-semibold hover:opacity-90 transition-opacity"
>
Search
</button>
</div>
<ProductList filters={filters} />
</div>
)
}What's new:
pause: !locationtells urql to wait until we have the user's location- The inventory query uses geolocation to find nearby stores
- Clicking a product opens a modal showing stock levels
Part 3: Add Pagination
The app currently shows 12 products. Add a "Load More" button to fetch additional pages. Here's the final src/App.jsx:
jsx
import { useQuery } from 'urql'
import { useState, useEffect } from 'react'
const PRODUCTS_QUERY = `
query Products($first: Int!, $after: String, $filters: ProductFilterInput) {
products(
pagination: { first: $first, after: $after }
filters: $filters
sortBy: PRICE
sortDirection: ASC
) {
edges {
node {
sku
name
priceInCents
producerName
primaryCategory
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
`
const INVENTORY_QUERY = `
query Inventory($sku: String!, $lat: Float!, $lng: Float!) {
product(sku: $sku) {
name
inventories(
filters: { latitude: $lat, longitude: $lng, radiusKm: 20, minQuantity: 1 }
pagination: { first: 5 }
) {
edges {
node {
quantity
distanceKm
store {
name
address
city
}
}
}
}
}
}
`
function InventoryModal({ product, onClose }) {
const [location, setLocation] = useState(null)
const [locError, setLocError] = useState(null)
useEffect(() => {
navigator.geolocation.getCurrentPosition(
(pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => setLocError('Unable to get location. Please enable location services.')
)
}, [])
const [result] = useQuery({
query: INVENTORY_QUERY,
variables: { sku: product.sku, lat: location?.lat, lng: location?.lng },
pause: !location,
})
const { data, fetching, error } = result
const inventories = data?.product?.inventories?.edges || []
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-neutral-900 rounded-xl p-8 max-w-md w-full max-h-[80vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<button className="float-right bg-transparent border-none text-neutral-400 text-2xl cursor-pointer" onClick={onClose}>×</button>
<h2 className="mb-6 text-xl font-bold">{product.name}</h2>
{locError && <p className="bg-red-950 border border-red-400 p-4 rounded-lg text-red-400">{locError}</p>}
{!location && !locError && <p className="text-center py-8 text-neutral-400">Getting your location...</p>}
{fetching && <p className="text-center py-8 text-neutral-400">Checking inventory...</p>}
{error && <p className="bg-red-950 border border-red-400 p-4 rounded-lg text-red-400">{error.message}</p>}
{data && inventories.length === 0 && (
<p>No stores within 20km have this in stock.</p>
)}
{inventories.map(({ node: inv }, i) => (
<div key={i} className="p-4 border border-neutral-700 rounded-lg mb-4">
<h4 className="font-medium mb-1">{inv.store.name}</h4>
<p className="text-neutral-400 text-sm">{inv.store.address}, {inv.store.city}</p>
<div className="flex justify-between items-center mt-2">
<span className="text-xl font-bold text-green-400">{inv.quantity} in stock</span>
<span className="text-amber-400">📍 {inv.distanceKm.toFixed(1)} km</span>
</div>
</div>
))}
</div>
</div>
)
}
function ProductCard({ product, onCheckInventory }) {
const price = (product.priceInCents / 100).toFixed(2)
return (
<div className="bg-neutral-900 rounded-xl p-6 border border-neutral-700 hover:border-red-400 transition-colors">
<h3 className="text-lg font-medium mb-2">{product.name}</h3>
<p className="text-neutral-400 text-sm mb-4">{product.producerName}</p>
<p className="text-2xl font-bold text-amber-400 mb-2">${price}</p>
<span className="inline-block bg-neutral-700 text-neutral-300 px-3 py-1 rounded-full text-xs">
{product.primaryCategory}
</span>
<button
onClick={() => onCheckInventory(product)}
className="w-full mt-4 py-3 bg-transparent border border-red-400 text-red-400 rounded-lg cursor-pointer text-sm hover:bg-red-400 hover:text-black transition-colors"
>
Check Availability
</button>
</div>
)
}
function ProductList({ filters }) {
const [selectedProduct, setSelectedProduct] = useState(null)
const [allProducts, setAllProducts] = useState([])
const [cursor, setCursor] = useState(null)
const [result] = useQuery({
query: PRODUCTS_QUERY,
variables: { first: 12, after: cursor, filters },
})
const { data, fetching, error } = result
// Reset when filters change
useEffect(() => {
setAllProducts([])
setCursor(null)
}, [filters])
// Accumulate products
useEffect(() => {
if (data?.products?.edges) {
setAllProducts((prev) => {
const newSkus = new Set(data.products.edges.map((e) => e.node.sku))
const filtered = cursor ? prev.filter((e) => !newSkus.has(e.node.sku)) : []
return [...filtered, ...data.products.edges]
})
}
}, [data, cursor])
if (fetching && allProducts.length === 0) {
return <div className="text-center py-12 text-neutral-400">Loading products...</div>
}
if (error) return <div className="bg-red-950 border border-red-400 p-4 rounded-lg text-red-400">Error: {error.message}</div>
const hasMore = data?.products?.pageInfo?.hasNextPage
return (
<>
<p className="text-neutral-400 mb-8">Found {data?.products?.totalCount || 0} products</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{allProducts.map(({ node }) => (
<ProductCard
key={node.sku}
product={node}
onCheckInventory={setSelectedProduct}
/>
))}
</div>
{hasMore && (
<button
onClick={() => setCursor(data.products.pageInfo.endCursor)}
disabled={fetching}
className="block mx-auto my-8 px-8 py-4 bg-neutral-700 text-white rounded-lg cursor-pointer hover:bg-neutral-600 disabled:opacity-50"
>
{fetching ? 'Loading...' : 'Load More'}
</button>
)}
{selectedProduct && (
<InventoryModal
product={selectedProduct}
onClose={() => setSelectedProduct(null)}
/>
)}
</>
)
}
export default function App() {
const [filters, setFilters] = useState({})
const [category, setCategory] = useState('')
const [maxPrice, setMaxPrice] = useState('')
const handleSearch = () => {
setFilters({
...(category && { category }),
...(maxPrice && { maxPrice: parseFloat(maxPrice) * 100 }),
})
}
return (
<div className="max-w-6xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-2 bg-gradient-to-r from-red-400 to-amber-400 bg-clip-text text-transparent">
LCBO Product Finder
</h1>
<div className="flex gap-4 flex-wrap mb-8">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="px-4 py-3 rounded-lg border border-neutral-700 bg-neutral-900 text-white"
>
<option value="">All Categories</option>
<option value="wine">Wine</option>
<option value="beer">Beer</option>
<option value="spirits">Spirits</option>
<option value="cider">Cider</option>
</select>
<input
type="number"
placeholder="Max price ($)"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className="px-4 py-3 rounded-lg border border-neutral-700 bg-neutral-900 text-white"
/>
<button
onClick={handleSearch}
className="px-6 py-3 rounded-lg bg-gradient-to-r from-red-400 to-amber-400 text-black font-semibold hover:opacity-90 transition-opacity"
>
Search
</button>
</div>
<ProductList filters={filters} />
</div>
)
}Recap
urql:
useQueryhandles fetching, loading states, and errorspausedelays queries until dependencies are ready- Built-in caching makes repeat queries instant
GraphQL:
- Variables make queries reusable
- Cursor pagination with
afterandendCursor - Nested queries fetch related data in one request
React:
- Lifting state up for filters
- Conditional rendering for loading/error states
- Effect hooks for side effects like geolocation
Next Steps
- Product Discovery for advanced filtering
- Store Location for more store patterns
- GraphQL API for the full schema