2025-11-26
How to properly implement Shopify's contextual save bar so it actually works when you want it to.

This blog is about the Shopify contextual save bar. Not knowing how it worked properly cost me many hours so I'm writing a blog on how to properly use it. This will be part of a larger series of problems that I had to solve to get my app ready for Built for Shopify. I'll be writing a big article about all the things I messed up and how to not make the same mistakes soon so keep an eye out for that.
First, why does the contextual save bar need its own blog post? It took me about a month worth of very frustrating coding to figure out that I was simply using the wrong thing. And the docs don't really say this so I think it's important to mention.
So this is what the issue was. I was using data-save-bar instead of ui-save-bar. There may be a few very specific cases where it's better but mostly it's a lot more difficult to use. It's meant to work with your formState and in theory you would already have all your variables in your formData then you can add a single "data-save-bar" and it just works. There are much more cases where that fails than where it works. I respect the intentions of whoever made it but it just doesn't work how it's supposed to. And I think it's rather unfortunate that on the contextual save bar doc it shows the data-save-bar instead of the ui-save-bar. It would be much better to show the ui-save-bar, that would save people a lot of time and difficulty.
So the solution is to ignore that data-save-bar exists and instead always use the ui-save-bar. It just works (if you use it properly). And there's no need to use <Form> with this method.
import { Page, Layout, Card, TextField, BlockStack, Text, Button } from '@shopify/polaris';
import { useState, useEffect, useRef } from 'react';
import { useLoaderData, useNavigate, useActionData, useSubmit } from "@remix-run/react";
import { authenticate } from "../shopify.server";
import { useAppBridge } from "@shopify/app-bridge-react";
import { ActionFunctionArgs, json } from "@remix-run/node";
import prisma from "../db.server";
import { useShopifyContextualSaveBar } from '../hooks/useShopifyContextualSaveBar';
declare global {
namespace JSX {
interface IntrinsicElements {
'ui-save-bar': any;
}
}
}
export const loader = async ({ request }: { request: Request }) => {
const { session } = await authenticate.admin(request);
// Load existing test data from settings
const settings = await prisma.settings.findFirst({
where: { userId: session.shop }
});
return json({
initialValue: settings?.brandName || ''
});
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const testValue = formData.get('testValue') as string;
// Save to database (using brandName field as example)
await prisma.settings.upsert({
where: { userId: session.shop },
update: { brandName: testValue },
create: {
userId: session.shop,
brandName: testValue
}
});
return json({ success: true, message: 'Brand name saved successfully!' });
};
export default function SaveTest() {
const { initialValue } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [textValue, setTextValue] = useState(initialValue);
const saveBarRef = useRef<any>(null);
const navigate = useNavigate();
const app = useAppBridge();
const submit = useSubmit();
// Store original values for dirty state comparison
const [originalValues, setOriginalValues] = useState<any>(null);
// Calculate dirty state by comparing current vs original values
const isDirty = originalValues ? (
textValue !== originalValues.textValue
) : false;
// Load initial data and set original values
useEffect(() => {
setTextValue(initialValue);
}, [initialValue]);
// Store original values AFTER all state has been set from loader data
useEffect(() => {
// Wait for state to settle
const timer = setTimeout(() => {
if (!originalValues) {
const original = {
textValue: textValue
};
setOriginalValues(original);
}
}, 100);
return () => clearTimeout(timer);
}, [initialValue, originalValues, textValue]);
// Show/hide save bar based on dirty state
useEffect(() => {
const saveBar = saveBarRef.current;
if (!saveBar) return;
if (isDirty) {
saveBar.show();
} else {
saveBar.hide();
}
}, [isDirty]);
// Save and discard handlers for UI save bar
const handleSave = () => {
submitData();
};
const handleDiscard = () => {
if (!originalValues) return;
setTextValue(originalValues.textValue);
};
// Handle action response
useEffect(() => {
if (actionData) {
if (actionData.success) {
// Show success toast using App Bridge
app.toast.show(actionData.message || 'Data saved successfully!');
// Update original values to current values after successful save
const newOriginalValues = {
textValue
};
setOriginalValues(newOriginalValues);
} else {
// Show error toast
app.toast.show(actionData.message || 'Failed to save data', {
isError: true,
duration: 5000
});
}
}
}, [actionData, app, textValue]);
function submitData() {
const formData = new FormData();
formData.append("testValue", textValue);
submit(formData, { method: "post" });
}
// Use the navigation blocking hook
const { handleNavigation } = useShopifyContextualSaveBar({
isDirty,
onSave: handleSave,
onDiscard: handleDiscard,
saveBarRef
});
return (
<Page
title="UI Save Bar Example"
backAction={{
content: "Back to Dashboard",
onAction: () => handleNavigation("/app")
}}
>
<Layout>
<Layout.Section>
<Card>
<BlockStack gap="400">
<Text as="h2" variant="headingMd">
UI Save Bar Demo
</Text>
<TextField
label="Brand Name"
value={textValue}
onChange={(value) => setTextValue(value)}
autoComplete="off"
helpText="This will be saved to your settings"
/>
</BlockStack>
</Card>
</Layout.Section>
</Layout>
<ui-save-bar ref={saveBarRef} id="test-save-bar">
<button variant="primary" onClick={handleSave}>Save</button>
<button onClick={handleDiscard}>Discard</button>
</ui-save-bar>
</Page>
);
}
You need to add the <ui-save-bar> to the bottom of the page. It looks like this:
<ui-save-bar ref={saveBarRef} id="test-save-bar">
<button variant="primary" onClick={handleSave}>Save</button>
<button onClick={handleDiscard}>Discard</button>
</ui-save-bar>
.show(), .hide(), and .leaveConfirmation() methodsThe save bar goes away if you save or discard so you add a variable that stores all the original values. I like to call it originalValues. Then you have another variable isDirty and you compare the current values to the original values like this:
const isDirty = originalValues ? (
textValue !== originalValues.textValue
) : false;
If you're handling more complex states like JSONs or arrays, you may want to have a currentValues variable and compare that. Also you need to make sure they're all ordered the same way. I was using the Shopify product selector and it would break if there were too many products or if any products changed. Basically if you aren't making your JSON objects yourself, make sure you're checking their formatting. And it wasn't actually breaking, it was just showing the save bar on the page whenever I would reload the page even though I didn't make any changes which is just bad ux.
It's also a good idea to set originalValues after the page loads, otherwise you could end up with a mismatched state. Ideally Remix solves this with loaders but there are a lot of edge cases. Like what if moving a variable from loaderData to the page changes the format a tiny bit? It just makes it less prone to errors. It isn't always necessary but it often is. Plus useEffect will tell you when the variables are done loading. Here's the code:
useEffect(() => {
// Wait for state to settle
const timer = setTimeout(() => {
if (!originalValues) {
const original = {
textValue: textValue
};
setOriginalValues(original);
}
}, 100);
return () => clearTimeout(timer);
}, [initialValue, originalValues, textValue]);
So once you have that comparison going, it's time to actually make the save bar show up. For that you just add a useEffect.
useEffect(() => {
const saveBar = saveBarRef.current;
if (!saveBar) return;
if (isDirty) {
saveBar.show();
} else {
saveBar.hide();
}
}, [isDirty]);
This just runs whenever isDirty changes and makes the call to show the save bar.
When you're saving, you'll want to set originalValues to the new values. Only do that upon successful save though.
When discarding, you just have to set all the values to their original states. You might have a better way to do that than calling setVariable(originalValues.variable) but if not, resetting them manually works fine.
There's not a super easy way to do this unfortunately. What I do is I have this useShopifyContextualSaveBar.ts file that stops the navigation and triggers the shake animation. So whenever you would navigate in your app, you would need to call that function. Though you can simplify it by making it use your existing navigation variable. Note that it's very easy to miss places or buttons that navigate or switch pages destructively so you'll probably want to grep your navigation terms.
import { useCallback } from 'react';
import { useNavigate, useBeforeUnload } from '@remix-run/react';
import { useAppBridge } from '@shopify/app-bridge-react';
interface UseShopifyContextualSaveBarOptions {
isDirty: boolean;
onSave?: () => void | Promise<void>;
onDiscard?: () => void;
saveBarRef?: React.RefObject<any>;
}
export function useShopifyContextualSaveBar({
isDirty,
onSave,
onDiscard,
saveBarRef
}: UseShopifyContextualSaveBarOptions) {
const navigate = useNavigate();
const app = useAppBridge();
// Prevent browser navigation when there are unsaved changes
useBeforeUnload(
useCallback(
(event) => {
if (isDirty) {
event.preventDefault();
return (event.returnValue = "You have unsaved changes. Are you sure you want to leave?");
}
},
[isDirty]
)
);
// Handle navigation with save bar confirmation
const handleNavigation = useCallback(async (url: string) => {
if (isDirty) {
try {
// This makes the save bar shake and blocks navigation
if (app && typeof (app as any).saveBar?.leaveConfirmation === 'function') {
await (app as any).saveBar.leaveConfirmation();
navigate(url);
} else {
console.log('Navigation blocked - save bar will handle it');
}
} catch (error) {
console.log('Navigation prevented by save bar');
}
} else {
navigate(url);
}
}, [isDirty, navigate, app]);
return { handleNavigation };
}
const { handleNavigation } = useShopifyContextualSaveBar({
isDirty,
onSave: handleSave,
onDiscard: handleDiscard,
saveBarRef
});
newOriginalValues and then it's making the new originalValue the old version.originalState wrong. Add the useEffect to set originalState after everything loads in.newOriginalValuessaveBar.show() before originalValues is set.