Skip to content

Submitting

When the user clicks Submit, Updog hands you the edited dataset grouped by source. You filter on the per-row flags and decide what each row becomes: an INSERT, an UPDATE, a DELETE, or nothing.

Type (result: DataEditorResult<TRow>) => void | Promise<void>
type DataEditorResult<TRow> = {
sources: DataEditorSourceResult<TRow>[];
counts: {
new: number;
changed: number;
deleted: number;
invalid: number;
};
learnedSynonyms: LearnedSynonyms;
};
type LearnedSynonym = {
source: string; // the imported header or cell value
target: string; // the canonical it was mapped to (column title or option)
};
type LearnedSynonyms = {
columns: LearnedSynonym[]; // header → column matches
values: LearnedSynonym[]; // cell value → option matches
};

counts is an aggregate across every source.

learnedSynonyms holds the mappings the user changed by hand in the wizard, split into columns and values. Each entry is one 1:1 mapping, ready to store as a row. Both lists are [] when nothing new was learned.

Four kinds of mapping stay out of it:

  • mappings the SDK matched on its own, which it will match again next time
  • mappings returned by onColumnMatch or onValueMatch, since that answer already lives in your code
  • pairs the built-in table or your own synonyms already covers
  • everything from an import the user cancelled

Persist them and feed them back through synonyms next time so repeat imports auto-match. See Remembering matches across imports.

type DataEditorSourceResult<TRow> = {
sourceId: string;
sourceName: string;
rows: ResultRow<TRow>[];
};

One entry per source. A backend row is omitted when nothing about it actually changed, because writing it back would be a no-op. That covers rows the user left alone and undeleted, and rows an import matched on the primary key and rewrote with the values they already held.

An import contributes one entry per file, plus one per sheet when a multi-sheet workbook is imported (sourceName is then workbook.xlsx - Sheet name). A user who uploads three files in one pass produces three entries, so expect result.sources to hold several import entries alongside whatever you tagged through loadData. See Several files in one import.

type ResultRow<TRow> = {
row: TRow;
isNew: boolean;
isChanged: boolean;
isDeleted: boolean;
isValid: boolean;
};

The flags are independent. A single row can be new, changed, and deleted at once.

isChanged compares values, not the way they are written. An empty cell that stayed empty is not a change, whichever form the emptiness took — an empty string, null, a missing field, or a multiselect with nothing chosen. A number and its text form are the same value too, so 1 rewritten as "1" leaves the row unchanged. Text that reads "null" stays ordinary text.

The row you get back carries one key per column in your schema. A column your user’s file never held arrives as an empty cell — "", or [] for a multiselect — rather than as a missing field, so Object.keys(row) is the same set for every row in the result. Values you loaded yourself come back as you wrote them, null included.

Updog leaves the rows ungrouped because routing depends on your backend. Some clients upsert, some reject invalid rows, some keep deletions for audit.

onComplete={async (result) => {
for (const source of result.sources) {
const inserts = source.rows.filter(r => r.isNew && !r.isDeleted && r.isValid);
const updates = source.rows.filter(r => !r.isNew && r.isChanged && !r.isDeleted && r.isValid);
const deletes = source.rows.filter(r => r.isDeleted && !r.isNew);
await persist(source.sourceId, { inserts, updates, deletes });
}
}}

If you never tagged sources through loadData, you get one entry with sourceId: "backend".

An export writes the stored form, not what the grid paints. A number leaves as plain digits with a dot for decimals and no grouping; a date leaves as ISO YYYY-MM-DD. JSON and XLSX carry a number column’s value typed, so a machine on the other end reads a number rather than re-parsing a locale. A value the SDK flags leaves as the raw text the user still has to fix.

column.formatter applies to the grid, so an export skips it: a column storing 1200 behind a (v) => "$" + v formatter writes 1200.