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
Why does this happen? Well, the
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 thevalueprop did work,doesn't have a hiddenproperty, 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 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:
- 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
useStateoruseReducerin Preact/React). Each row in your dynamic table, for example, would be an object in an array within this central state. - 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
isDirtyflag 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 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:

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