
How to Build a CSV Column Mapping UI in React
A depot keeps sending the fleet team its vehicle list, 214 vehicles over seven columns. Your app holds seven fields for a vehicle, and three of those columns reach a field by name alone.
| A | B | C | D | E | F | G | |
|---|---|---|---|---|---|---|---|
| 1 | Vehicle # | Reg | Make/Model | Odo | In Service | Home Depot | Home Depot |
| 2 | FL-0001 | LS24 TBK | Mercedes Sprinter 315 | 21 759 | 08/11/2025 | East | DEP-E |
| 3 | FL-0002 | LS19 SOK | Renault Trafic SL28 | 15 217 | 29/01/2024 | East | DEP-E |
| 4 | FL-0003 | MK18 WVT | Renault Trafic SL28 | 229 850 | 06/08/2023 | East | DEP-E |
| 5 | FL-0004 | BX17 ESG | Ford Transit Custom 300 | 162 995 | 21/07/2023 | Wakefield | DEP-WF |
| 6 | FL-0005 | MK24 WHJ | Mercedes Sprinter 315 | 20 090 | 25/01/2025 | North | DEP-N |
| 7 | FL-0006 | SN16 LWY | Vauxhall Vivaro 2900 | 196 856 | 23/08/2022 | South | DEP-S |
| 8 | FL-0007 | LT25 SYU | Ford Transit Custom 300 | 192 785 | 31/07/2021 | Wakefield | DEP-WF |
| 9 | FL-0008 | MK25 LJV | Ford Transit Custom 300 | 187 511 | 08/05/2021 | East | DEP-E |
| 205 rows not shown | |||||||
| 215 | FL-0214 | YD22 NPZ | Vauxhall Vivaro 2900 | 88 402 | 14/03/2022 | South | DEP-S |
1Vehicle #,Reg,Make/Model,Odo,In Service,Home Depot,Home Depot2FL-0001,LS24 TBK,Mercedes Sprinter 315,21 759,08/11/2025,East,DEP-E3FL-0002,LS19 SOK,Renault Trafic SL28,15 217,29/01/2024,East,DEP-E4FL-0003,MK18 WVT,Renault Trafic SL28,229 850,06/08/2023,East,DEP-E5FL-0004,BX17 ESG,Ford Transit Custom 300,162 995,21/07/2023,Wakefield,DEP-WF6FL-0005,MK24 WHJ,Mercedes Sprinter 315,20 090,25/01/2025,North,DEP-N7FL-0006,SN16 LWY,Vauxhall Vivaro 2900,196 856,23/08/2022,South,DEP-S8FL-0007,LT25 SYU,Ford Transit Custom 300,192 785,31/07/2021,Wakefield,DEP-WF9FL-0008,MK25 LJV,Ford Transit Custom 300,187 511,08/05/2021,East,DEP-E⋮205 rows not shown215FL-0214,YD22 NPZ,Vauxhall Vivaro 2900,88 402,14/03/2022,South,DEP-SVehicle # is your assetTag. Reg is your plate. Make/Model is two of your fields inside one column. Odo is a kilometre reading with a space in it. The depot appears twice, once as a name and once as a code, under one header printed twice. Nothing in that file was written with your schema in mind, and nothing about it is unusual.
The screen that connects the two sides is the mapping step, and it is state before it is anything else. That state, the write path that keeps it honest, and the React row on top of them are what this guide builds. The React import guide covers getting the parsed rows in the first place.
Step 1. Write down the two sides
Your fields are fixed, they carry a label a person can read, and some of them are required.
export type Field = { id: string; label: string; required?: boolean;};
export const fields: Field[] = [ { id: "assetTag", label: "Asset tag", required: true }, { id: "plate", label: "Registration", required: true }, { id: "make", label: "Make" }, { id: "model", label: "Model" }, { id: "odometerKm", label: "Odometer (km)" }, { id: "inServiceDate", label: "In service", required: true }, { id: "depot", label: "Depot" },];The file side arrives from your parser as an array of header strings. Two of those strings are Home Depot. Any structure keyed by header text collapses them into one key, so the second column disappears before a person sees it. Give every column a distinct key first.
export const dedupeHeaders = (headers: string[]): string[] => { const used = new Set<string>(); return headers.map((header) => { let candidate = header; let n = 2; while (used.has(candidate)) { candidate = header + " (" + n + ")"; n++; } used.add(candidate); return candidate; });};The headers now read Home Depot and Home Depot (2). PapaParse 5.6.0 does the same thing on its own when you parse with header: true, renaming the repeat to Home Depot_1 and recording the pair in meta.renamedHeaders. Either way the rule holds. One file column, one key, and the suffix is visible to the person on the screen.
Step 2. Store the mapping as one relation
A mapping is a relation between two sets, and a person edits it one row at a time. One entry per file column, in file order, is the shape the screen already has.
export type Mapping = { /** One entry per file column, in file order. */ sourceColumn: string; /** A field id, or null while the column reaches nothing. */ targetField: string | null; /** What the matcher scored the pair, 0 to 100. */ confidence?: number;};
export type MappingState = Mapping[];targetField is null while a column reaches nothing, which is where every column starts. confidence is what the matcher scored the pair, and it exists so the screen can mark its own guesses apart from a person's answers.
Keying by file column is what makes the list renderable. Every entry is a row, unmapped columns hold their place in that list, and a field with no column stays a derived fact.
Step 3. Enforce one field per column on write
Two file columns cannot both fill depot. Left alone, the second one wins during the import and the first one is lost with no message. The place to settle that is the write path, so no caller can produce a state where a field is claimed twice.
export const assign = ( state: MappingState, sourceColumn: string, targetField: string | null,): MappingState => state.map((entry) => { if (entry.sourceColumn === sourceColumn) { return { sourceColumn, targetField, confidence: undefined }; } if (targetField !== null && entry.targetField === targetField) { return { ...entry, targetField: null, confidence: undefined }; } return entry; });Assigning a field takes it from whichever column held it. The row that lost it goes back to null and shows as unmapped on the next render.
before Home Depot → depot Home Depot (2) → (none)assign Home Depot (2) → depotafter Home Depot → (none) Home Depot (2) → depotThe rule you pick decides what the person sees, and two of the three keep one column per field.
| Rule | What the person sees | What lands |
|---|---|---|
| Steal | the losing row clears itself in front of them | one column per field |
| Refuse | the field is greyed out until they clear it themselves | one column per field |
| Allow both | two rows claiming one field, no warning | whichever column runs last |
Allowing both is the one to avoid. Stealing and refusing carry the same guarantee, and the steal takes fewer clicks.
Step 4. Derive the screen from the mapping
Everything the screen shows is a function of that one array. A separate count of matched columns, or a stored list of free fields, is a second copy that drifts the moment a person edits a row.
export const takenFields = (state: MappingState): Set<string> => new Set(state.flatMap((e) => (e.targetField ? [e.targetField] : [])));
export const unmappedColumns = (state: MappingState): string[] => state.filter((e) => e.targetField === null).map((e) => e.sourceColumn);
export const missingRequired = ( state: MappingState, fields: Field[],): Field[] => { const taken = takenFields(state); return fields.filter((f) => f.required && !taken.has(f.id));};missingRequired is the one to keep in front of the person. The mapping step exists to settle whether the file can fill the fields your app cannot do without, and that list is the answer.
Step 5. Render one row per file column
The row carries the header, a few real values from under it, and the field picker. Sample values are what a person maps by. Home Depot and Home Depot (2) are the same word twice, and East against DEP-E tells them apart.
type RowProps = { entry: Mapping; fields: Field[]; samples: string[]; onAssign: (sourceColumn: string, targetField: string | null) => void;};
export function MappingRow({ entry, fields, samples, onAssign }: RowProps) { const labelId = "map-" + entry.sourceColumn;
return ( <div className="mapping-row"> <div> <strong id={labelId}>{entry.sourceColumn}</strong> <p>{samples.slice(0, 3).join(", ")}</p> </div>
<select aria-labelledby={labelId} value={entry.targetField ?? ""} onChange={(event) => { onAssign(entry.sourceColumn, event.target.value || null); }} > <option value="">Do not import</option> {fields.map((field) => ( <option key={field.id} value={field.id}> {field.label} {field.required ? " *" : ""} </option> ))} </select> </div> );}A native <select> gets keyboard support and the platform picker for nothing. The custom alternative owes the whole combobox pattern, which the ARIA Authoring Practices Guide builds from role="combobox" and aria-expanded, plus aria-controls pointing at the popup while it is open, aria-activedescendant tracking the focused option, and aria-autocomplete describing what typing does. Take that on when a schema grows past what a person can scan, and pay it with a filter box.
aria-labelledby points at the header alone, so a screen reader announces which column the dropdown belongs to. Put that id on the wrapper and the sample values underneath join the name.
Step 6. Decide what happens to an unmapped column
The mapping is also the import plan. A column with targetField: null writes nothing.
export const buildRecords = ( rows: Record<string, string>[], state: MappingState,): Record<string, string>[] => { const plan = state.flatMap((entry) => entry.targetField === null ? [] : [{ from: entry.sourceColumn, to: entry.targetField }], );
return rows.map((row) => { const record: Record<string, string> = {}; for (const { from, to } of plan) { record[to] = row[from] ?? ""; } return record; });};That is the moment the file loses data, and it is silent by construction.
file value Reg = "LS24 TBK"mapping Reg → (none)imported row { assetTag: "FL-0001", make: "Mercedes Sprinter 315" }The registration number is in the file, on the screen, and absent from the record. A mapping step catches that either before the person moves on or after the rows land.
| Rule | Cost | Fits |
|---|---|---|
| Block the step while a required field is unmapped | the person cannot move on, and cannot see the rows either | a schema whose required fields are few and obvious |
| Let them through and validate the rows | the empty cell arrives with a row number and a message | a schema where a person needs the data in front of them to decide |
Blocking early is the stricter guarantee. Validating later shows the person which rows are affected before they go back and fix the mapping. Pick one, and say which on the screen.
Step 7. Rebuild the mapping when the file changes
The mapping is derived from a specific list of headers, and that list changes under it.
The header row moves. A person tells you row 1 was a title and the real headers sit on row 3. Every key in the mapping changes, and the answers they typed by hand have to survive under the headers that still read the same.
A second file arrives. Its headers are its own, and its Depot is not the first file's Depot. One mapping per file keeps the one-field-per-column rule true where it is true, which is inside a file. A single mapping keyed by header text across two files lets one file's Work take company from the other file's own Company header.
The person starts over. Then the mapping goes with the file that produced it.
One rule covers all three changes. Treat a fresh proposal as the base and lift the person's own answers on top of it. An answer whose header no longer exists drops out with it.
What one column to one field cannot do
Make/Model holds Mercedes Sprinter 315. Your schema wants make and model. A one-to-one mapping has no way to express that, so one of the two fields stays empty whatever the person picks. Splitting a column is a transform, and it belongs after the mapping step or before the file arrives. Saying that out loud beats a dropdown that silently drops half the value.
The same limit runs the other way. Two columns that both belong in notes need a join, and a mapping step that steals the field from one of them is telling the truth about what it can do.
Matching itself has a floor. Vehicle #, Reg and Odo reach nothing in this schema by string similarity alone. Normalization strips spaces and a few separators and leaves the # standing, so vehicle# meets assettag, and a three-letter header is too short to be contained by Registration or Odometer (km). No table of synonyms is complete, and every mapping step needs the dropdown behind it.
The same screen with Updog Importer
Updog Importer ships this step. You describe the same fields as columns, and the words your schema invented go in as synonyms.
import { DataEditor, type DataEditorColumn } from "@updog/data-editor";
const columns: DataEditorColumn[] = [ { id: "assetTag", title: "Asset tag", validators: [{ type: "required" }] }, { id: "plate", title: "Registration", validators: [{ type: "required" }] }, { id: "make", title: "Make" }, { id: "model", title: "Model" }, { id: "odometerKm", title: "Odometer (km)", editor: { type: "number" } }, { id: "inServiceDate", title: "In service", editor: { type: "date" } }, { id: "depot", title: "Depot", editor: { type: "select", options: ["North", "South", "East", "Wakefield"], }, },];
type Props = { open: boolean; onClose: () => void };
export function FleetImport({ open, onClose }: Props) { return ( <DataEditor apiKey="your-license-key" open={open} onClose={onClose} columns={columns} primaryKey="assetTag" synonyms={{ columns: { assetTag: ["vehicle #", "vehicle no", "fleet number"], plate: ["reg", "reg no", "number plate"], odometerKm: ["odo", "mileage"], }, }} onComplete={async (result) => { await saveVehicles(result.sources); }} /> );}Against the depot file, In Service reaches inServiceDate on its own, scoring 100 against the column title. Make/Model scores 80 against both make and model and takes one of them. Home Depot reaches depot, and Home Depot (2) finds it taken. The three synonym lists above cover Vehicle #, Reg and Odo, which score zero without them.
Vehicle # → assetTag (synonym)Reg → plate (synonym)Make/Model → make (80, model stays empty)Odo → odometerKm (synonym)In Service → inServiceDate (100)Home Depot → depot (80)Home Depot (2) → (none)The screen shows a matched count per file, a green Best match tag on the option the matcher chose, a header that opens a panel of real values from that column, and a Show matched checkbox, on from the start, that clears the answered rows out of the list once a person turns it off. A column mapped to nothing imports nothing, and its data never reaches the grid. Nothing on that step blocks Next, so a required field left empty is caught by validation in the grid instead, where the person can see the rows.
The pairs a person fixes by hand come back in learnedSynonyms on submit, and go into the synonyms prop on the next open, which is what remembering a mapping between uploads is built on.
What you built
One array holding one entry per file column. A write path that keeps a field to a single column. Three derived lists that the screen reads and never stores. A row with a real label on a real select. A build step that says out loud which columns land and which do not.
The dropdowns are the part a person sees, and the invariant is the part that decides whether the file arrives whole. Parsing hands you the columns, and this step is where they stop being strings and start being your data.