← Back to Blog

Shopify Contextual Save Bar: Proper Implementation

2025-11-26

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

Shopify Contextual Save Bar: Proper Implementation

Shopify Contextual Save Bar: Proper Implementation

The contextual save bar is very annoying if done wrong.

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.

The issue:

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.

The solution:

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.

TL;DR: Just use this code:

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>
    );
}


So starting off

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>

Explanation:

  • saveBarRef sets up the .show(), .hide(), and .leaveConfirmation() methods
  • id doesn't really matter, you can get rid of it if you want
  • discardConfirmation doesn't work with this
  • The buttons are just specifying what they're actually calling.

Button functionality:

The 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;

NOTE:

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.

Optional but useful:

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.

So what do you do when you save or discard?

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.

How you block navigation buttons if there are unsaved changes?

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 };
}

And this is how you call it:

const { handleNavigation } = useShopifyContextualSaveBar({
    isDirty,
    onSave: handleSave,
    onDiscard: handleDiscard,
    saveBarRef
});

Common issues and their fixes:

The save bar goes away but then immediately comes back

  • you might be refreshing your variables when you call your action function so it's getting the old variable from the database for newOriginalValues and then it's making the new originalValue the old version.
  • you might have a default value in your database or code somewhere so if you save a blank field, it'll save but then it fills the value in and then it doesn't match the saved state.

The save bar shows up immediately without you changing anything

  • You're setting the originalState wrong. Add the useEffect to set originalState after everything loads in.

The save bar doesn't go away after saving

  • add the variable to newOriginalValues

The save bar flashes very briefly on every page load

  • You’re calling saveBar.show() before originalValues is set.