Skip to content

Commit

Permalink
Add an infinite scroll example
Browse files Browse the repository at this point in the history
  • Loading branch information
blittle committed Jan 30, 2024
1 parent 2e1e3ab commit 02cc172
Show file tree
Hide file tree
Showing 6 changed files with 1,261 additions and 0 deletions.
39 changes: 39 additions & 0 deletions examples/infinite-scroll/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Hydrogen example: infinite scroll collection page

This folder contains an example implementation of [infinite scroll](https://shopify.dev/docs/custom-storefronts/hydrogen/data-fetching/pagination#automatically-load-pages-on-scroll) within a product collection page using the [Pagination component](https://shopify.dev/docs/api/hydrogen/2024-01/components/pagination).

The example uses [`react-intersection-observer`](https://www.npmjs.com/package/react-intersection-observer) to detect when the `Load more` button is in view. A `useEffect` then triggers a navigation to the next page url, which seemlessly loads more products as the user scrolls.

A few side effects of this implementation are:

1. The page progressively enhances, so that when JavaScript has yet to load, the page is still interactive because the user can still click the `Load more` button.
2. As the user scrolls, the URL automatically changes as new pages are loaded.
3. Because the implementation uses the `Pagination` component, navigating back to the collection list after clicking on a product automatically maintains the user's scroll position.

## Key files

This folder contains the minimal set of files needed to showcase the implementation.
Files that aren’t included by default with Hydrogen and that you’ll need to
create are labeled with 🆕.

| File | Description |
| -------------------------------------------------------------------------- | --------------------------- |
| [`app/routes/collections.$handle.tsx`](app/routes/collections.$handle.tsx) | The product collection page |

## Instructions

### 1. Link your store to inject the required environment variables

```bash
h2 link
```

### 2. Edit the route loader

In `app/routes/collections.$handle.tsx`, update the `pageBy` parameter passed to tghe `getPaginationVariables` function call to customize how many products to load at a time.

```ts
const paginationVariables = getPaginationVariables(request, {
pageBy: 8,
});
```
231 changes: 231 additions & 0 deletions examples/infinite-scroll/app/routes/collections.$handle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import {json, redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {
useLoaderData,
useNavigate,
Link,
type MetaFunction,
} from '@remix-run/react';
import {
Pagination,
getPaginationVariables,
Image,
Money,
} from '@shopify/hydrogen';
import type {ProductItemFragment} from 'storefrontapi.generated';
import {useEffect} from 'react';
import {useVariantUrl} from '~/lib/variants';
import {useInView} from 'react-intersection-observer';

export const meta: MetaFunction<typeof loader> = ({data}) => {
return [{title: `Hydrogen | ${data?.collection.title ?? ''} Collection`}];
};

export async function loader({request, params, context}: LoaderFunctionArgs) {
const {handle} = params;
const {storefront} = context;
const paginationVariables = getPaginationVariables(request, {
pageBy: 8,
});

if (!handle) {
return redirect('/collections');
}

const {collection} = await storefront.query(COLLECTION_QUERY, {
variables: {handle, ...paginationVariables},
});

if (!collection) {
throw new Response(`Collection ${handle} not found`, {
status: 404,
});
}
return json({collection});
}

export default function Collection() {
const {collection} = useLoaderData<typeof loader>();
const {ref, inView, entry} = useInView();

return (
<div className="collection">
<h1>{collection.title}</h1>
<p className="collection-description">{collection.description}</p>
<Pagination connection={collection.products}>
{({
nodes,
isLoading,
PreviousLink,
NextLink,
state,
nextPageUrl,
hasNextPage,
}) => (
<>
<PreviousLink>
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
</PreviousLink>
<ProductsGrid
products={nodes}
inView={inView}
hasNextPage={hasNextPage}
nextPageUrl={nextPageUrl}
state={state}
/>
<br />
<NextLink ref={ref}>
<span ref={ref}>
{isLoading ? 'Loading...' : <span>Load more yeah ↓</span>}
</span>
</NextLink>
</>
)}
</Pagination>
</div>
);
}

function ProductsGrid({
products,
inView,
hasNextPage,
nextPageUrl,
state,
}: {
products: ProductItemFragment[];
inView: boolean;
hasNextPage: boolean;
nextPageUrl: string;
state: any;
}) {
const navigate = useNavigate();

useEffect(() => {
if (inView && hasNextPage) {
navigate(nextPageUrl, {
replace: true,
preventScrollReset: true,
state,
});
}
}, [inView, navigate, state, nextPageUrl, hasNextPage]);

return (
<div className="products-grid">
{products.map((product, index) => {
return (
<ProductItem
key={product.id}
product={product}
loading={index < 8 ? 'eager' : undefined}
/>
);
})}
</div>
);
}

function ProductItem({
product,
loading,
}: {
product: ProductItemFragment;
loading?: 'eager' | 'lazy';
}) {
const variant = product.variants.nodes[0];
const variantUrl = useVariantUrl(product.handle, variant.selectedOptions);
return (
<Link
className="product-item"
key={product.id}
prefetch="intent"
to={variantUrl}
>
{product.featuredImage && (
<Image
alt={product.featuredImage.altText || product.title}
aspectRatio="1/1"
data={product.featuredImage}
loading={loading}
sizes="(min-width: 45em) 400px, 100vw"
/>
)}
<h4>{product.title}</h4>
<small>
<Money data={product.priceRange.minVariantPrice} />
</small>
</Link>
);
}

const PRODUCT_ITEM_FRAGMENT = `#graphql
fragment MoneyProductItem on MoneyV2 {
amount
currencyCode
}
fragment ProductItem on Product {
id
handle
title
featuredImage {
id
altText
url
width
height
}
priceRange {
minVariantPrice {
...MoneyProductItem
}
maxVariantPrice {
...MoneyProductItem
}
}
variants(first: 1) {
nodes {
selectedOptions {
name
value
}
}
}
}
` as const;

// NOTE: https://shopify.dev/docs/api/storefront/2022-04/objects/collection
const COLLECTION_QUERY = `#graphql
${PRODUCT_ITEM_FRAGMENT}
query Collection(
$handle: String!
$country: CountryCode
$language: LanguageCode
$first: Int
$last: Int
$startCursor: String
$endCursor: String
) @inContext(country: $country, language: $language) {
collection(handle: $handle) {
id
handle
title
description
products(
first: $first,
last: $last,
before: $startCursor,
after: $endCursor
) {
nodes {
...ProductItem
}
pageInfo {
hasPreviousPage
hasNextPage
endCursor
startCursor
}
}
}
}
` as const;
15 changes: 15 additions & 0 deletions examples/infinite-scroll/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "example-infinite-scroll",
"private": true,
"prettier": "@shopify/prettier-config",
"scripts": {
"build": "shopify hydrogen build --diff",
"dev": "shopify hydrogen dev --codegen --diff",
"preview": "npm run build && shopify hydrogen preview",
"lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .",
"codegen": "shopify hydrogen codegen"
},
"dependencies": {
"react-intersection-observer": "^8.32.1"
}
}
11 changes: 11 additions & 0 deletions examples/infinite-scroll/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../../templates/skeleton/tsconfig.json",
"include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": ["./*", "../../templates/skeleton/*"],
"~/*": ["app/*", "../../templates/skeleton/app/*"]
}
}
}
Loading

0 comments on commit 02cc172

Please sign in to comment.