Collection Page Components Intermediate 12 min read

Collection Page Architecture and Data Flow

Design the architecture for React-powered collection pages. Learn how to pass product data from Liquid, manage filter state, and structure components for performance.

Collection pages are the backbone of any e-commerce store. They display products, handle filtering, sorting, and pagination. In this lesson, we’ll design the architecture for React-powered collection pages that work seamlessly with Liquid data.

Collection Page Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│ COLLECTION PAGE │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Collection Banner (Liquid) │ │
│ │ Title, Description, Image │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Active Filters (React) Sort Dropdown (React) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────────────────────────────────┐ │
│ │ │ │ │ │
│ │ Filter Sidebar │ │ Product Grid (React) │ │
│ │ (React) │ │ │ │
│ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ □ Size │ │ │ Card │ │ Card │ │ Card │ │ Card │ │ │
│ │ □ Color │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
│ │ □ Price │ │ │ │
│ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ │ │ │ Card │ │ Card │ │ Card │ │ Card │ │ │
│ │ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
│ └─────────────────┘ └─────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Pagination (React) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘

Data Flow Strategy

Collection pages have two sources of data:

  1. Initial data from Liquid: Products, filters, and collection info
  2. Dynamic updates via API: When filters or page changes
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Liquid │ │ React │ │ Shopify API │
│ (Initial Data) │────►│ (State Mgmt) │◄───►│ (Filter/Page) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐
│ URL State │
│ (?filter=...) │
└─────────────────┘

Component Structure

src/components/collection/
├── CollectionPage/
│ ├── CollectionPage.tsx # Main orchestrator
│ ├── CollectionPage.module.css
│ └── index.ts
├── ProductGrid/
│ ├── ProductGrid.tsx # Grid layout
│ ├── ProductGrid.module.css
│ └── index.ts
├── ProductCard/
│ ├── ProductCard.tsx # Individual product
│ ├── ProductCard.module.css
│ └── index.ts
├── FilterSidebar/
│ ├── FilterSidebar.tsx # Filter controls
│ ├── FilterGroup.tsx # Single filter group
│ ├── FilterSidebar.module.css
│ └── index.ts
├── ActiveFilters/
│ ├── ActiveFilters.tsx # Applied filter chips
│ └── index.ts
├── SortDropdown/
│ ├── SortDropdown.tsx # Sort selector
│ └── index.ts
├── Pagination/
│ ├── Pagination.tsx # Page navigation
│ └── index.ts
└── index.ts

TypeScript Types

Define types for collection data:

src/types/collection.ts
/**
* Represents a product within a collection listing.
* Contains the essential data needed for product cards and filtering.
*/
export interface CollectionProduct {
id: number; // Unique product identifier.
title: string; // Product display title.
handle: string; // URL-friendly slug for the product.
url: string; // Full URL to the product page.
vendor: string; // Brand or vendor name.
productType: string; // Category/type of product (e.g., "T-Shirt").
tags: string[]; // Array of tags for filtering and organization.
price: number; // Current price (in cents, typically).
priceMin: number; // Minimum price across all variants.
priceMax: number; // Maximum price across all variants.
compareAtPrice: number | null; // Original price for sale items.
available: boolean; // Whether any variant is in stock.
featuredImage: {
url: string;
alt: string;
width: number;
height: number;
} | null; // Main product image, can be null.
images: Array<{
url: string;
alt: string;
}>; // Additional product images for hover effects.
variants: Array<{
id: number;
title: string;
available: boolean;
price: number;
}>; // Simplified variant data for quick selection.
options: Array<{
name: string;
values: string[];
}>; // Product options (e.g., Size, Color).
}
/**
* Represents a single value within a filter group.
* Used for checkbox filters like color, size, etc.
*/
export interface FilterValue {
label: string; // Display label (e.g., "Blue").
value: string; // URL parameter value.
count: number; // Number of products matching this filter.
active: boolean; // Whether this filter is currently applied.
}
/**
* Represents a filter group (e.g., "Color", "Size", "Price").
* Shopify provides these through the collection.filters object.
*/
export interface Filter {
id: string; // Filter identifier (e.g., "v.option.color").
label: string; // Display label for the filter group.
type: 'list' | 'boolean' | 'price_range'; // Type determines UI component.
values: FilterValue[]; // Available filter values with counts.
}
/**
* Complete data structure for a collection page.
* This is serialized from Liquid into JSON for React consumption.
*/
export interface CollectionData {
collection: {
id: number;
handle: string;
title: string;
description: string;
image: string | null;
productsCount: number;
}; // Basic collection metadata.
products: CollectionProduct[]; // Array of products to display.
filters: Filter[]; // Available filters for this collection.
pagination: {
currentPage: number;
totalPages: number;
totalProducts: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}; // Pagination state for navigation.
sort: {
current: string;
options: Array<{
value: string;
label: string;
}>;
}; // Current sort and available options.
}

Liquid Data Serialization

Pass collection data to React:

{% comment %} sections/collection-products.liquid {% endcomment %}
{%- comment -%}
This div serves as the React mount point for the entire collection products section.
React will take over rendering the product grid, filters, and pagination here.
{%- endcomment -%}
<div id="collection-products-root"></div>
{%- comment -%}
Serialize all collection data into JSON for React consumption.
This bridges Liquid's server-side data to React's client-side state.
The JSON script tag pattern is secure and performant.
{%- endcomment -%}
<script type="application/json" id="collection-data">
{
{%- comment -%} Collection metadata for header/banner areas {%- endcomment -%}
"collection": {
"id": {{ collection.id }},
"handle": {{ collection.handle | json }},
"title": {{ collection.title | json }},
"description": {{ collection.description | json }},
"image": {% if collection.image %}{{ collection.image | image_url: width: 1200 | json }}{% else %}null{% endif %},
"productsCount": {{ collection.products_count }}
},
{%- comment -%} Products array - limited image data to reduce payload size {%- endcomment -%}
"products": [
{%- for product in collection.products -%}
{
"id": {{ product.id }},
"title": {{ product.title | json }},
"handle": {{ product.handle | json }},
"url": {{ product.url | json }},
"vendor": {{ product.vendor | json }},
"productType": {{ product.type | json }},
"tags": {{ product.tags | json }},
"price": {{ product.price }},
"priceMin": {{ product.price_min }},
"priceMax": {{ product.price_max }},
"compareAtPrice": {% if product.compare_at_price %}{{ product.compare_at_price }}{% else %}null{% endif %},
"available": {{ product.available | json }},
"featuredImage": {% if product.featured_image %}
{
"url": {{ product.featured_image | image_url: width: 600 | json }},
"alt": {{ product.featured_image.alt | json }},
"width": {{ product.featured_image.width }},
"height": {{ product.featured_image.height }}
}
{% else %}null{% endif %},
{%- comment -%} Limit to 2 images for hover effect - keeps payload small {%- endcomment -%}
"images": [
{%- for image in product.images limit: 2 -%}
{
"url": {{ image | image_url: width: 600 | json }},
"alt": {{ image.alt | json }}
}{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
],
"variants": [
{%- for variant in product.variants -%}
{
"id": {{ variant.id }},
"title": {{ variant.title | json }},
"available": {{ variant.available | json }},
"price": {{ variant.price }}
}{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
],
"options": [
{%- for option in product.options_with_values -%}
{
"name": {{ option.name | json }},
"values": {{ option.values | json }}
}{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
]
}{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
],
{%- comment -%} Shopify's built-in filters object provides filter options with counts {%- endcomment -%}
"filters": {{ collection.filters | json }},
{%- comment -%} Pagination state from Liquid's paginate object {%- endcomment -%}
"pagination": {
"currentPage": {{ current_page | default: 1 }},
"totalPages": {{ paginate.pages }},
"totalProducts": {{ collection.products_count }},
"hasNextPage": {{ paginate.next.is_link | json }},
"hasPreviousPage": {{ paginate.previous.is_link | json }}
},
{%- comment -%} Sort options - current selection and available choices {%- endcomment -%}
"sort": {
"current": {{ collection.sort_by | json }},
"options": [
{ "value": "manual", "label": "Featured" },
{ "value": "best-selling", "label": "Best Selling" },
{ "value": "title-ascending", "label": "A-Z" },
{ "value": "title-descending", "label": "Z-A" },
{ "value": "price-ascending", "label": "Price: Low to High" },
{ "value": "price-descending", "label": "Price: High to Low" },
{ "value": "created-ascending", "label": "Oldest" },
{ "value": "created-descending", "label": "Newest" }
]
}
}
</script>

Collection State Store

Manage collection state with Zustand:

src/stores/collection.ts
import { create } from 'zustand';
import type { CollectionData, CollectionProduct, Filter } from '@/types/collection';
/**
* Collection state interface defining all data, UI state, and actions.
* This store manages the entire collection page: products, filters, sorting, and pagination.
*/
interface CollectionState {
// Data - sourced from Liquid initially, then updated via API
products: CollectionProduct[];
filters: Filter[];
totalProducts: number;
// UI State - controls component rendering and interactions
currentPage: number;
sortBy: string;
activeFilters: Record<string, string[]>; // Map of filter IDs to selected values.
isLoading: boolean;
error: string | null;
// View preferences - persisted to localStorage
gridColumns: 2 | 3 | 4;
// Actions - functions that modify state and trigger side effects
initialize: (data: CollectionData) => void;
setSort: (sortBy: string) => void;
toggleFilter: (filterId: string, value: string) => void;
clearFilter: (filterId: string) => void;
clearAllFilters: () => void;
setPage: (page: number) => void;
setGridColumns: (columns: 2 | 3 | 4) => void;
fetchProducts: () => Promise<void>;
}
// Create the Zustand store for collection state management.
export const useCollection = create<CollectionState>((set, get) => ({
// Initial state values - empty until initialized with Liquid data.
products: [],
filters: [],
totalProducts: 0,
currentPage: 1,
sortBy: 'manual', // Default Shopify sort is "manual" (featured).
activeFilters: {},
isLoading: false,
error: null,
gridColumns: 4,
// Initialize store with data from Liquid-rendered JSON.
// Called once on component mount.
initialize: (data) => {
set({
products: data.products,
filters: data.filters,
totalProducts: data.pagination.totalProducts,
currentPage: data.pagination.currentPage,
sortBy: data.sort.current,
// Parse any existing filters from URL (for shareable links).
activeFilters: parseActiveFiltersFromUrl(),
});
},
// Update sort order - resets to page 1 and fetches new data.
setSort: (sortBy) => {
set({ sortBy, currentPage: 1 }); // Reset to page 1 when sort changes.
get().fetchProducts(); // Fetch products with new sort.
updateUrl(get()); // Sync URL for shareable links.
},
// Toggle a filter value on/off.
// If already active, removes it; if inactive, adds it.
toggleFilter: (filterId, value) => {
set((state) => {
const current = state.activeFilters[filterId] || [];
const updated = current.includes(value)
? current.filter((filterValue) => filterValue !== value) // Remove if exists.
: [...current, value]; // Add if doesn't exist.
return {
activeFilters: {
...state.activeFilters,
[filterId]: updated,
},
currentPage: 1, // Reset to page 1 when filters change.
};
});
get().fetchProducts();
updateUrl(get());
},
// Clear all values for a specific filter group.
clearFilter: (filterId) => {
set((state) => {
// Destructure to remove the filter, keep the rest.
const { [filterId]: removed, ...rest } = state.activeFilters;
return { activeFilters: rest, currentPage: 1 };
});
get().fetchProducts();
updateUrl(get());
},
// Clear all active filters.
clearAllFilters: () => {
set({ activeFilters: {}, currentPage: 1 });
get().fetchProducts();
updateUrl(get());
},
// Navigate to a specific page.
setPage: (page) => {
set({ currentPage: page });
get().fetchProducts();
updateUrl(get());
// Smooth scroll to the top of the product grid for better UX.
document.getElementById('collection-products-root')?.scrollIntoView({
behavior: 'smooth',
});
},
// Update grid column count and persist preference.
setGridColumns: (columns) => {
set({ gridColumns: columns });
localStorage.setItem('grid-columns', String(columns)); // Persist for return visits.
},
// Fetch products from Shopify with current filters, sort, and page.
fetchProducts: async () => {
const state = get();
set({ isLoading: true, error: null }); // Set loading state.
try {
const url = buildCollectionUrl(state);
const response = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to fetch products');
}
// Shopify returns HTML with section rendering.
// Parse products from the response using a helper function.
const html = await response.text();
const products = parseProductsFromHtml(html);
set({ products, isLoading: false });
} catch (error) {
set({
error: 'Failed to load products',
isLoading: false,
});
}
},
}));
/**
* Parse active filters from the current URL.
* Shopify uses the pattern: filter.{filterId}={value}
* This enables shareable filtered URLs.
*/
function parseActiveFiltersFromUrl(): Record<string, string[]> {
const params = new URLSearchParams(window.location.search);
const filters: Record<string, string[]> = {};
params.forEach((value, key) => {
if (key.startsWith('filter.')) {
const filterId = key.replace('filter.', '');
if (!filters[filterId]) {
filters[filterId] = [];
}
filters[filterId].push(value);
}
});
return filters;
}
/**
* Update the browser URL with the current state.
* Uses replaceState to avoid polluting browser history.
*/
function updateUrl(state: CollectionState) {
const params = new URLSearchParams();
// Only add sort_by if not default.
if (state.sortBy !== 'manual') {
params.set('sort_by', state.sortBy);
}
// Only add page if not page 1.
if (state.currentPage > 1) {
params.set('page', String(state.currentPage));
}
// Add all active filters.
Object.entries(state.activeFilters).forEach(([filterId, values]) => {
values.forEach((value) => {
params.append(`filter.${filterId}`, value);
});
});
const search = params.toString();
const url = search ? `${window.location.pathname}?${search}` : window.location.pathname;
window.history.replaceState({}, '', url); // Update URL without navigation.
}
/**
* Build the fetch URL for Shopify's collection endpoint.
* The 'view=ajax' parameter tells Liquid to use an AJAX-specific template.
*/
function buildCollectionUrl(state: CollectionState): string {
const params = new URLSearchParams();
params.set('view', 'ajax'); // Use AJAX template for JSON response.
params.set('sort_by', state.sortBy);
params.set('page', String(state.currentPage));
Object.entries(state.activeFilters).forEach(([filterId, values]) => {
values.forEach((value) => {
params.append(`filter.${filterId}`, value);
});
});
return `${window.location.pathname}?${params.toString()}`;
}

Main Collection Page Component

src/components/collection/CollectionPage/CollectionPage.tsx
import { useEffect } from 'react';
import { useCollection } from '@/stores/collection';
import { readJsonScript } from '@/utils/data-bridge'; // Helper to parse JSON from script tags.
import type { CollectionData } from '@/types/collection';
import { ProductGrid } from '../ProductGrid';
import { FilterSidebar } from '../FilterSidebar';
import { ActiveFilters } from '../ActiveFilters';
import { SortDropdown } from '../SortDropdown';
import { Pagination } from '../Pagination';
import { Spinner } from '@/components/ui';
import styles from './CollectionPage.module.css';
/**
* Main orchestrator component for the collection page.
* Coordinates data flow between Liquid, the Zustand store, and child components.
*/
export function CollectionPage() {
// Read initial collection data from Liquid-rendered JSON script tag.
const collectionData = readJsonScript('collection-data') as CollectionData | null;
// Get store actions and state using Zustand selectors.
// Selecting individual pieces of state prevents unnecessary re-renders.
const initialize = useCollection((state) => state.initialize);
const products = useCollection((state) => state.products);
const isLoading = useCollection((state) => state.isLoading);
const error = useCollection((state) => state.error);
// Initialize the store with Liquid data on mount.
useEffect(() => {
if (collectionData) {
initialize(collectionData);
}
}, [collectionData, initialize]);
// Handle case where JSON data is missing or invalid.
if (!collectionData) {
return <div className={styles.error}>Failed to load collection data</div>;
}
return (
<div className={styles.page}>
{/* Toolbar: Active filters display and sort dropdown */}
<div className={styles.toolbar}>
<ActiveFilters />
<SortDropdown options={collectionData.sort.options} />
</div>
{/* Main layout: Sidebar + Product grid */}
<div className={styles.layout}>
<aside className={styles.sidebar}>
<FilterSidebar />
</aside>
<main className={styles.main}>
{/* Display error message if fetch failed */}
{error && <div className={styles.error}>{error}</div>}
{/* Grid wrapper with loading overlay */}
<div className={`${styles.gridWrapper} ${isLoading ? styles.loading : ''}`}>
<ProductGrid products={products} />
{/* Loading spinner overlays the grid during fetches */}
{isLoading && (
<div className={styles.loadingOverlay}>
<Spinner />
</div>
)}
</div>
<Pagination />
</main>
</div>
</div>
);
}
src/components/collection/CollectionPage/CollectionPage.module.css
.page {
padding: 2rem 0;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 2rem;
}
@media (max-width: 1023px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
}
.main {
min-width: 0;
}
.gridWrapper {
position: relative;
transition: opacity 0.2s ease;
}
.gridWrapper.loading {
opacity: 0.6;
pointer-events: none;
}
.loadingOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.5);
}
.error {
padding: 2rem;
text-align: center;
color: var(--color-error);
}

Key Takeaways

  1. Dual data sources: Initial data from Liquid, updates via API
  2. URL as source of truth: Sync filters, sort, and page with URL
  3. Zustand for state: Centralized state for filters, sort, pagination
  4. Component isolation: Each component has a single responsibility
  5. Loading states: Show loading overlay during fetches
  6. Optimistic URL updates: Update URL immediately, fetch in background

In the next lesson, we’ll build the Product Card component with variant previews.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...