File upload

A native file input, an image preview via an object URL, and a real progress bar. Progress needs upload events, so this uses XMLHttpRequest (the one place fetch can't report progress) wrapped in a small promise.

import { html, mount, createState, onCleanup } from "@zoijs/core";

function upload(file, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("POST", "/api/upload");
    xhr.upload.addEventListener("progress", (e) => {
      if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
    });
    xhr.addEventListener("load", () =>
      xhr.status < 400 ? resolve(JSON.parse(xhr.responseText)) : reject(new Error(`HTTP ${xhr.status}`))
    );
    xhr.addEventListener("error", () => reject(new Error("Network error")));
    const body = new FormData();
    body.append("file", file);
    xhr.send(body);
  });
}

function Uploader() {
  const file = createState(null);
  const previewUrl = createState("");
  const progress = createState(0);
  const status = createState("idle"); // idle | uploading | done | error

  // Object URLs must be revoked or they leak memory.
  const setPreview = (url) => {
    const old = previewUrl.peek();
    if (old) URL.revokeObjectURL(old);
    previewUrl.set(url);
  };
  onCleanup(() => setPreview(""));

  const onPick = (e) => {
    const f = e.target.files[0] || null;
    file.set(f);
    status.set("idle");
    progress.set(0);
    setPreview(f && f.type.startsWith("image/") ? URL.createObjectURL(f) : "");
  };

  const start = async () => {
    const f = file.get();
    if (!f) return;
    status.set("uploading");
    try {
      await upload(f, (pct) => progress.set(pct));
      status.set("done");
    } catch {
      status.set("error");
    }
  };

  return html`
    <section>
      <input type="file" accept="image/*" onchange=${onPick} />

      ${() => previewUrl.get() && html`<img src=${() => previewUrl.get()} alt="Preview" style="max-width:200px" />`}

      <button onclick=${start} disabled=${() => !file.get() || status.get() === "uploading"}>
        Upload
      </button>

      ${() =>
        status.get() === "uploading" &&
        html`<progress value=${() => progress.get()} max="100">${() => progress.get()}%</progress>`}

      ${() => status.get() === "done" && html`<p>✓ Uploaded.</p>`}
      ${() => status.get() === "error" && html`<p class="error">Upload failed.</p>`}
    </section>
  `;
}

mount(Uploader, "#app");

Watch-outs#

  • Revoke object URLs. URL.createObjectURL(file) allocates memory that lives until you revokeObjectURL it. setPreview revokes the previous URL on every change, and onCleanup revokes the last one on unmount — so previews never leak.
  • fetch can't do upload progress. It has no upload-progress events, so a real progress bar means XMLHttpRequest. Everything else (the body, the response) is the same FormData you'd send with fetch.
  • src is guarded. A blob: object URL is a safe image source and Zoijs allows it; it blocks dangerous schemes on src/href. Set accept and validate type/size before sending — never trust the client, but don't render untrusted bytes as anything but an image either. See Security.
  • One file shown; many is the same shape. For multi-file, iterate e.target.files, keep an array of { file, url, progress, status } in state, and render it with each — one row per file, each with its own progress.

Next: Data table.