Back to all postsA gray felt gear on a warm cream background

Import, Edit and Delete Rows Through Your REST API

A port's arrival list lives behind a REST API and keeps changing. An agent sends the next week as a file, a vessel arrives that nobody booked, and two calls get cancelled before they happen.

All of it lands on one screen. Your API fills the grid, the person imports the agent's file into the same grid, corrects what is already there, removes the calls that are off, and submits once. Your handler receives the rows that moved, one list of what was added, changed and removed, and turns it into POST, PATCH and DELETE requests.

The agent's file arrives looking like this.

ravensgate-arrivals.csv
ABCDEF
1Call RefVesselIMOBerthETAStatus
2PC-2026-0413Kestrel BayIMO 9074729Quay 32026-04-02Expected
3PC-2026-0414Aldervik9182741Quay 12026-04-02Along side
4PC-2026-0415Marisol Trader9331556Quay 42026-04-03Expected
5PC-2026-0415Marisol Trader9331556Quay 42026-04-03Expected
52 rows not shown
58PC-2026-0469Sable Point9448023Quay 22026-04-05Expected
1Call Ref,Vessel,IMO,Berth,ETA,Status2PC-2026-0413,Kestrel Bay,IMO 9074729,Quay 3,2026-04-02,Expected3PC-2026-0414,Aldervik,9182741,Quay 1,2026-04-02,Along side4PC-2026-0415,Marisol Trader,9331556,Quay 4,2026-04-03,Expected5PC-2026-0415,Marisol Trader,9331556,Quay 4,2026-04-03,Expected52 rows not shown58PC-2026-0469,Sable Point,9448023,Quay 2,2026-04-05,Expected

The file opens with six headers and runs 57 lines. The IMO number on row 2 arrives as IMO 9074729, with the prefix the agent's system prints. Row 3 gives the status the old berthing book used, Along side. Rows 4 and 5 are the same call written twice, both under PC-2026-0415.

Your endpoint answers the GET. Updog Importer paints those rows. The person drops the file in beside them, fixes what is flagged, and presses submit. Your handler reads that list and calls your API.

Between that browser and your API there is no Updog server.

Step 1. Describe the columns

The columns array describes your resource to the person looking at it. Each entry names the title they read, the editor that shapes typing in a cell, and the validators that mark a value your API would refuse.

import type { DataEditorColumn } from "@updog/data-editor";
type PortCall = {
callRef: string;
vessel: string;
imo: string;
berth: string;
eta: string;
status: string;
};
const STATUSES = ["Expected", "Berthed", "Departed"];
export const columns: DataEditorColumn[] = [
{
id: "callRef",
title: "Call ref",
size: 150,
transformer: (value) => String(value).trim().toUpperCase(),
validators: [
{ type: "required" },
{ type: "unique" },
],
},
{
id: "vessel",
title: "Vessel",
size: 170,
validators: [{ type: "required" }],
},
{
id: "imo",
title: "IMO",
size: 120,
transformer: (value) => String(value).replace(/^IMO\s*/i, "").trim(),
validators: [
{ type: "required" },
{ type: "regex", pattern: "^\\d{7}$", message: "Seven digits" },
],
},
{
id: "berth",
title: "Berth",
size: 120,
validators: [{ type: "required" }],
},
{
id: "eta",
title: "ETA",
size: 140,
editor: { type: "date" },
validators: [
{ type: "required" },
{ type: "date" },
],
},
{
id: "status",
title: "Status",
size: 140,
editor: { type: "select", options: STATUSES, enableCustomValue: false },
validators: [
{ type: "required" },
{ type: "oneOf", values: STATUSES },
],
},
];

The transformer on imo strips the IMO prefix before validation runs, so IMO 9074729 reaches the regex as seven digits. The transformer on callRef trims and uppercases, so the key compares the same however the agent typed it. unique marks a reference that appears twice in the grid, and the last step of the wizard decides whether the repeated PC-2026-0415 becomes two rows.

Along side matches no option on its own, so synonyms teaches it. The person sees the mapping in the value-matching step of the import, with Along side pointing at Berthed, and can change it there.

Step 2. Fill the grid from your API

loadData runs when the editor opens, and onChunk takes the rows.

import type { ChunkSourceOptions } from "@updog/data-editor";
type OnChunk = (rows: PortCall[], options?: ChunkSourceOptions) => void;
const loadData = useCallback(async (onChunk: OnChunk) => {
const response = await fetch("/api/port-calls?week=2026-W14", {
credentials: "same-origin",
});
const { calls } = await response.json();
onChunk(calls, { source: "Port calls", done: true });
}, []);

You can call onChunk as many times as your API pages, and the editor processes each chunk without freezing the grid. The source name is what the person reads in the data sources panel, and done closes that source's loading state.

Each key in a row is a column id, so the answer your endpoint sends needs no mapping on the way in.

{
"calls": [
{
"callRef": "PC-2026-0390",
"vessel": "Corvid Star",
"imo": "9210338",
"berth": "Quay 1",
"eta": "2026-03-30",
"status": "Departed"
}
]
}

Typed columns read those values the same way an import does. The date column canonicalizes 2026-03-30, and the select column takes Departed as the option it already knows.

Rows loaded this way set the baseline. Nothing marks them, the counters read zero, and submit stays disabled until somebody changes something.

Step 3. Let the person add, change and remove rows

enableAddRow decides what the person may add, and enableDeleteRow what they may remove. Editing a cell needs no prop.

<DataEditor<PortCall>
apiKey="your-license-key"
open={open}
onClose={closeEditor}
columns={columns}
primaryKey="callRef"
loadData={loadData}
enableAddRow
enableDeleteRow="all"
synonyms={{
values: { Berthed: ["along side", "alongside", "a/side"] },
}}
onComplete={onComplete}
/>

enableAddRow is on by default. It gives the person Insert row above, Insert row below and Duplicate row in the right-click menu, and an Add row button while the grid is still empty. A row added any of those ways takes isNew from the moment it appears.

The agent's file arrives through the data sources panel. Add data source opens the import wizard, the person maps the headers, and the file lands as its own source next to the rows your API sent. The last step of the wizard asks how the file meets the grid. Add every row makes each line a row of its own. The wizard preselects Update by Call ref, because primaryKey names that column. That option drops a line onto the row whose reference it already matches, and the row keeps its place and its source. A reference your API never sent becomes a new row either way.

That choice decides the repeated PC-2026-0415. Under Update by Call ref both lines land on the call your API already has, and the second line wins. Under Add every row both lines stay, and unique marks them together with the call already in the grid, three rows reading Value must be unique.

enableDeleteRow is off by default, and it takes "all" or "new". With "new" the person can only remove rows they added or imported. With "all" they can remove a call your API holds, which is what a cancelled arrival needs.

A deleted row moves into a bin. The editor flags it, hides it from the grid, and keeps it in memory with everything else. The person opens the deleted-rows filter to look at what they removed, restores a row they took out by mistake, and undo works on the whole operation. Submit empties that bin into your handler, every row in it carrying isDeleted whatever your validators said about it.

Step 4. Read the flags

Submit hands your handler every row that moved, grouped by source and flagged independently. isNew says where the row came from, and it never flips back. isChanged says a cell moved, and typing the original value back into it clears the flag. isDeleted is the person's own mark. isValid reports the verdict of your validators.

What the row carries What happened The request
isNew added in the grid, or imported under a new call ref POST
isChanged a cell differs from what your API sent PATCH
isDeleted without isNew a call your API holds, removed by the person DELETE
isDeleted with isNew added and removed inside one session nothing to send
isValid false a validator marked the row your call

Rows nobody touched stay out of the result, so absence means there is nothing to do with them.

A week of calls reaches the handler as a list this short.

Call ref Flags on submit Request
PC-2026-0414 isNew POST /api/port-calls
PC-2026-0415 isChanged PATCH /api/port-calls/PC-2026-0415
PC-2026-0390 isDeleted DELETE /api/port-calls/PC-2026-0390
PC-2026-0413 isNew, isDeleted none

Watch the fourth line. A row the person added or imported and then removed carries both flags, and your API has never seen its reference. Sending it to DELETE addresses a resource your API never created, which is why the delete filter reads isDeleted && !isNew.

Step 5. Send one round of requests

The handler groups the diff by verb and walks each group in order.

import type { DataEditorResult } from "@updog/data-editor";
const send = async (method: string, path: string, body?: PortCall) => {
const response = await fetch(path, {
method,
credentials: "same-origin",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const gone = method === "DELETE" && response.status === 404;
if (!response.ok && !gone) {
throw new Error(method + " " + path + " answered " + response.status);
}
};
const onComplete = useCallback(async (result: DataEditorResult<PortCall>) => {
const entries = result.sources.flatMap((source) => source.rows);
const created = entries.filter((e) => e.isNew && !e.isDeleted && e.isValid);
const changed = entries.filter(
(e) => !e.isNew && e.isChanged && !e.isDeleted && e.isValid,
);
const removed = entries.filter((e) => e.isDeleted && !e.isNew);
for (const entry of created) {
await send("POST", "/api/port-calls", entry.row);
}
for (const entry of changed) {
await send("PATCH", "/api/port-calls/" + entry.row.callRef, entry.row);
}
for (const entry of removed) {
await send("DELETE", "/api/port-calls/" + entry.row.callRef);
}
}, []);

POST sends the whole row, since your API is creating a call it has never seen. PATCH sends that same whole row under its call ref. The result hands you the row and no field-level diff, so your endpoint reads the body as the call's new state. DELETE sends the reference alone.

credentials: "same-origin" sends the session your app already has. It works while your API answers on the app's own origin, and no key travels to the browser for any of it.

This handler lets a 404 on DELETE pass. Somebody cancelled the call in another screen while the session was open, and the outcome the person asked for is the outcome your API already holds.

Submit opens a dialog counting the rows to create, the rows to update and the rows to delete, and the handler starts when the person confirms.

Throw when your API refuses. Updog Importer awaits your handler and empties the editor the instant that promise resolves. A handler that swallows the failure and returns reads as a clean save, and the grid clears with the week unsent. Throwing keeps every row, every mapping and every correction on the screen, so the person submits again on data they can still see. Copy anything you want to keep inside the handler, because the editor lets go of its rows, its sources and its history once the promise resolves.

The person waits with that dialog open and a spinner on the button while the requests run. A diff too big for one round wants each group posted as one request, and a retry path for the group that fails.

Step 6. Open again where the person stopped

The person gets called away with the file half corrected. Your app stores the diff, and the next session hands it back.

const loadData = useCallback(async (onChunk: OnChunk) => {
const response = await fetch("/api/port-calls?week=2026-W14&draft=1", {
credentials: "same-origin",
});
const { calls, draft } = await response.json();
onChunk(calls, {
source: "Port calls",
done: true,
changes: draft.changes,
});
}, []);

changes is sparse, so it lists the rows that moved and says nothing about the rest.

{
"changes": [
{ "index": 2, "originalValues": { "berth": "Quay 1" } },
{ "index": 5, "isNew": true },
{ "index": 8, "isDeleted": true }
]
}

originalValues marks a row as edited and keeps the values your API had before the person touched them. isNew marks a row they added. isDeleted marks one they removed. The editor opens reading Changed rows 1, New rows 1 and Deleted rows 1, with the marks already in the grid and the removed row already in the bin.

index counts inside the chunk it travels with. Loading the week in three calls and numbering the changes from the whole set marks the wrong rows, and nothing on screen says so.

Seeded changes are the baseline of that session, so undo does not reach past them. A person who types the original value back into a seeded cell clears the mark the same way they clear their own.

What this screen does not carry

Updog Importer integrates with nobody. There is no REST connector, no destination list and no webhook. onComplete hands your code an object, and both endpoints here are yours to write.

The editor keeps no draft of its own. Step 6 reads one your app stored, written by a handler of yours that stored the diff and sent nothing.

The wizard offers the columns carrying a unique validator and preselects the one primaryKey names. A file that identifies a call by vessel and date needs that key built before the rows reach the grid.

The handler here sends one request per row. A berthing week affords that. Client-side and server-side CSV import covers what moves when the volume climbs past it, and how to import CSV into PostgreSQL writes the chunked version of this handler against a database.

What the round trip gives you

You wrote six columns, a loadData that fills the grid from your collection, two props that let the person add and remove rows, and one handler that turns four flags into three requests. The mount and the handler here are React. The web component build takes the same props, so a Vue, Angular or Svelte app writes the same schema and the same handler.

The agent's file and the calls your API already has share one grid, and the person works on both without knowing which is which. Your API sees the difference, one verb at a time.