A collection of full stack patterns that I'm tired of copy/pasting between apps for my clients.
The functionality enabled by this library assumes a Shopify app with React and Polaris. A server-side framework like Remix will be required for modal and filepicker functionality.
The following features are required for client side fetching in the File Picker and Autocomplete Search. Please make sure to upgrade your app to utilize these before attempting to add this library.
pnpm add @sadsciencee/shopify
- Node.js 18+
- React 18+
- Shopify App Bridge V4
- Direct Access API enabled
- Runtime: Node.js or Cloudflare Workers
@shopify/app-bridge-react@^4.1.6
@shopify/polaris@^12.0.0
react@^18.2.0
react-dom@^18.2.0
A React hook/component for the app bridge resource picker is just about ready. TODO: more testing and docs
App Bridge V4 handles modals through native iframes, as opposed to the previous version which allowed React Portals. As a result there are some fairly finicky requirements to enable max modals or complex modals. You can't pass in initial state, or callbacks, bi-directional communication is a whole thing
Not to fear though. This library takes care of most of that. There are still a few setup steps that can't be avoided.
Create a catch-all /modal
route that renders server side. Copy your /routes/app.tsx
file to /routes/modal.tsx
, but remove the <NavMenu>
component (App Bridge NavMenu conflicts with modals).
See apps/example/app/routes/modal.tsx
for a working example you can copy.
Here is a super basic implementation.
import { ModalV4 } from '@sadsciencee/shopify/react';
<ModalV4
/**
* this should be the initial state of your title bar. You can update the disabled status and even hide/show buttons
* from your modal route with the `useParent` hook in step 3
*/
titleBar={{
title: 'Products',
primaryButton: {
label: 'Save',
disabled: true,
},
secondaryButton: {
label: 'Reset',
disabled: true,
},
}}
/**
* id and route are coupled to the id and route in step 3
*/
id={'uniqueId'}
route={'products'}
variant="max" // 'small' | 'base' | 'large' | 'max'
/**
* Render function for modal trigger element.
*/
opener={({ onClick }) => <Button onClick={onClick}>Open Modal</Button>}
/>
Create routes for each modal using the pattern modal.modal-type.$id.tsx
.
Here's a remix route file you can copy/paste. If you are using Next.js, I'm sure you'll figure it out.
import { BlockStack, Box, Card, Layout, Link, List, Page, Text } from '@shopify/polaris';
import { useParent } from '@sadsciencee/shopify/react';
import { useCallback } from 'react';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export const loader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id ?? 'auto'
return { id };
};
export default function YourModal() {
const loaderData = useLoaderData<typeof loader>();
const onPrimaryAction = useCallback(() => {
generateProduct();
}, [generateProduct]);
const onSecondaryAction = useCallback(() => {
console.log('Secondary Button Clicked');
}, []);
/**
* Pass in callback `onReply` to handle replies from the modal.
*/
const onReply = useCallback((data: unknown) => {
console.log('Reply from modal:', data)
}, [])
/**
* `useParent` will return the following object which you can use to interact with the parent
*/
const {
/**
* Send a message to the parent frame.
* @example sendMessage({ userEmail: 'david@ucoastweb.com' });
*/
sendMessage,
/**
* The initial state of the parent frame, at the time the modal was loaded.
*/
parentState,
/**
* The initial state of the title bar in the parent frame, at the time the modal was loaded.
*/
titleBarState,
/**
* Modify the title bar in the parent frame.
* @example updateTitleBar({
* title: 'Create Product',
* primaryButton: { label: 'Save', disabled: false },
* secondaryButton: { label: 'Reset', disabled: false }
* });
*
* You only have to pass the values you want to change. To disable the primary button, pass `disabled: true`.
* @example updateTitleBar({ primaryButton: { disabled: true } });
*
* To hide an existing button, pass `null`.
* @example updateTitleBar({ primaryButton: null });
*/
updateTitleBar,
loaded,
} = useParent({
id: loaderData.id,
route: 'hello',
onPrimaryAction,
onSecondaryAction,
onReply,
});
return (
<Card>
<BlockStack gap="300">
<Text as="p" variant="bodyMd">
The app template comes with an additional page which demonstrates how to create
multiple pages within app navigation using{' '}
<Link
url="https://shopify.dev/docs/apps/tools/app-bridge"
target="_blank"
removeUnderline
>
App Bridge
</Link>
.
</Text>
<Text as="p" variant="bodyMd">
To create your own page and have it show up in the app navigation, add a page inside{' '}
<Code>app/routes</Code>, and a link to it in the <Code><NavMenu></Code>{' '}
component found in <Code>app/routes/app.jsx</Code>.
</Text>
<Box></Box>
</BlockStack>
</Card>
);
}
// optionally create a shared message type that you use in both the portal and the parent.
// this can contain whatever you want, shouldReply and shouldClose are not required fields
type ModalMessageType = {
whatever: 'you',
want: 'here',
shouldReply: boolean,
shouldClose: boolean,
}
const onMessage = useCallback((data: ModalMessageType, {close, reply}) => {
// the provided reply callback allows you to respond to messages. this can be helpful if are triggering
// some operation from the modal that requires a success/fail response from the parent
if (shouldReply) {
reply({info: 'no problem! here is the information'})
}
// if you want to auto-close the modal once the information has been passed from
if (shouldClose) {
close()
}
}, []);
<ModalV4
titleBar={{
title: 'Products',
primaryButton: {
label: 'Delete',
disabled: false,
},
}}
id={'uniqueId'}
route={'products'}
variant="max"
opener={({ onClick }) => <Button onClick={onClick}>Open Modal</Button>}
sharedState={{
howdy: "partner"
}}
onMessage={onMessage}
/>
Coming soon :)
Coming soon :)