npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-sheet-uploader

v1.2.0

Published

Open-source, embeddable spreadsheet importer for React. Drop-in compatible with the Dromo uploader API: same fields schema, settings, row/bulk/column/step hooks, and results shape.

Readme

react-sheet-uploader

An open-source, embeddable spreadsheet importer for React — CSV, TSV, XLSX and JSON in, clean validated objects out.

It is a drop-in replacement for Dromo's React uploader: the same fields schema, the same settings, the same row / bulk / column / step hooks, and the same onResults payload. No license key, no backend, no per-import billing. Everything runs in the browser.

npm install react-sheet-uploader
# or: yarn add react-sheet-uploader
import { SheetUploader } from 'react-sheet-uploader';
import 'react-sheet-uploader/styles.css';

<SheetUploader
  fields={[
    { label: 'Name', key: 'name', validators: [{ validate: 'required' }] },
    { label: 'Email', key: 'email', type: 'email', validators: [{ validate: 'unique' }] },
  ]}
  settings={{ importIdentifier: 'Contacts' }}
  onResults={(data, metadata) => console.log(data)}
>
  <button>Import contacts</button>
</SheetUploader>

Contents


What you get

  • Formats — CSV, TSV, delimited text, XLSX/XLSM, JSON, plus your own parsers.
  • Header detection — finds the header row under title rows and blank lines; the user can override it.
  • Column matching — fuzzy header matching against labels, keys and alternateMatches, scored globally so the best column wins each field.
  • 17 field types with coercion, from email and phone-number to us-zip-code and uuid.
  • A full validator set, including cross-row unique and composite unique_with.
  • Hooks — row, bulk row, column, step, row-delete and beforeFinish, all async-aware.
  • An editable, virtualised review grid — 50k rows scroll smoothly; spreadsheet keyboard shortcuts, multi-cell selection, fill down, undo, copy/paste to and from Excel, per-column error filters, and an error navigator.
  • Export from the review step — download the grid as it stands as CSV, TSV or JSON.
  • Manual entry — start from an empty grid instead of a file.
  • Theming via styleOverrides, driven entirely by CSS custom properties.

Migrating from Dromo

Change the import, drop the stylesheet in, and delete the license key when you feel like it:

- import DromoUploader from 'dromo-uploader-react';
+ import { SheetUploader } from 'react-sheet-uploader';
+ import 'react-sheet-uploader/styles.css';

- <DromoUploader licenseKey="..." fields={fields} settings={settings} onResults={onResults}>
+ <SheetUploader fields={fields} settings={settings} onResults={onResults}>

A DromoUploader alias is exported too, so import { DromoUploader } from 'react-sheet-uploader' works if you would rather not touch the JSX.

Accepted and ignored. These only mean something against Dromo's servers. They are typed and accepted so your existing config keeps compiling; they simply do nothing: licenseKey, schemaId, settings.backendSyncMode, settings.webhookUrl, settings.developmentMode, settings.autoMapHeaders, settings.browserExcelParsing.

Deliberate differences.

| | Dromo | react-sheet-uploader | |---|---|---| | metadata.id | server-assigned id | always null — there is no server | | Legacy .xls | supported | not built in; re-save as .xlsx, or pass a fileParsers entry | | AI "Transform data" | available | not implemented | | Saved schemas / Schema Studio | available | not applicable | | Session resume | available | not applicable | | Locales | 35+ | English only | | phone-number | full libphonenumber validation | E.164 normalisation with a country calling-code table; length-checked, not carrier-checked |


Opening the uploader

Three ways, matching Dromo's:

// 1. A trigger. Anything you pass as children opens it when clicked.
<SheetUploader fields={fields}><button>Import</button></SheetUploader>

// 2. Controlled.
<SheetUploader fields={fields} open={isOpen} onOpenChange={setIsOpen} />

// 3. Imperative, via a ref.
const uploader = useRef(null);
<SheetUploader ref={uploader} fields={fields} />;
uploader.current.open();

Fields

interface Field {
  label: string;              // shown to the user
  key: string;                // key in the results
  type?: FieldType;           // defaults to 'string'
  description?: string;       // help text during matching and review
  alternateMatches?: string[];// extra header names that map here
  validators?: Validator[];
  selectOptions?: SelectOption[];
  readOnly?: boolean;         // visible, not editable
  hidden?: boolean;           // never shown, still in the results
  requireMapping?: boolean;   // matching cannot continue until it is mapped
  manyToOne?: boolean;        // several columns feed it; the value is an array
}

Field types

A type is either a name or a [name, options] tuple:

{ label: 'Signups', key: 'signups', type: 'number' }
{ label: 'Revenue', key: 'mrr', type: ['number', { preset: 'usd', round: 2 }] }

| Type | Options | Notes | |---|---|---| | string | — | Passed through exactly as entered. | | number | preset, round, min, max, displayFormat, outputFormat | Reads 1,234.56, 1.234,56, $1,234, (89) and 45%. Presets: default, plain, percent, usd, usd_accounting, eur, gbp, decimal_0–decimal_4, integer, percent_0–percent_4. | | date | dayFirst, displayFormat, outputFormat | ISO, slashed and textual dates, plus Excel serials. Outputs YYYY-MM-DD. | | datetime | as date plus withSeconds | Outputs ISO-8601. | | time | withSeconds, formats | 12- and 24-hour clocks. | | select | allowCustom, exactMatchOnly | Matches value, label, alternateMatches, then fuzzily. | | multi-select | as select plus delimiter, trimValues | Outputs string[]; drops duplicates. | | checkbox | — | Empty, 0, off, n, no, false, disabled are false. | | email | — | HTML5 spec, minus single-label domains and IP literals. Lowercased. | | country | format: '2-letter' \| '3-letter' | ISO 3166-1 names, alpha-2 and alpha-3, plus common aliases. | | phone-number | country, format, outputFormatted | Normalises to E.164; strips national trunk zeros. | | ssn | outputDash | Rejects never-issued ranges. | | domain | allowSubdomains | Strips scheme, path and port. | | url | acceptedProtocols, acceptedDomains | Bare domains get https://. | | us-state-territory | — | Names and codes, including DC and territories. | | us-zip-code | format, outputDash | Leading zeros survive. | | uuid | version | Optional version check. |

Validators

validators: [
  { validate: 'required' },
  { validate: 'unique', errorMessage: 'Already taken', level: 'warning' },
]

Every validator takes an optional errorMessage and a level of error (default, blocks submission), warning or info.

| Validator | Extra keys | |---|---| | required | — | | unique, unique_case_insensitive | — | | unique_with | uniqueKey — fields sharing a key are unique as a tuple | | regex_match, regex_exclude | regex, regexOptions (ignoreCase, dotAll, multiline, unicode) | | require_with, require_without, require_with_all, require_without_all | fields | | require_with_values, require_without_values, require_with_all_values, require_without_all_values | fieldValues | | length | min, max | | alphabetical | — |

Rows where every user-editable cell is blank are treated as pending input, not errors. They are never validated and never submitted, so a fresh manual-entry grid does not open covered in required-field failures.


Settings

settings={{
  importIdentifier: 'Contacts',      // drives the default title, "Add Contacts"
  title: 'Import your contacts',
  invalidDataBehavior: 'REMOVE_INVALID_ROWS', // | 'BLOCK_SUBMIT' | 'INCLUDE_INVALID_ROWS'
  allowEmptySubmit: true,
  allowCustomFields: false,          // let users invent fields while matching
  passThroughUnmappedColumns: false, // adds `$unmapped` to each result row
  maxRecords: 50000,
  maxFileSize: 1024 * 1024 * 100,
  delimiter: '|',                    // auto-detected when omitted
  templateDownloadFilename: 'template.csv',
  initialData: [...],                // skip the file picker
  initialFile: someFile,
  manualInputDisabled: false,        // hide "enter your data by hand"
  manualInputOnly: false,            // hide the dropzone entirely

  uploadStep:      { helpText, sheetOverride, templateDownloadOverrideURL },
  matchingStep:    { helpText, fuzzyMatchHeaders, headerRowOverride, suggestCustomFirst,
                     aiMatch, aiMatchTimeoutMs },   // see "Column matching" below
  matchValuesStep: { helpText, maxMappableSelectValues },
  reviewStep:      { helpText, processingText, allowAddingRows, allowRemovingRows,
                     enableNavigatingErrors, highlightAutoFixes,
                     enableExport, exportFilename },
}}

Column matching

Columns are matched to fields automatically, then shown to the user for review. Nothing is ever imported on a guess: a suggested mapping is marked Suggested until the user accepts it, and the user can accept them all at once with Confirm all.

The built-in matcher needs no network, no key, and no configuration. It scores each (column, field) pair three ways and takes the strongest:

  • Character bigrams — catches typos: Email Adress → Email Address.
  • Token overlap after expansion — catches shorthand and word order. An abbreviation table covers the spreadsheet vocabulary that character metrics cannot reach: DOB → Date of Birth, Qty → Quantity, Zip → Postal Code, Cust Ref → customerReference, Address Email → Email Address.
  • Word-boundary containment — Customer Email Address → Email.

The score is then adjusted by what the column actually contains. A column of 2024-01-05 values is pushed away from an email field however its header reads, and a column of [email protected] values is pulled toward one. Assignment is global — every pair is scored and the strongest claimed first — so a mediocre early match cannot steal a field that a later column matches exactly.

Set matchingStep.fuzzyMatchHeaders: false to restrict matching to exact alias hits.

Add alternateMatches to a field for any header your own data uses:

{ label: 'Email', key: 'email', alternateMatches: ['e-mail', 'contact', 'work email'] }

AI-assisted matching

Some headers need to know what the words mean: that Rev/Mo is monthly recurring revenue, or that Tier is your plan field. matchingStep.aiMatch hands those to a language model.

This package makes no model call of its own. It runs in the browser, and every provider with a free tier still requires an API key — a key shipped to the browser is a public key. So aiMatch is a function you implement, normally a fetch to your own endpoint, which holds the key and calls whichever provider you like. The prompt builder and response parser are exported, so the integration is short:

// Your app.
<SheetUploader
  fields={fields}
  settings={{
    matchingStep: {
      aiMatch: async (input, signal) => {
        const res = await fetch('/api/match-columns', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(input),
          signal,
        });
        return res.json();
      },
      aiMatchTimeoutMs: 15000, // default
    },
  }}
/>
// Your server. Any provider, any model — this is an easy task, so the
// cheapest tier is the right choice.
import { buildMatchPrompt, parseMatchResponse } from 'react-sheet-uploader';

app.post('/api/match-columns', async (req, res) => {
  const completion = await yourModel.generate(buildMatchPrompt(req.body));
  res.json(parseMatchResponse(completion));
});

input carries the column headers, a few sample values from each, and the schema — enough for the model to work with, and no more of the user's data than it needs. Long sample values are truncated and hidden fields are omitted before anything leaves the browser.

How the result is treated:

  • Nothing is trusted. A suggestion naming an unknown field, pointing at a column that does not exist, or double-claiming a single-use field is dropped. Below confidence: 0.5 it is dropped too — a blank the user fills in beats a wrong guess they have to notice.
  • Nothing is load-bearing. A rejection, a timeout, or unparseable output leaves the heuristic mappings exactly as they were. The step is usable either way.
  • Nothing is silent. Suggestions arrive unconfirmed, behind a banner saying how many changed. Low-confidence rows are flagged Check this, with the model's stated reason beside them. A mapping the user already changed is never overwritten.
  • Nothing blocks. The heuristic result renders immediately and refines when the response lands; the request is aborted if the user moves on first.

If you would rather write the prompt yourself, aiMatch only has to return { columnIndex, fieldKey, confidence?, reason? }[] — buildMatchPrompt and parseMatchResponse are a convenience, not a requirement.


Hooks

All hooks may be async; the uploader waits for them.

<SheetUploader
  rowHooks={[(record, mode) => { ... return record; }]}
  bulkRowHooks={[async (records, mode) => records]}
  columnHooks={[{ fieldKey: 'company', callback: (values) => values }]}
  stepHooks={[{ type: 'REVIEW_STEP', callback: (instance, data) => {} }]}
  rowDeleteHooks={[(record) => {}]}
  beforeFinish={(data, metadata, instance) => {}}
/>

Row hooks run per row on load (mode: 'init') and after each edit ('update'). Mutate the record and return it:

const deriveDomain = (record) => {
  const email = record.row.email.value;
  record.row.domain.value = email.slice(email.indexOf('@') + 1);
  return record;
};

Set info to attach messages to a cell. An error blocks submission:

record.row.email.info = [{ message: 'Already registered', level: 'error' }];

Assigning to value re-runs coercion and validation, so writing 'Jan 5, 2024' into a date field still yields 2024-01-05. Read resultValue to get that coerced value — the ISO date behind whatever the grid displays — and assign to it to change the output without changing what the user sees.

Bulk row hooks get the whole table in one call — the right place for a single API round-trip:

const checkDuplicates = async (records) => {
  const taken = await fetch('/api/existing-emails').then((r) => r.json());
  return records.map((record) => {
    if (taken.includes(record.row.email.value)) {
      record.row.email.info = [{ message: 'Already exists', level: 'error' }];
    }
    return record;
  });
};

Column hooks rewrite one column in a single pass, before row hooks run.

Step hooks fire at UPLOAD_STEP, REVIEW_STEP, REVIEW_STEP_POST_HOOKS and REVIEW_STEP_PRE_SUBMIT, receiving (instance, data).

beforeFinish is where table-level rules live. Return { cancel: true, message } to keep the user on the review screen:

beforeFinish={(data) =>
  data.length < 20 ? { cancel: true, message: 'Import at least 20 rows' } : undefined
}

The instance passed to step hooks and beforeFinish exposes addField, removeField, addRows, removeRows, getRecords, setRecords, updateInfoMessages, setMessage, setConfirmationMessage, goToStep, open and close.


Results

onResults={(data, metadata) => { ... }}

data is a plain array of objects keyed by field key. manyToOne and multi-select fields hold arrays; with passThroughUnmappedColumns each row also carries $unmapped, keyed by source column index.

[{ name: 'Ada Lovelace', email: '[email protected]', tags: ['design'] }]

metadata carries id (always null), filename, originalFile, importIdentifier, user, rawHeaders, fields (with fileHeader, fileHeaderIndex, isCustom, manyToOne), rowsWithError, errors, totalRows, validRows and invalidRows.

onCancel fires if the user leaves without finishing. onError fires when a file fails to parse or a hook throws.

Storing the results yourself

There is no backend, so there is nothing to configure: onResults is the storage hook. It is awaited, so the modal keeps its spinner up for the whole upload, and if it throws the user stays on the review screen with an error banner and can retry without losing their work.

onResults={async (data, metadata) => {
  const { url } = await fetch('/api/presign', { method: 'POST' }).then((r) => r.json());
  await fetch(url, { method: 'PUT', body: JSON.stringify(data) });
}}

metadata.originalFile is the File exactly as the user chose it, for archiving the raw upload alongside the parsed data. It is null for manual entry and for initialData.

if (metadata.originalFile) {
  await fetch(rawUrl, { method: 'PUT', body: metadata.originalFile });
}

It is a live File, not JSON — JSON.stringify(metadata) renders it as {}. Send it as a request body rather than serializing it.

Mint presigned URLs on your server. This library runs entirely in the browser, so any storage credential handed to it is readable by the user.

If presigning can fail and you would rather find out before the user hits Submit, do it in beforeFinish and return { cancel: true, message } on failure.


Styling

Import the stylesheet once:

import 'react-sheet-uploader/styles.css';

Then rebrand through settings.styleOverrides, which maps onto CSS custom properties:

styleOverrides: {
  global: { primaryTextColor: '#1a1523', borderRadius: '2px', customFontFamily: 'Inter' },
  primaryButton: { backgroundColor: '#1a1523', textColor: '#fff' },
  stepperBar: { completeColor: '#0f7b6c' },
  dropzone: { borderColor: '#1a1523', borderStyle: 'solid' },
  modalOverlay: { backgroundColor: 'rgba(26, 21, 35, 0.6)' },
}

Supported groups: global, primaryButton, secondaryButton, tertiaryButton, dropzone, helpText, stepperBar, dataTable, modalOverlay, errorNavigator.

Every rule is scoped under .rsu-root and every class is prefixed rsu-, so nothing leaks either way. If you would rather skip styleOverrides, set the --rsu-* variables yourself.


Custom file formats

<SheetUploader
  fileParsers={[
    {
      extensions: ['xml'],
      parseFile: async (buffer, fileName) => {
        const text = new TextDecoder().decode(buffer);
        return [['Header A', 'Header B'], ['1', '2']]; // rectangular string[][]
      },
    },
  ]}
/>

This is also how to add legacy .xls, by delegating to a reader of your choice.


The import flow

  1. Upload — drop a file, or start an empty grid by hand.
  2. Sheet — only when a workbook has more than one sheet.
  3. Header row — skipped when matchingStep.headerRowOverride is set.
  4. Match columns — auto-matched, user-confirmable; optionally AI-assisted.
  5. Match values — only when select values in the file match no option.
  6. Review — edit, fix, and submit.

In the review grid: click a cell to select it and type to edit; click a row number to select the whole row; select-type cells open a dropdown on a single click; and column headers show an error count that filters the grid to that column's problems.

Review grid shortcuts

The grid is driven like a spreadsheet. Cmd is Ctrl on Windows and Linux.

| Keys | What it does | | --- | --- | | Arrows, Tab, Shift+Tab | Move a cell. Tab wraps to the next row; stepping past the last row adds one | | Cmd+arrow | Jump to the first or last row, or the first or last column | | Home / End | First or last cell of the row. With Cmd, the first or last cell of the table | | PageUp / PageDown | Move a screen at a time | | Shift+arrow, Shift+Home/End/PageUp/PageDown | Extend the selection into a block of cells | | Shift+click, or click and drag | Select a block with the mouse | | Shift+Space / Cmd+Space | Select the whole row / the whole column | | Cmd+A | Select every cell and check every row | | Escape | Give back the block, then the checked rows | | Enter, F2, or any character | Edit the cell. A character replaces the value and becomes the first keystroke | | Shift+Enter, Alt+Enter, Cmd+Enter | Insert a line break inside a cell — drawn as a ↵ marker, since rows are a fixed height | | Enter / Tab while editing | Commit and move down / across. Escape cancels | | Delete / Backspace | Clear every editable cell in the selection | | Cmd+C / Cmd+X | Copy or cut the selected block, else the checked rows, else the active cell | | Cmd+V | Paste. A single copied cell fills the whole selected block; a wider clipboard spreads out from the block's top-left corner | | Cmd+D / Cmd+R | Fill the selection down from its first row, or right from its first column | | Cmd+Z / Cmd+Shift+Z | Undo or redo cell edits (adding and deleting rows is not undoable) | | Space | Check or uncheck the row |

Copy and paste interoperate with Excel, Sheets and Numbers: the grid writes TSV and reads TSV or CSV, honouring quoted fields, so a cell containing a line break survives the round trip.

Exporting

The review step has an Export button that downloads the grid exactly as it stands — invalid rows included, since the point is to keep the work in progress. CSV is the default; the caret offers TSV and JSON. It exports the checked rows if there are any, otherwise every row passing the current filter and search. Turn it off with reviewStep: { enableExport: false }, and name the file with reviewStep: { exportFilename: 'contacts' } (defaults to import).

buildExport and downloadExport are also exported for headless use.


Headless use

The engines are exported if you want the pipeline without the UI:

import {
  parseFile, normalizeField, coerceValue, autoMatchColumns,
  detectHeaderRow, buildRecords, runPipeline, validateRecords,
  buildResults, buildTemplateCsv,
} from 'react-sheet-uploader';

These sit outside the Dromo compatibility surface and may change more freely than the component API.


Browser support and bundle size

Modern evergreen browsers. The published bundles are minified, with source maps alongside so you can still step through readable code. The main chunk is roughly 45 kB gzipped; the spreadsheet reader is a separate ~18 kB chunk, loaded on demand the first time someone uploads an XLSX file. The stylesheet is ~5 kB gzipped.

Runtime dependencies are just papaparse and read-excel-file. React 17, 18 and 19 are supported as peer dependencies.

Development

yarn install
yarn dev        # demo app at localhost:5173
yarn test
yarn build

Releasing

Releases are driven by the version in package.json. Whenever that version changes on main, the Publish to npm workflow typechecks, tests, builds and publishes the package.

yarn version patch   # or minor / major
git push origin main

Editing the version field by hand works the same way — the workflow only cares that the version changed. On a bump it also pushes a v<version> tag and cuts a GitHub release.

npm is the source of truth: if the version in package.json is already published, the run exits without publishing, so re-runs, reverts and package.json edits that leave the version alone are all no-ops.

Pre-release versions go out under a matching npm dist-tag rather than latest, and skip the GitHub release:

yarn version 0.4.0-beta.1   # published as `beta`

Publishing requires an NPM_TOKEN secret (an npm automation token with publish rights) under Settings → Secrets and variables → Actions. Provenance attestation is on by default; set the NPM_PROVENANCE repository variable to false to disable it.

License

MIT