React hook
sh
npm i optiq-image # react >= 18 is an optional peer dependencytsx
import { useState } from "react";
import { useOptiq } from "optiq-image/react";
export function AvatarPicker({ onReady }: { onReady: (file: Blob, name: string) => void }) {
const [file, setFile] = useState<File | null>(null);
const { status, result, progress, error, cancel } = useOptiq(file, { mode: "fast", maxWidthOrHeight: 1024 });
return (
<div>
<input type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
{status === "compressing" && (
<p>
{progress?.stage} {Math.round((progress?.progress ?? 0) * 100)}% <button onClick={cancel}>Cancel</button>
</p>
)}
{status === "done" && result && (
<button onClick={() => onReady(result.blob, result.fileName)}>
Use it ({Math.round(result.sizeReductionRatio * 100)}% smaller)
</button>
)}
{status === "error" && file && <button onClick={() => onReady(file, file.name)}>Upload original</button>}
</div>
);
}status:idle | compressing | done | error | cancelled.- The job restarts when
fileor the options change. Options are compared by value, so an inline object does not restart it on every render. The previous job is aborted (its worker is terminated), and so is the running job on unmount. enabled: falsekeeps the hook idle;compressorlets you pass your owncreateCompressor()pool.- On error the original file is untouched; upload it instead.
Example: photo upload form
Compress on the device, show progress and the saving, upload through a presigned S3 URL, and fall back to the original file if compression or upload fails. (This file is type-checked in CI against the real package types.)
View example: PhotoUploadForm.tsx
tsx
// A single-photo upload form: compress on the device, show progress and savings, upload to S3 through a presigned
// URL from your backend, and fall back to the original file if anything goes wrong.
import { useEffect, useMemo, useState } from "react";
import { isOptiqError } from "optiq-image";
import { useOptiq } from "optiq-image/react";
import { uploadToPresignedUrl, type PresignedUpload } from "optiq-image/upload";
async function presign(fileName: string, contentType: string, size: number): Promise<PresignedUpload> {
const res = await fetch("/api/presign", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fileName, contentType, size }),
});
if (!res.ok) throw new Error(`presign failed: ${res.status}`);
return res.json(); // { url, key }
}
const kb = (n: number) => `${(n / 1024).toFixed(0)} KB`;
export function PhotoUploadForm({ onUploaded }: { onUploaded: (key: string) => void }) {
const [file, setFile] = useState<File | null>(null);
const [upload, setUpload] = useState<{ progress: number } | { error: string } | null>(null);
// Phones and slow networks: "fast" keeps SSIM >= 0.975 without the 3.5 MB AVIF encoder.
const { status, result, progress, error, cancel } = useOptiq(file, { mode: "fast", maxWidthOrHeight: 2560 });
// Preview the optimised image (or the original while compressing); revoke object URLs when they change.
const previewUrl = useMemo(() => (result ? URL.createObjectURL(result.blob) : file ? URL.createObjectURL(file) : undefined), [file, result]);
useEffect(
() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
},
[previewUrl],
);
async function submit() {
if (!file) return;
// Compression is optional: use the result when there is one, otherwise the untouched original.
const body = result && !result.usedOriginal ? result.blob : file;
const name = result ? result.fileName : file.name;
const type = result ? result.mimeType : file.type;
try {
setUpload({ progress: 0 });
const target = await presign(name, type, body.size);
await uploadToPresignedUrl(body, target, {
contentType: type, // must match what the URL was signed for
fileName: name,
onProgress: (p) => setUpload({ progress: p.total ? p.loaded / p.total : 0 }),
});
onUploaded(target.key ?? name);
setFile(null);
setUpload(null);
} catch (e) {
setUpload({ error: e instanceof Error ? e.message : String(e) });
}
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<input type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
{previewUrl && <img src={previewUrl} alt="" style={{ maxWidth: 320, display: "block", marginTop: 12 }} />}
{status === "compressing" && (
<p>
Optimising… {progress?.stage} <progress value={progress?.progress ?? 0} max={1} />{" "}
{progress?.bestBytes && file ? `≈ ${Math.round((1 - progress.bestBytes / file.size) * 100)}% smaller so far` : null}{" "}
<button type="button" onClick={cancel}>
Cancel
</button>
</p>
)}
{status === "done" && result && (
<p>
{result.usedOriginal
? "Already optimal — uploading the original."
: `${kb(result.originalBytes)} → ${kb(result.outputBytes)} (−${Math.round(result.sizeReductionRatio * 100)}%, ${result.outputFormat.toUpperCase()})`}
</p>
)}
{status === "error" && (
<p role="alert">
Could not optimise ({isOptiqError(error) ? error.code : "error"}). The original will be uploaded instead.
</p>
)}
{upload && "error" in upload && <p role="alert">Upload failed: {upload.error}. Your file is still selected — try again.</p>}
{upload && "progress" in upload && <progress value={upload.progress} max={1} />}
<button type="submit" disabled={!file || status === "compressing" || (upload !== null && "progress" in upload)}>
Upload
</button>
</form>
);
}Example: several files at once
One hook per file. Jobs share the default worker pool (two at a time), so a long list does not freeze the page or start dozens of encoders.
View example: GalleryUploader.tsx
tsx
// Many files at once: one hook per file (each runs in the shared worker pool, two at a time by default),
// per-file status, and a running total of the bytes saved.
import { useCallback, useEffect, useState } from "react";
import { useOptiq } from "optiq-image/react";
import type { CompressionResult } from "optiq-image";
type SetResult = (id: string, r: CompressionResult | null) => void;
function GalleryItem({ id, file, setResult, onRemove }: { id: string; file: File; setResult: SetResult; onRemove: () => void }) {
const { status, result, progress, cancel } = useOptiq(file, { mode: "balanced", maxWidthOrHeight: 2048 });
// setResult is stable (useCallback in the parent), so this only runs when this item's state changes.
useEffect(() => setResult(id, status === "done" && result ? result : null), [id, status, result, setResult]);
return (
<li>
<strong>{file.name}</strong>{" "}
{status === "compressing" && (
<>
<progress value={progress?.progress ?? 0} max={1} /> <button onClick={cancel}>Cancel</button>
</>
)}
{status === "done" && result && <span>−{Math.round(result.sizeReductionRatio * 100)}% · {result.outputFormat}</span>}
{status === "error" && <span>kept original</span>}
{status === "cancelled" && <span>cancelled</span>}{" "}
<button onClick={onRemove}>Remove</button>
</li>
);
}
export function GalleryUploader() {
const [files, setFiles] = useState<File[]>([]);
const [results, setResults] = useState<Record<string, CompressionResult | null>>({});
const keyOf = (f: File) => `${f.name}:${f.size}:${f.lastModified}`;
const setResult = useCallback<SetResult>((id, r) => setResults((all) => (all[id] === r ? all : { ...all, [id]: r })), []);
const done = Object.values(results).filter((r): r is CompressionResult => !!r);
const before = done.reduce((s, r) => s + r.originalBytes, 0);
const after = done.reduce((s, r) => s + r.outputBytes, 0);
return (
<div>
<input type="file" accept="image/*" multiple onChange={(e) => setFiles((fs) => [...fs, ...Array.from(e.target.files ?? [])])} />
<ul>
{files.map((f) => (
<GalleryItem key={keyOf(f)} id={keyOf(f)} file={f} setResult={setResult} onRemove={() => setFiles((fs) => fs.filter((x) => x !== f))} />
))}
</ul>
{done.length > 0 && (
<p>
{done.length}/{files.length} ready · {(before / 1048576).toFixed(1)} MB → {(after / 1048576).toFixed(1)} MB
</p>
)}
</div>
);
}