Skip to content

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 install

This 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 graphql

Add 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 dev

What's happening:

  • urql's useQuery hook fetches data and tracks loading/error states for you
  • The cacheExchange caches 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: !location tells 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:

  • useQuery handles fetching, loading states, and errors
  • pause delays queries until dependencies are ready
  • Built-in caching makes repeat queries instant

GraphQL:

  • Variables make queries reusable
  • Cursor pagination with after and endCursor
  • 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