How toUpload Images in a Remix Application

If you want to add file upload to let users send you images, e.g., for an avatar, Remix provides a few (still unstable for some reason) tools to do so.

tl;dr: Here's a repository with an app using this code: https://github.com/sergiodxa/remix-demo-file-upload

Let's start by importing the file upload functions and other things we'll need.

import {
  json,
  unstable_createMemoryUploadHandler,
  unstable_parseMultipartFormData,
  unstable_createFileUploadHandler,
  unstable_composeUploadHandlers,
} from "@remix-run/node";
import type { NodeOnDiskFile, ActionFunctionArgs } from "@remix-run/node";
import { useFetcher } from "@remix-run/react";
import { useEffect, useState } from "react";

Now, let's use them in an action function. We'll use unstable_parseMultipartFormData instead of request.formData() to parse the request body, allowing us to use an upload handler to save the uploaded files.

As our upload handler, we'll compose unstable_createFileUploadHandler to save the files to disk and unstable_createMemoryUploadHandler to keep any other FormData entry in memory.

The return of our action will be a JSON object with the list of files we just uploaded, including the name and the URL.

export async function action({ request }: ActionFunctionArgs) {
  let formData = await unstable_parseMultipartFormData(
    request,
    unstable_composeUploadHandlers(
      unstable_createFileUploadHandler({
        // Limit file upload to images
        filter({ contentType }) {
          return contentType.includes("image");
        },
        // Store the images in the public/img folder
        directory: "./public/img",
        // By default, `unstable_createFileUploadHandler` adds a number to the file
        // names if there's another with the same name; by disabling it, we replace
        // the old file
        avoidFileConflicts: false,
        // Use the actual filename as the final filename
        file({ filename }) {
          return filename;
        },
        // Limit the max size to 10MB
        maxPartSize: 10 * 1024 * 1024,
      }),
      unstable_createMemoryUploadHandler(),
    ),
  );

  let files = formData.getAll("file") as NodeOnDiskFile[];
  return json({
    files: files.map((file) => ({ name: file.name, url: `/img/${file.name}` })),
  });
}

Now, let's create a hook to contain our logic. We don't really need it to be a hook, but for the sake of the example and to reduce code block size, we'll make it one.

This hook will use a Remix fetcher to let us upload the files from the browser.

We'll expose submit, isUploading if the state is not idle, and the list of images combining the ones we're uploading and the ones we've already uploaded.

function useFileUpload() {
  let { submit, data, state, formData } = useFetcher<typeof action>();
  let isUploading = state !== "idle";

  let uploadingFiles = formData
    ?.getAll("file")
    ?.filter((value: unknown): value is File => value instanceof File)
    .map((file) => {
      let name = file.name;
      // This line is important; it will create an Object URL, which is a `blob:` URL string
      // We'll need this to render the image in the browser as it's being uploaded
      let url = URL.createObjectURL(file);
      return { name, url };
    });

  let images = (data?.files ?? []).concat(uploadingFiles ?? []);

  return {
    submit(files: FileList | null) {
      if (!files) return;
      let formData = new FormData();
      for (let file of files) formData.append("file", file);
      submit(formData, { method: "POST", encType: "multipart/form-data" });
    },
    isUploading,
    images,
  };
}

Now we can build our route component using this hook:

export default function Component() {
  let { submit, isUploading, images } = useFileUpload();

  return (
    <main>
      <h1>Upload a file</h1>

      <label>
        {/* Here we use our boolean to change the label text */}
        {isUploading ? <p>Uploading image...</p> : <p>Select an image</p>}

        <input
          name="file"
          type="file"
          // We hide the input so we can use our own label as a trigger
          style={{ display: "none" }}
          onChange={(event) => submit(event.currentTarget.files)}
        />
      </label>

      <ul>
        {/*
         * Here we render the list of images, including the ones we're uploading
         * and the ones we've already uploaded
         */}
        {images.map((file) => {
          return <Image key={file.name} name={file.name} url={file.url} />;
        })}
      </ul>
    </main>
  );
}

Finally, we need to create a component to render the images. We'll use a component to revoke the object URL and blur the image while it's being uploaded.

Something important is that the key needs to be the same between the uploading image and the already uploaded one. This will let us keep the same Image component instance and allow us to do a simple effect once the image is loaded. For this, we use file.name, and it's the reason we disabled avoidFileConflicts in the upload handler. Another option could be to create a unique ID client-side before the upload.

function Image({ name, url }: { name: string; url: string }) {
  // Here we store the object URL in a state to keep it between renders
  let [objectUrl] = useState(() => {
    if (url.startsWith("blob:")) return url;
    return undefined;
  });

  useEffect(() => {
    // If there's an objectUrl but the `url` is not a blob anymore, we revoke it
    if (objectUrl && !url.startsWith("blob:")) URL.revokeObjectURL(objectUrl);
  }, [objectUrl, url]);

  return (
    <img
      alt={name}
      src={url}
      width={320}
      height={240}
      style={{
        // Some styles; here we apply a blur filter when it's being uploaded
        transition: "filter 300ms ease",
        filter: url.startsWith("blob:") ? "blur(4px)" : "blur(0)",
      }}
    />
  );
}

With this, if the user clicks the label or drops a file, it will trigger the file input, which will initiate our file upload logic.