Cracking the Code: Dynamic Forms & the Elusive Save Bar in Shopify Admin UI Extensions
Hey there, fellow store owners and developers! As someone who spends a lot of time poring over community discussions, I often come across fascinating challenges that really dig into the nitty-gritty of building on Shopify. Recently, a thread caught my eye, titled "Managing dirty state in Shopify Admin UI Extension forms." It perfectly illustrates a common headache when you're trying to build something truly dynamic and custom within the Shopify Admin.
Our developer friend, briniselyes, was wrestling with a tricky problem: building an interactive table within a Shopify Admin UI Extension. Think about it – a table where merchants can add or remove rows and columns, edit values, and generally manage complex tabular data for something like metaobjects. The goal was to use Shopify's built-in component, which usually does a fantastic job of automatically showing that familiar "Save" bar at the bottom whenever a field is changed.
The Disappearing Save Bar: A Head-Scratcher
Here's where the problem started: while typing into an within the table would correctly trigger the save bar, things went south when rows or columns were *removed*. Even though the underlying data clearly changed, the save bar would just vanish! This is because each table cell was an , and when a row or column was removed, those input elements were taken out of the DOM. The , which tracks its dirty state based on these visible DOM inputs, lost its reference and decided nothing was "dirty" anymore.
briniselyes tried a few clever workarounds, but they hit roadblocks:
- Dispatching events on
: Programmatically faking an input or change event didn't trigger the save bar. Plus,doesn't have ahiddenproperty, so any field used to trigger the bar would always be visible. - Using native
: Same dispatching approach, but with a standard HTML hidden input. This also failed, and critically, native HTML elements likearen't properly rendered by Preact in the Admin UI Extension context – only Shopify'ss-*web components are.
This highlights a key architectural point for Admin UI Extensions: they run in a sandboxed worker with a remote DOM. This means you can't just throw arbitrary styles or native HTML elements at them and expect them to behave like a standard web page. Our community expert, Ecom_swift_LLC, confirmed this, explaining that arbitrary style attributes and CSS are often stripped, making visual hiding with CSS (like display: none) ineffective for s-* components.
Here's an image of the table with dummy data that briniselyes shared:

The Right Way to Detect Dirty State: State as the Source of Truth
So, if the DOM isn't reliable for tracking dirty state in dynamic forms, what's the solution? Ecom_swift_LLC provided some excellent guidance: stop treating the DOM as your primary source of truth for your data.
Instead, embrace a robust state management pattern:
-
Centralize Your Form State: Keep the entire form's data in a single state object (using
useStateoruseReducerin your parent component). This object should hold all the values for your rows and columns, keyed by a unique ID. -
Pure Output Rows: When you render your table, the rows and cells become "pure output" of this centralized state. If a row is conceptually removed, you update your state object, and the UI re-renders based on that state, not by directly manipulating DOM nodes in a way that
expects for tracking. -
Snapshot Comparison for Dirty Tracking: To reliably detect if your data has changed (is "dirty"), maintain a second "snapshot" of your state object. This snapshot should be taken when the form initially loads and after every successful save. Then, to check if the form is dirty, simply compare your current state object to this snapshot. This approach works perfectly even if rows or columns are added, removed, or reordered, because the comparison is happening at the data level, not the DOM level.
This programmatic approach to dirty state detection is indeed the gold standard for complex forms, especially in environments like Admin UI Extensions where direct DOM manipulation for state tracking is limited.
The Unanswered Question: Triggering the Save Bar
However, even with this robust method for *detecting* dirty state, briniselyes hit another wall: "How do I actually show the save bar based on my custom isDirty flag?" The component, as currently implemented in app-home UI Extensions (non-iframe), relies on its own internal DOM tracking. The shopify.saveBar.show() API is only available for iframe-based app-home extensions, which briniselyes wanted to avoid.
This is where the community discussion points to a fundamental challenge: while we can perfectly manage and detect dirty state programmatically, the Admin UI Extension's component doesn't offer a direct, public API to manually trigger its save bar based on an external isDirty flag when elements are dynamically removed from the DOM. This thread actually concludes with this specific problem still open, prompting the conversation to move to the more specialized Shopify Developer Community Forum – a great reminder from PaulNewton that for deep technical issues, it's crucial to be in the right forum with the right audience.
The original code shared by briniselyes, which gives a good look at the setup:
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>×
))}
)}
);
}
So, while the community provided a clear path to *detecting* changes in dynamic forms within Shopify Admin UI Extensions, the challenge of *programmatically triggering* the save bar when elements are removed from the DOM remains a nuanced topic. It's a testament to the evolving nature of app development on Shopify, and why staying engaged with the developer community forums is so vital for finding solutions and sharing insights on these kinds of specific technical hurdles.