Import
The import wizard guides users through uploading one or more files, mapping columns to your schema, and resolving value mismatches. Rows then enter the grid, where users clean and edit them before submission.
importFormats
Section titled “importFormats”| Type | DataEditorFormat[] | false |
| Default | All formats |
Which file formats the user can import. Set to false to disable import entirely.
importFormats={["csv", "xlsx"]}DataEditorFormat
Section titled “DataEditorFormat”type DataEditorFormat = "csv" | "tsv" | "xlsx" | "json" | "xml";Shared with exportFormats. Each value maps to one or more file types:
csv:.csvtsv:.tsvxlsx: the spreadsheet token. On import it accepts.xlsx,.xls(legacy Excel),.xlsb, and.ods; on export it writes.xlsx.json:.jsonxml:.xml
remoteSources
Section titled “remoteSources”| Type | RemoteSource[] |
Custom data sources rendered as buttons on the upload step. You own the integration: auth, pickers, downloads. The SDK calls your fetch function and processes the result.
Return a File object to go through the standard parse pipeline (CSV/XLSX/etc.), or return structured records to skip parsing entirely.
RemoteSource
Section titled “RemoteSource”type RemoteSource = { id: string; label: string; icon: string; description?: string; fetch: () => Promise<File | Record<string, unknown>[]>;};remoteSources={[ { id: "google-sheets", label: "Google Sheets", icon: "<svg>...</svg>", fetch: async () => { const data = await myGoogleSheetsLib.pick(); return data.rows; }, },]}customFormats
Section titled “customFormats”| Type | CustomImportFormat[] |
File formats the SDK cannot read, handled by your own code. The SDK accepts the
file, hands it to your handle function, and stages whatever comes back as
ordinary preview cards.
CustomImportFormat
Section titled “CustomImportFormat”type CustomImportTable = { name: string; rows: Record<string, unknown>[];};
type CustomImportFormat = { label: string; extensions: string[]; mimeTypes?: string[]; handle: ( file: File, ctx: { signal: AbortSignal }, ) => Promise<File | Record<string, unknown>[] | CustomImportTable[]>;};customFormats={[ { label: "PDF", extensions: [".pdf"], handle: async (file, { signal }) => { const body = new FormData(); body.append("file", file); const res = await fetch("/api/extract", { method: "POST", body, signal }); return res.json(); }, },]}What handle returns
Section titled “What handle returns”Record<string, unknown>[]stages one table under the file’s name. The keys become headers in first-seen order across the whole array, and a missing key reads as an empty cell.CustomImportTable[]stages several tables from one file, each under its own name. Every table gets its own card and its own column-matching screen, so an invoice can yield line items and totals separately.Filegoes through the built-in parser, so a handler can hand back CSV, XLSX, JSON, or XML. The card carries the returned file’s name. A returned file never reaches a handler again, so a.pdfhandler that returns a.pdfreads it once.
Matching a file to a handler
Section titled “Matching a file to a handler”extensions matches the end of the file name, case-insensitively, and needs the
leading dot. [".pdf"] matches Invoice.PDF, and ["pdf"] matches nothing.
mimeTypes matches the browser’s file.type, exactly or by wildcard.
["image/*"] claims every image the OS labels, whatever the extension.
{ label: "Image", extensions: [], mimeTypes: ["image/*"], handle: scanImage }A format with an empty extensions list matches on MIME alone. A file goes to
the first format that claims it, so when two entries overlap the earlier one
wins. Files no format claims take the built-in path unchanged.
importFormats: false turns import off completely, custom handlers with it.
Running, cancelling, failing
Section titled “Running, cancelling, failing”Handlers run concurrently and off the built-in parse queue, so the rest of a batch keeps reading while a slow extraction works. That file’s card stays pending, and Next stays disabled until every card is ready.
The user can cancel a pending card with the cross in its corner. Cancelling
aborts ctx.signal, and a result that arrives afterwards is discarded. Pass the
signal to fetch, or check signal.aborted between steps, so the work itself
stops.
Throwing fails the file. The thrown message is shown to the user verbatim, so throw text a user can act on, and wrap a low-level error to keep an internal message off the screen.
handle: async (file, { signal }) => { try { return await extract(file, signal); } catch (cause) { throw new Error("This scan is too blurry to read. Try a clearer copy."); }}A throw with no message falls back to “Could not process the file”. Every failure
also reaches onError as a PARSE_ERROR with
source: "custom-handler". An empty array is a separate outcome. The file was
read and held no table, and the user is told that no tables were found.
Where the file goes
Section titled “Where the file goes”The file travels from the user’s browser to wherever your handler sends it, under your own processing agreement. Nothing about it passes through Updog infrastructure, and Updog stores nothing.
onUnstructuredFile
Section titled “onUnstructuredFile”Some files read fine and still hold no single table. A report wraps its data in a
banner and a footer, one sheet carries two tables side by side, a roster is laid
out as a document. onUnstructuredFile hands that file to your own code, which
reads it however it likes and gives back tables.
onUnstructuredFile={async (file, { signal }) => { const body = new FormData(); body.append("file", file); const res = await fetch("/api/read-tables", { method: "POST", body, signal }); return res.json(); // CustomImportTable[]}}You get the File and the abort signal, and you return what a customFormats
handler returns, so rows, named tables, or a File in a format Updog already
reads. Named tables fit a sheet holding more than one. Each becomes its own card
with its own checkbox, and the user drops the ones they do not want.
When it fires
Section titled “When it fires”Updog scores every sheet it reads for how safely it reads as one rectangular table. A sheet that scores badly triggers one call carrying the whole file, whichever sheet it was. The scoring runs only when this prop is set.
The measure is internal and moves as it meets more real files, so build against what the handler receives.
Declining
Section titled “Declining”Return null and the file stages the ordinary way, exactly as if the prop were
absent. A throw, an empty result, and anything slower than 30 seconds all land
there too. The user sees an ordinary card either way and is never told a call
happened.
Cancelling the card aborts ctx.signal, and a result arriving afterwards is
discarded.
Where the file goes
Section titled “Where the file goes”The same place a customFormats file goes. From the user’s browser to wherever
your handler sends it, under your own processing agreement. Nothing about it
passes through Updog infrastructure, and Updog stores nothing.
Files that reach this handler are the messy ones, and messy spreadsheets hold names, pay rates, and shift patterns. Whoever reads them on your side is reading that, so tell your users where their file goes.
Choosing what to import
Section titled “Choosing what to import”The upload step takes several files at once. The picker allows a multiple selection, and the drop zone accepts a whole batch. Files parse one at a time in drop order, each showing a placeholder card while it is read.
A file that parses becomes a card with its name, its row count, and a checkbox. A multi-sheet workbook fans out to one card per sheet, each labelled with its sheet name and the file it came from. Cards start checked, except a file or sheet with no data rows, which starts unchecked. More files can be added from the card grid at any time, and Start over clears the whole selection.
Only checked cards go on to the rest of the wizard, so unchecking a card before Next is how a user drops a file they picked by mistake.
A file that fails to parse, whether corrupt, unreadable, or too large, never
produces a card, and the remaining files in the batch keep parsing. One bad file
does not cost the user the rest of the upload. The SDK reports every failure
through onError as a PARSE_ERROR, and a
file rejected for its size also shows the user a toast.
Header detection
Section titled “Header detection”Updog inspects the first rows of each file to decide which row holds the column headers, looking at where each column’s values switch from text labels to a consistent data type (numbers, dates, booleans). A title or metadata row above the real header is skipped automatically.
When detection is not confident about a file, the wizard adds a header-selection step where the user picks the header row from a preview. With several uploaded files, each uncertain file gets its own screen in upload order, and Next moves to the next one; files whose header was detected confidently are skipped entirely.
On the column-matching step, Change header row reopens header selection for the file being matched, including a file whose header was detected confidently and whose screen was skipped. Choosing a different row matches the columns again, and any column you mapped by hand keeps your choice when its header reads the same on the new row.
Files without a header row
Section titled “Files without a header row”When a file contains only data and no detectable header (for example, a raw
export whose first row is already values), Updog generates placeholder column
names (Column 1, Column 2, …) and treats every row as data, so nothing is
dropped. You then map those columns to your schema in the matching step using
the previewed values.
Several files in one import
Section titled “Several files in one import”Users can upload several files at once. Each file becomes its own unit, and a multi-sheet workbook adds one unit per selected sheet. Column matching repeats per unit, so the same schema column can be fed by a differently named header in each file.
The later steps are shared. Value matching runs once and collects values per
schema column across every file. A select column fed by Work in one file and
Company in another shows one merged list. The primary key is a single choice
applied to every file. Its step appears when there is something to merge into,
either rows already in the grid or a second file, and there is a key to offer,
either a mapped unique column or a multi-column primaryKey whose every part
is mapped. A multi-column key appears as one choice named after its columns.
Pick No primary key to append every row instead of merging.
Each unit lands as its own data source. A whole file takes the file name, a
sheet takes workbook.xlsx - Sheet name.
When an import fails partway
Section titled “When an import fails partway”Files import one after another. If one of them fails, the wizard stays open with every mapping the user made still in place, so a retry starts from those mappings. The files that already landed keep their rows in the grid and are skipped by the retry, so nothing is imported twice. The retry resumes with the file that failed and the ones queued behind it.
Cancelling closes the wizard and drops whatever had not been imported yet.
onColumnMatch
Section titled “onColumnMatch”| Type | (headers: string[], columns: DataEditorColumn[]) => Record<string, string | null> | Promise<...> |
Override column matching during import. Called when the user first reaches the column-matching step for each file, with that file’s headers and your column definitions. Return a map of { csvHeader: columnId | null }. Entries set to null or omitted fall back to built-in matching. When the callback throws or takes longer than 30 seconds, the SDK uses built-in matching instead. Uploading a different file or changing the header row re-invokes it on the next visit to the step; navigating back and forth between steps does not. With several files, each file gets its own matching screen and its own mapping, so the same header name can map to different columns in different files.
onColumnMatch={async (headers, columns) => { // Call your AI or matching service const mappings = await myMatchingService.match(headers, columns); return mappings;}}onValueMatch
Section titled “onValueMatch”| Type | (valuesToMatch: Record<string, ValueMatchInput>) => ValueMatchOutput | Promise<ValueMatchOutput> |
Override value matching during import for select columns. Called once, when the user first reaches the value-matching step, with all select columns’ imported values and allowed options. It runs once for the whole upload, however many files there are. The SDK collects a select column’s values across every file, so a column fed by Work in one file and Company in another arrives as one merged list.
When the callback throws or takes longer than 30 seconds, the SDK uses built-in fuzzy matching instead. Your returned matches persist across later column-mapping edits, and navigating back and forth between steps leaves the callback alone. Adding or removing a file re-invokes it on the next visit to the step, and the matches it already returned stay in place if that second call fails. Changing a file’s header row discards the matches the callback returned and falls back to built-in fuzzy matching.
type ValueMatchInput = { importedValues: string[]; options: string[];};
// Return: { columnId: { importedValue: optionValue | null } }type ValueMatchOutput = Record<string, Record<string, string | null>>;Values set to null skip auto-matching. Unmapped values fall back to built-in fuzzy matching.
onValueMatch={async (valuesToMatch) => { // valuesToMatch = { country: { importedValues: ["espana", "fr"], options: ["Spain", "France"] } } return { country: { espana: "Spain", fr: "France" }, };}}In the value-matching step a select value is imported only when it is mapped, and values left unmatched are dropped. By default (enableCustomValue true) the user keeps an off-list value by creating a new option for it, and created options persist on the column. An off-list value becomes an option only when someone creates it. Your validators decide whether the result is valid. Set enableCustomValue: false on the column’s select editor to turn inline option creation off, leaving a strict closed enum that maps only to existing options.
Multiselect delimiter
Section titled “Multiselect delimiter”A multiselect column stores a string[], so a raw cell holding several values is split into tokens before matching. The SDK auto-detects the separator among ,, ;, |, newline, and tab by checking which one splits cells into tokens that resemble your options (exact or fuzzy). Each resulting token then flows through value matching like a single select value. The value-matching step shows the detected separator and lets the user override it.
Detection needs the file’s tokens to resemble your options. When the two vocabularies share nothing (file values red, green against option codes R/G/B), set the column’s delimiter explicitly so splitting stops depending on detection. A cell that is itself a whole option containing the delimiter (option "Smith, Jr") is never split.
The SDK also recovers a cell that uses a different separator from the rest of the column, such as a stray ; where the column is comma-separated. A token that is no option on its own, but splits on another candidate separator into pieces that each resemble an option, is split further. Recovery holds a piece to the same standard value matching uses, so a piece written slightly differently from the option it means (Penicillin against the option Penicillins) keeps the split and reaches value matching as its own token. A token whose pieces resemble nothing stays intact, so a value that merely contains a separator survives.
Formats the importer reads
Section titled “Formats the importer reads”A date column is read by whichever shape explains the most of its values.
| Written as | Example |
|---|---|
| ISO | 2026-05-01, 2026-5-1 |
| Year first with a slash or a dot | 2026/05/01, 2026.05.01 |
| Day or month first with a slash, a dot, or a dash | 01/05/2026, 01.05.2026, 01-05-2026 |
| Two digit year | 01/05/26, 1/5/26 |
| Digits only | 20260501 |
| Month as a word | 1 May 2026, May 1, 2026, 01-May-2026, May-01-2026 |
| Month as a word in the editor locale | 1 мая 2026 |
| Japanese | 2026年5月1日 |
| An Excel serial number | 46143 |
A time after the date is dropped, so 2026-05-01T10:30:00Z and 2026-05-01 10:30:00 both store the day. A two digit year from 00 to 68 reads as the 2000s and from 69 to 99 as the 1900s. A serial number is taken only when every value of the column is one, because a lone number is far more often an ID.
A number column is read the same way, by whichever pair of separators explains the most of its values.
| Written as | Example |
|---|---|
| Point decimal, comma grouping | 1,234.56 |
| Comma decimal, point grouping | 1.234,56 |
| Space grouping | 1 234,56, 1 234.56 |
| Apostrophe grouping | 1'234.56, 1'234,56 |
| Lakh grouping | 12,34,567.89 |
| No grouping | 1234.56, 1234,56 |
A currency symbol, a percent sign, and accounting parentheses around the digits are dropped, and a percent keeps its magnitude, so 45% stores 45. A letter coded currency such as USD or zł is kept, because letters are ambiguous and stripping them would turn an ID into a number.
A column whose values never separate two shapes falls to the browser locale. When the tied shapes would read the column apart, the SDK reports it through onError so you can see which one it applied. A column of plain integers ties every pair of separators and every one of them gives the same number back, so nothing is reported.
The SDK reads a time column the same way.
| Written as | Example |
|---|---|
| Hours and minutes | 14:30, 9:05 |
| With seconds | 14:30:05, 9:05:07 |
| With a fraction of a second | 14:30:05.250, 14:30:05,250 |
| On a 12-hour clock | 2:30 PM, 2:30 p.m., 2.30pm, 2:30:05 PM |
The SDK drops a date in front of the time and an offset behind it, so 2026-05-01T14:30Z and 14:30+02:00 both store 14:30. A time of day never moves into another zone, so what stays is the time on the wall. Nothing reads a lettered zone name such as 2:30 PM PST, and nothing reads 25:00 or 09:60. The SDK stores 24:00 as the end of the day.
synonyms
Section titled “synonyms”| Type | { columns?: Record<string, string[]>; values?: Record<string, string[]> } |
Extra synonyms layered on top of the built-ins, in two tables that stay apart. columns feeds column matching (header against column), values feeds value matching (imported value against select option). Each key is the canonical target, either a column ID or title, or a select option value. The array lists aliases the matching engine should treat as equivalent. The SDK merges your entries with the built-ins as a union per key, so your list extends the built-in one.
synonyms={{ columns: { // incoming header → column productSku: ["sku", "article_no", "item_code"], firstName: ["first", "given_name", "fname"], }, values: { // incoming cell value → select option Active: ["live", "enabled"], },}}Either half may be omitted. Keeping the two apart stops an alias learned inside a dropdown from competing for a column, and vice versa.
Built-in value synonyms already cover common categorical vocabularies (gender M/F, seniority Jr/Sr, employment type FT/PT, status, marital status, priority, yes/no), so abbreviations like M, Jr, or FT auto-map without any configuration.
Remembering matches across imports
Section titled “Remembering matches across imports”Updog stores nothing on a server. To make repeat imports auto-match the headers and values your users already mapped once, persist what they mapped and feed it back through synonyms. Every onComplete result carries learnedSynonyms: { source, target } mappings the user changed by hand, split into columns and values.
Record which list a row came from — the two sides go back in as two tables.
<DataEditor synonyms={storedSynonyms} onComplete={async (result) => { const { columns, values } = result.learnedSynonyms; await saveSynonyms([ ...columns.map((pair) => ({ ...pair, side: "columns" })), ...values.map((pair) => ({ ...pair, side: "values" })), ]); await saveRows(result.sources); }}/>The synonyms prop groups aliases under each canonical, so on the next mount turn your stored rows into that shape:
const synonyms = { columns: {}, values: {} };for (const { side, source, target } of storedRows) { (synonyms[side][target] ??= []).push(source);}A pair you have already fed back stops appearing in learnedSynonyms, because the matcher now produces it on its own. Each row reaches you once.
enableCreateColumn
Section titled “enableCreateColumn”| Type | boolean |
| Default | true |
Allow creating new columns for unmatched headers during import. When enabled, a user keeps the data from a file column that matches nothing in your schema by creating a column for it during the import.
