Mastering Dirty State: Keeping Your Shopify Admin UI Extension Forms and Save Bar in Sync

Hey there, fellow store owners and app developers! As someone who spends a lot of time diving into the Shopify ecosystem, I often see fascinating discussions pop up in the community forums. Recently, a thread caught my eye that really hit home for anyone building custom interfaces within the Shopify Admin: managing “dirty state” in Admin UI Extension forms, especially when dealing with dynamic content like tables.

The original post by briniselyes described a common headache: they were building an embedded app using Admin UI Extensions (specifically the app-home surface) with , , and other Polaris-style web components. Their app let users manage metaobjects with structured tabular data, allowing them to add/remove rows and columns, and edit cell values inline. The problem? The built-in save bar, which usually appears automatically when you type into an , would vanish when rows or columns were removed from the table.

Why does this happen? Well, the component tracks whether your form has unsaved changes (its “dirty state”) by keeping an eye on the DOM. When you remove a row or column, you’re essentially removing the underlying elements from the DOM. When those elements disappear, the form loses its tracking reference, assumes nothing’s changed, and poof! The save bar is gone, even though your data is definitely different.

What Didn't Quite Work (and Why)

Briniselyes shared a few things they tried that didn't solve the issue:

  • Programmatically dispatching events on : This didn't trigger the save bar. While changing the value prop did work, doesn't have a hidden property, so the field would always be visible, which wasn't ideal for tracking hidden changes.
  • Using native HTML : Same issue with event dispatching, and crucially, native HTML elements like aren't rendered by Preact in the Admin UI extension context. Only the s-* web components are.
  • Multiple wrappers: This didn't address the core dirty state detection problem.

There was also a suggestion from websensepro about "soft deletes" – marking items as _isDeleted: true and hiding them with CSS (display: none) instead of removing them from the DOM. This would theoretically keep the elements mounted, preserving dirty state tracking. However, as Ecom_swift_LLC later clarified, Admin UI extensions run in a sandboxed worker with a remote DOM. This means arbitrary style attributes and CSS are often stripped, rendering display: none ineffective for s-* components. Briniselyes also confirmed this in their own testing, noting that inline styles don’t work on Polaris web components in this context.

The Right Way to Manage State (and the Remaining Challenge)

This is where Ecom_swift_LLC provided some really solid advice that forms the foundation of robust UI extension development:

  1. Stop treating the DOM as the source of truth. Instead, manage your entire form's state in a single JavaScript object within your parent component (using something like useState or useReducer in Preact/React). Each row in your dynamic table, for example, would be an object in an array within this central state.
  2. For dirty tracking, keep a second snapshot of that state object. Take this snapshot when the form loads and after each successful save. Then, to determine if your form is "dirty," simply compare your current state object to this snapshot. This gives you a reliable isDirty flag that survives conditional rendering, additions, and removals, because the values live in your component's state, not solely the DOM.

This approach is fantastic for detecting changes accurately. However, briniselyes then asked the critical follow-up: "How do I actually show the save bar based on my custom isDirty flag?" The component still relies on its internal DOM tracking, and the shopify.saveBar.show() API isn't available on the non-iframe app-home UI extension surface. This is the tricky part that the thread didn't fully resolve within the merchant community forum.

Location, Location, Location!

This brings us to a super important point raised by PaulNewton right at the start: the importance of posting technical questions in the right place. Paul rightly pointed out that the original Shopify Community Forum is primarily for merchants. For deep-dive developer issues like this, the Shopify Developer Community Forums is where you'll find a crowd of experts specifically focused on APIs, extensions, and complex development challenges. Briniselyes did eventually move the discussion there, which was the right move.

The full code example briniselyes provided illustrates the complexity of managing dynamic table data within a UI extension:

import {useState, useEffect, useRef} from 'preact/hooks';
import {fetchCustomisation, updateCustomisation, listCustomisations} from '../../../../shared/models/customisation';
import type {CustomisationSummary} from '../../../../shared/models/customisation';
import {gidToId} from '../../../../shared/utils/gid';

interface Row {
    [key: string]: string;
}

export default function TestSaveBarPage({id: initialId}: { id?: string }) {
    const [customisations, setCustomisations] = useState([]);
    const [selectedId, setSelectedId] = useState(initialId);
    const [columns, setColumns] = useState([]);
    const [rows, setRows] = useState([]);
    const initialColumns = useRef([]);
    const initialRows = useRef([]);
    const [status, setStatus] = useState(initialId ? 'loading' : 'idle');
    const [error, setError] = useState(null);

    useEffect(() => {
        listCustomisations().then(setCustomisations).catch(() => {});
    }, []);

    useEffect(() => {
        if (!selectedId) {
            setColumns([]);
            setRows([]);
            initialColumns.current = [];
            initialRows.current = [];
            return;
        }
        setStatus('loading');
        fetchCustomisation(selectedId).then((c) => {
            const predefined: Row[] = c.predefinedValues || [];
            const cols = predefined.length > 0
                ? Object.keys(predefined[0])
                : [];
            setColumns(cols);
            setRows(predefined);
            initialColumns.current = cols;
            initialRows.current = predefined;
            setStatus('idle');
        }).catch((e: unknown) => {
            setError((e as Error).message || 'Fehler beim Laden');
            setStatus('idle');
        });
    }, [selectedId]);

    const handleCellChange = (rowIndex: number, col: string, value: string) => {
        setRows((prev) =>
            prev.map((row, i) => (i === rowIndex ? {...row, [col]: value} : row))
        );
    };

    const addRow = () => {
        const emptyRow: Row = {};
        for (const col of columns) {
            emptyRow[col] = '';
        }
        setRows((prev) => [...prev, emptyRow]);
    };

    const removeRow = (index: number) => {
        setRows((prev) => prev.filter((_, i) => i !== index));
    };

    const addColumn = () => {
        const name = `Spalte ${columns.length + 1}`;
        setColumns((prev) => [...prev, name]);
        setRows((prev) => prev.map((row) => ({...row, [name]: ''})));
    };

    const renameColumn = (oldName: string, newName: string) => {
        if (!newName.trim() || newName === oldName) return;
        if (columns.includes(newName)) return;
        setColumns((prev) => prev.map((c) => (c === oldName ? newName : c)));
        setRows((prev) => prev.map((row) => {
            const updated: Row = {};
            for (const col of columns) {
                if (col === oldName) {
                    updated[newName] = row[oldName] || '';
                } else {
                    updated[col] = row[col] || '';
                }
            }
            return updated;
        }));
    };

    const removeColumn = (col: string) => {
        setColumns((prev) => prev.filter((c) => c !== col));
        setRows((prev) => prev.map((row) => {
            const updated: Row = {};
            for (const c of columns) {
                if (c !== col) {
                    updated[c] = row[c] || '';
                }
            }
            return updated;
        }));
    };

    const handleSave = async () => {
        if (!selectedId) return;
        setStatus('saving');
        setError(null);
        try {
            const current = await fetchCustomisation(selectedId);
            await updateCustomisation(selectedId, {
                ...current,
                predefinedValues: rows,
            });
            initialColumns.current = [...columns];
            initialRows.current = rows.map((r) => ({...r}));
        } catch (e: unknown) {
            setError((e as Error).message || 'Fehler beim Speichern');
        } finally {
            setStatus('idle');
        }
    };

    const handleReset = () => {
        setColumns([...initialColumns.current]);
        setRows(initialRows.current.map((r) => ({...r})));
    };

    if (status === 'loading') {
        return 
            Laden...
        ;
    }

    return (
        
            Produktarten

            {error && (
                
                    {error}
                
            )}

            
                 setSelectedId((e.target as HTMLSelectElement).value || undefined)}
                >
                    — Produktart wählen —
                    {customisations.map((c) => (
                        {c.name}
                    ))}
                
            

            
                
                    
                        {columns.length === 0 && rows.length === 0 && (
                            Keine Spalten vorhanden. Fügen Sie eine Spalte hinzu, um zu beginnen.
                        )}

                        {columns.length > 0 && (
                            
                                
                                    {columns.map((col, colIndex) => (
                                        {col}
                                    ))}
                                    Aktionen
                                
                                
                                    {rows.map((row, rowIndex) => (
                                        
                                            {columns.map((col) => (
                                                
                                                     handleCellChange(rowIndex, col, (e.target as HTMLInputElement).value)}
                                                    />
                                                
                                            ))}
                                            
                                                 removeRow(rowIndex)} t>
                                                    Entfernen
                                                
                                            
                                        
                                    ))}
                                
                            
                        )}

                        
                            Zeile hinzufügen
                            Spalte hinzufügen
                        

                        {columns.length > 0 && (
                            
                                Spalten verwalten
                                {columns.map((col) => (
                                    
                                         renameColumn(col, (e.target as HTMLInputElement).value)}
                                        />
                                         removeColumn(col)} t>×
                                    
                                ))}
                            
                        )}
                    
                
            
        
    );
}

And here’s an image that shows the table in action, giving a visual context to the challenge:

shopify

So, what's the ultimate takeaway here for merchants and developers? If you're building complex, dynamic forms within Admin UI Extensions, especially those that add or remove elements, remember these key points:

  • State management is paramount: Centralize your form data in your component's state and use snapshots for reliable dirty tracking.
  • Understand UI Extension limitations: The sandboxed environment and remote DOM mean standard HTML tricks (like hidden inputs or arbitrary CSS for s-* components) might not behave as you expect.
  • Choose the right forum: For highly technical development queries, the Shopify Developer Community Forums are your best bet for finding specialized answers and platform-specific insights.

While the exact programmatic solution for forcing the save bar to appear in app-home UI extensions for dynamic changes wasn't fully delivered in this specific thread, the discussion provided crucial insights into the underlying mechanisms and best practices for state management. It's a great example of how the community helps us navigate the nuances of building powerful apps on Shopify. And remember, if you're ever thinking about starting your own Shopify store or migrating to this robust platform, it's a fantastic choice for building a strong online presence. You can explore Shopify and get started here.

Share:

Start with the tools

Explore migration tools

See options, compare methods, and pick the path that fits your store.

Explore migration tools