Lab - Server Actions and Built-in Components

Plan: Composable Developer

Lesson 13 of 19 · 45 min

After the steps in the previous exercise, you should have a basic presentation of product FAQs displayed on your product detail page. Next, you’ll improve this with enhanced presentation using the core component library and interactivity.

In this lab, you will:

  • Update the product FAQs presentation to use the Accordion component from the core component library
  • Implement a “Load More” feature, using a server action to fetch additional pages of results
  • Add loading states and error handling

Setup

This exercise continues where you left off previously in your Catalyst project. Simply start the dev server from the project root if it’s not currently running:

pnpm run dev

If you need a fresh start, you can follow the instructions below to set up a new project complete with previous exercise code.

  1. In your terminal, run the following, replacing the path to the working directory with your own path:
pnpm dlx create-next-app@latest -e https://github.com/bigcommerce-edu/lab-catalyst-makeswift-faqs/tree/e-faqs-enh-start
  1. Copy .env.example as .env.local and modify .env.local with configuration details for your store.
  2. Start the dev server process:
cd /path/to/working/directory
pnpm run dev

Exercise Reference Copy

If it’s helpful to compare your own code as you go, you can clone or download the completed version of this lab as a reference.

Product FAQs Enhancement Lab Snapshot

Step 1: Implement an Accordion

  1. Edit the file components/custom/product-faqs/faqs-list.tsx and add an import among the other imports at the top of the file.
import { Accordion, AccordionItem } from '../../../../../../vibes/soul/primitives/accordion';
  1. Modify the original JSX code returned from the component as shown.
export function FaqsList({
faqs,
}: FaqsListProps) {
return faqs.length <= 0 ? '' : (
<div className="mx-auto md:w-2/3">
<Accordion
type="multiple"
>
{faqs.map(faq => (
<AccordionItem key={faq.key} title={faq.question} value={faq.key}>
{faq.answer}
</AccordionItem>
))}
</Accordion>
</div>
);
}

Browse to your FAQ-enabled product on your storefront, and you should now see the original presentation replaced with a cleaner, compact interface allowing FAQs to be expanded individually.

Product FAQs with accordions

As a brief exercise to demonstrate how common components can be themed using CSS variables, let’s also override one of the Accordion component’s color styles.

  1. Modify the Accordion placement with a style property to set --accordion-light-title-text-hover in this context.
<Accordion
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
style={{
'--accordion-light-title-text-hover': 'hsl(var(--info))'
} as React.CSSProperties}
type="multiple"
>
...
</Accordion>

In this case, we’re making use of one of the primitive color values defined in the site-wide palette (info), utilizing the matching CSS variable (--info). Since Makeswift is integrated in this project, this CSS variable is defined in lib/makeswift/components/site-theme/base-colors.tsx. Browse to the product page again and verify that the corresponding color value is used when hovering over each accordion title.

In a Makeswift-enabled storefront, it’s considered best practice to avoid the direct use of primitive color values as we’ve done here with --info. Instead, the color palette defined within the Makeswift site should be authoritative, with the use of Site Theme properties to allow for mapping them to practical CSS variables for components.

See Makeswift Core for more on this technique.

Example Code

Step 2: Implement a Load More Component

Time to implement the ability to load more product FAQs.

  1. Edit the file messages/en.json to add one more string in the “FAQ” section: A “Load More” label.
...
"FAQ": {
"heading": "Frequently Asked Questions",
"loadMore": "Load more"
}
...
  1. Open the file components/custom/product-faqs/load-more-faqs.tsx. This component handles the interactivity involved with the “Load More” action and renders any additional “pages” of FAQs that are loaded.

Note the use client directive at the top of the component file. As it’s responsible for interactivity in the browser, this is a client component.

  1. Add an import for the Button component from the core library, with the other imports at the top of the file.
import { Button } from '../../../../../../vibes/soul/primitives/button';
  1. Modify the props interface and destructuring expression to accept the appropriate props.
interface LoadMoreFaqsProps {
productId: number;
limit: number;
initialEndCursor: string | null;
}
export function LoadMoreFaqs({
productId,
limit,
initialEndCursor,
}: LoadMoreFaqsProps) {
...
}
  1. Modify the component to create state values to track the “more FAQs” that are loaded by the component, as well as the current end cursor. This modification will also create a full list of FAQs to display by concatenating the original passed FAQs with any further FAQs that are loaded, as well as defining a function for loading the next set based on locale.
export function LoadMoreFaqs({
...
}: LoadMoreFaqsProps) {
const locale = useLocale();
const t = useTranslations('Product.FAQ');
const [faqs, setFaqs] = useState<Faq[]>([]);
const [endCursor, setEndCursor] = useState<string | null>(initialEndCursor);
const getNextFaqs = async () => {
if (!productId) {
return;
}
try {
const nextFaqData = await getNextProductFaqs({ productId, locale, limit, after: endCursor });
setEndCursor(nextFaqData.endCursor);
setFaqs(faqs.concat(nextFaqData.faqs));
} catch (err) {
// Handle error
}
};
return (
...
);
}
  1. Modify the returned JSX to render any additional loaded FAQs, using the same FaqsList component already designed for this.
export function LoadMoreFaqs({
...
}: LoadMoreFaqsProps) {
...
return (
<>
{faqs.length > 0 && (
<FaqsList faqs={faqs} />
)}
</>
);
}
  1. Modify the returned JSX code to include a “Load More” button if there is an endCursor value.
export function LoadMoreFaqs({
...
}: LoadMoreFaqsProps) {
...
return (
<>
{faqs.length > 0 && (
<FaqsList faqs={faqs} />
)}
{(endCursor !== null) && (
<div className="mx-auto md:w-2/3 lg:w-1/3 text-center py-4">
<Button
onClick={getNextFaqs}
variant="secondary"
>
{t('loadMore')}
</Button>
</div>
)}
</>
);
}
  1. Open the file components/custom/product-faqs/index.tsx and add the boolean prop showLoadMore. This isn’t currently needed but will allow for conditional inclusion of the “Load More” interactivity in the future.
interface ProductFaqsProps {
...
showLoadMore?: boolean;
}
export function ProductFaqs({
...
showLoadMore = true,
}: ProductFaqsProps) {
...
}
  1. Add the LoadMoreFaqs component, with the rendering conditional on showLoadMore and the presence of an endCursor.
export function ProductFaqs({
...
}: ProductFaqsProps) {
return (
<section ...>
<div ...>
...
<FaqsList faqs={faqsCollection.faqs} />
{showLoadMore && (faqsCollection.endCursor !== null) && (
<LoadMoreFaqs
initialEndCursor={faqsCollection.endCursor}
limit={limit}
productId={productId}
/>
)}
</div>
</section>
);
}

Browse to your product page once again. Presuming the product contains more FAQ metafields than the two initially loaded on the page, you should have a “Load More” button. Since the server action this will call is already wired up, you can see a corresponding request in the Network tab of your browser’s dev tools when you activate the button, although there won’t be any result until the server action is working.

Example Code

Step 3: Complete the Server Action

  1. Open the file components/custom/product-faqs/_actions/get-next-product-faqs.ts. This defines the server action that the “Load More” interaction in the client component activates.
  2. Modify the component to run the metafields query.
export const getNextProductFaqs = async(variables: ProductFaqVariables) => {
return await getProductFaqMetafields(variables);
}

You can see that not much is required here, as this function is essentially serving simply as a “pass-through” to the query function. Having the wrapper here allows this specific context to be defined as a server action, as well as any future logic that is specific to this response to our “load more” button.

Browse to your product page once again. Presuming the product contains more FAQ metafields than the two initially loaded on the page, you should have a working “Load More” button that will successfully fetch and display the next (up to) two FAQs.

Example Code

Step 4: Load Initial FAQs Asynchronously

In the next step, we’ll use the <Stream> component pattern to ensure the initial loading of FAQs doesn’t impact the rendering of the basic page content.

  1. Edit the file components/custom/product-faqs/index.tsx and fill out a definition for the ProductFaqsSkeleton component. This defines a visual loading state appropriate for the FAQs content.
export function ProductFaqsSkeleton() {
return (
<div className="animate-pulse mx-auto md:w-2/3 p-4 items-center">
<div className="my-3 h-12 w-full rounded-md bg-contrast-100 @md:my-4" />
<div className="my-3 h-12 w-full rounded-md bg-contrast-100 @md:my-4" />
<div className="my-3 h-12 w-full rounded-md bg-contrast-100 @md:my-4" />
</div>
);
}
  1. Add an import at the top of the file for the appropriate streaming resources.
import { Stream, Streamable } from '../../../../../../vibes/soul/lib/streamable';
  1. Update the props interface to accept a “streamable” version of the faqsCollection, which can be resolved as a promise or not, as well as establishing an alias for the prop in the component signature.
interface ProductFaqsProps {
...
faqsCollection: Streamable<FaqsCollection>;
...
}
export function ProductFaqs({
...
faqsCollection: streamableFaqsCollection,
...
}: ProductFaqsProps) {
...
}
  1. Modify the current content of ProductFaqs to wrap it in <Stream> with an appropriate fallback, as well as a callback that will render when the streamable value is resolved.
export function ProductFaqs({
...
}: ProductFaqsProps) {
return (
<Stream fallback={<ProductFaqsSkeleton />} value={streamableFaqsCollection}>
{(faqsCollection) => (
<section ...>
...
</section>
)}
</Stream>
);
}
  1. Edit the file core/app/[locale]/(default)/product/[slug]/page.tsx to wrap the FAQs fetch in Streamable.from, ensuring we have a promise that doesn’t block page rendering and will only be executed when it is used.
export default async function Product({ params, searchParams }: Props) {
...
const tFaqs = await getTranslations('Product.FAQ');
const faqsHeading = tFaqs('heading');
const faqsLimit = 2;
const streamableFaqsCollection = Streamable.from(async () => {
return await getProductFaqMetafields({ productId, locale, limit: faqsLimit });
});
return (
...
);
}
  1. Update the faqsCollection prop of ProductFaqs to receive the streamable value.
<ProductFaqs
faqsCollection={streamableFaqsCollection}
heading={faqsHeading}
limit={faqsLimit}
productId={productId}
/>
  1. Edit the file components/custom/product-faqs/_data/component-data.ts. To make the new loading state obvious, you’ll artificially extend the time it takes to perform the query. Add the following at the top of the getProductFaqMetafields function.
const getProductFaqMetafields = cache(
async (variables: Variables) => {
await new Promise((resolve) => setTimeout(resolve, 5000));
...
}
);

Browse to or fully refresh your product page to see the loading state in action.

Example Code

Step 5: Additional Loading State

The “Load More” button works, but currently the user receives no feedback while the network request is occurring to let them know that something is happening. Let’s improve that.

The Button component you’ve already implemented supports a built-in loading state. All that’s needed is to pass it a state variable that tracks when the button is in the loading state.

  1. Open components/custom/product-faqs/load-more-faqs.tsx and add a pending state var and pass it on to Button.
export function LoadMoreFaqs({
...
}: LoadMoreFaqsProps) {
...
const [faqs, setFaqs] = useState<Faq[]>([]);
const [endCursor, setEndCursor] = useState<string | null>(initialEndCursor);
const [pending, setPending] = useState(false);
const getNextFaqs = async () => {
if (!productId) {
return;
}
setPending(true);
try {
...
} catch (err) {
...
}
setPending(false);
};
return (
<>
...
{(endCursor !== null) && (
<div className="mx-auto md:w-2/3 lg:w-1/3 text-center py-4">
<Button
loading={pending}
onClick={getNextFaqs}
variant="secondary"
>
...
</Button>
</div>
)}
</>
);
}

Browse to your product page again and use the “Load More” button to observe the new loading state.

Loading state for button

Example Code

Step 6: Add Error Handling

Let’s add a proper user notification in the event that our “load more” button results in an error.

  1. Edit the file components/custom/product-faqs/load-more-faqs.tsx and add the following import after the other imports.
import { toast } from '../../../../../../vibes/soul/primitives/toaster';
  1. Modify the catch block in getNextFaqs to trigger an error notification.
export function LoadMoreFaqs({
...
}: LoadMoreFaqsProps) {
...
const getNextFaqs = async () => {
...
try {
...
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
toast.error(error);
}
...
};
...
}
  1. If you want to test this error notification, temporarily modify components/custom/product-faqs/_actions/get-next-product-faqs.ts and add an exception.
...
const getNextProductFaqs = async (
...
) => {
throw new Error("Something went wrong");
...

Now when you use the “Load More” button, you should be able to observe the simple error notification in the top right corner of the viewport.

Error message

Example Code

Full Exercise Code

Full Exercise Code