@s3nd/react
Hooks that never see a credential.
Send a file or a snapshot, read a code back, and an input that repairs the code as the user types it. Its whole dependency tree is the protocol package and nanoid, with React as a peer. The AWS SDK stays on your server.
npm install @s3nd/reactimport { S3ndProvider } from '@s3nd/react'
export default function Providers({ children }) {
return <S3ndProvider baseUrl="/api/transfers">{children}</S3ndProvider>
}01Sending
A file input, a code.
sendFile() takes a File straight off an input, keeping its name and type. Failures land in error rather than rejecting, because an event handler should not need a try/catch.
import { useSendTransfer } from '@s3nd/react'
function DropFile() {
const { sendFile, transfer, isPending, error } = useSendTransfer()
return (
<>
<input
type="file"
disabled={isPending}
onChange={(event) => event.target.files?.[0] && sendFile(event.target.files[0])}
/>
{transfer && <p>Read this out to them: {transfer.code}</p>}
{error && <p>{error.message}</p>}
</>
)
}The hook posts to the transfer routes on your server, which hold the bucket credentials. The browser never sees a key, and your authorize function decides who may send.
transfer carries the code, the kind, the size and the expiry. Show the code grouped in fours; the receiving side accepts it with or without the spaces.
02Receiving
Look it up, show it, then download.
load() fetches what a code holds without moving the bytes, so the user sees a filename and a size before anything is downloaded. loadBytes() brings the file across.
import { useReceiveTransfer, useSyncCodeInput } from '@s3nd/react'
function PickUp() {
const input = useSyncCodeInput()
const { load, loadBytes, transfer, notFound, isPending } = useReceiveTransfer()
async function download() {
const bytes = await loadBytes(input.code!)
if (bytes) saveToDisk(new Blob([bytes]), transfer?.filename ?? 'file') // your helper
}
return (
<>
<input {...input.inputProps} placeholder="K7QP 2M4X" />
<button onClick={() => load(input.code!)} disabled={!input.isComplete || isPending}>
Look it up
</button>
{notFound && <p>Unknown or expired code.</p>}
{transfer?.kind === 'file' && (
<button onClick={download}>
Download {transfer.filename} · {transfer.size} bytes
</button>
)}
</>
)
}03The code input
What the user typed stays untouched.
useSyncCodeInput does the repair in the browser, before any request. Rewriting the field under the cursor is the one thing that makes these inputs miserable, so it never does.
Complete. Separators dropped, case folded, and O, I and L read as 0, 1 and 1, because Crockford base32 has no O, I or L to confuse them with.
Alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ · 8 chars · 40 bits
value is verbatim. code is the canonical form to submit, null while what is typed cannot be one. isComplete is the moment to enable the button.
inputProps carries the keyboard and autofill hints a one-time code wants: autoComplete="one-time-code", capitals, no autocorrect, and a numeric keyboard when the alphabet is digits.
Pass the same shape your server configured, { length: 4, alphabet }, and both halves follow.
04App state
The same hooks carry a snapshot.
Structured state goes through send() as a snapshot, and comes back inline in data. Loading and applying are deliberately separate: only your code knows its object stores, and the user should see what is about to replace their data.
const { send, transfer } = useSendTransfer()
// Structured state goes as a snapshot, with your schema version.
await send(await exportDatabase(), { version: 3 })
// On the other device: load, show, then apply.
const { load, transfer, data } = useReceiveTransfer<DatabaseDump>()
await load(code)
// transfer.device, transfer.createdAt → show them
// importDatabase(data!) → only after the user confirmsThe IndexedDB example in the repository has a complete export and import pair against a real object store, and the use-case page walks the whole flow.
05Guarantees
A user hammering a button gets one answer.
Every call aborts the one before it, a late reply from a superseded call is dropped rather than published, and nothing is written after unmount.
Client hooks, App Router ready
Every export is a client hook and the build carries 'use client', so it drops straight into the Next.js App Router. React 18 or later.
Tokens and custom clients
Pass headers to the provider for a token, or client to bring your own, which is also how you drive it in tests with no network at all.
Status you can render
status is idle, pending, success or error, and notFound covers both an unknown and an expired code, the way the protocol does.
- useSendTransfer()
- send, sendFile, transfer, status, isPending, error, reset
- useReceiveTransfer()
- load, loadBytes, burn, transfer, data, notFound, status, isPending, error, reset
- useSyncCodeInput()
- value, setValue, code, isComplete, error, reset, inputProps
- useTransferClient()
- the underlying client, for anything the hooks do not cover
Put a file. Hand over the code.
Point it at the bucket you already pay for. Nothing to deploy, nothing to sign up for, nothing in the middle.
npm install -g @s3nd/clis3nd init --provider r2 --bucket drops3nd put ./anything.zip