Uploading images the right way

Uploading images the right way

·

5 min read

Images have always been an important part of any web application but I have always written messy logic for uploading images. In my first job, we were using React Class components and I remember creating a big upload function, a few states for handling loading, errors, etc., and a ton of if-else conditions for the rendering part. I am sure you must have written or seen something like it too. But then React hooks were released and I have finally found an abstraction after a few iterations that works well and doesn’t look half bad.

We will create a useUploadImage hook. It is important to only keep relevant logic inside the hook and leave all other decisions to the component calling it like what to do after a successful or failed upload so that our hook stays reusable. We will then create a uploadImage method inside the hook. This method has four parameters - the file to upload, the API endpoint you’re uploading to, a callback function to execute after a successful upload, and an optional callback to execute if any error occurs.

Now when making any POST request, one needs to encode the body of the requested data. For uploading files, API endpoints use “multipart/form-data” encoding. So let’s go ahead and create a new FormData object and append the file along with any other information that your API endpoint asks for (I have taken cloudinary as an example). I am assigning any as response and error type just for the sake of this example.

export const useUploadImage = () => {
 const uploadImage = ({
    file,
    uploadURL,
    onUploadComplete,
    onUploadError,
  }: {
    file: File;
    uploadURL: string;
    onUploadComplete: (response: any) => void;
    onUploadError?: (error: any) => void;
  }) => {
    const formData = new FormData();
    formData.append("file", file);

    // Example: When using cloudinary,
    formData.append("api_key", "1234");
    formData.append("timestamp", "1315060510");
    formData.append("folder", "demo");
    formData.append("signature", "bfd09f95f331f558cbd1320e67aa8d488770583e");
  }
}

Moving on, we will now create a uploadRef object using the useRef hook because it gives us a mutable object with a .current property that can be manipulated easily without worrying about unexpected re-renders, unlike states.

For creating the upload request, We will be using the good old XHR so that we can get the upload progress quickly in real time and use it to show those pretty progress bars in the UI. To achieve that, we will add an event listener on its upload object to listen for the progress event and then set it in a state to easily expose it out of the event listener.

import { useRef, useState } from "react";

export const useUploadImage = () => {
  const [uploadProgress, setUploadProgress] = useState(0);
  const uploadRef = useRef<XMLHttpRequest | null>(null);

  const uploadImage = ({
    file,
    uploadURL,
    onUploadComplete,
    onUploadError,
  }: {
    file: File;
    uploadURL: string;
    onUploadComplete: (response: any) => void;
    onUploadError?: (error: any) => void;
  }) => {
    const formData = new FormData();
    formData.append("file", file);

    uploadRef.current = new XMLHttpRequest();
    uploadRef.current.open("PUT", uploadURL);
    uploadRef.current.upload.addEventListener(
      "progress",
      ({ loaded, total }) => {
        setUploadProgress((loaded * 100) / total);
      },
    );
  }
}

Finally, we will initiate the request and then attach an async function to the onload event of XHR to handle the upload response object and execute the onUploadComplete callback that we have defined as one of the parameters. Let's also attach a function to the onerror event to deal with any errors during upload and execute the onUploadError callback inside it. We will also return the method we created along with the uploadProgress state.

import { useRef, useState } from "react";

export const useUploadImage = () => {
  const [uploadProgress, setUploadProgress] = useState(0);
  const uploadRef = useRef<XMLHttpRequest | null>(null);

  const uploadImage = ({
    file,
    uploadURL,
    onUploadComplete,
    onUploadError,
  }: {
    file: File;
    uploadURL: string;
    onUploadComplete: (response: any) => void;
    onUploadError?: (error: any) => void;
  }) => {
    const formData = new FormData();
    formData.append("file", file);

    uploadRef.current = new XMLHttpRequest();
    uploadRef.current.open("PUT", uploadURL);
    uploadRef.current.upload.addEventListener(
      "progress",
      ({ loaded, total }) => {
        setUploadProgress((loaded * 100) / total);
      },
    );    
    uploadRef.current.send(formData);

    uploadRef.current.onload = async () => {
      const response = uploadRef.current?.response;
      if (response) {
        const jsonResponse = JSON.parse(response);
        uploadRef.current = null;
        onUploadComplete(jsonResponse.data || jsonResponse);
      }
    };
    uploadRef.current.onerror = async () => {
      const error = uploadRef.current?.response;
      if (onUploadError) {
        onUploadError(error);
      }
    };
  }

  return {
    uploadImage,
    uploadProgress
  }
}

This looks pretty good but there are still some edge cases left that we need to cover. Firstly, what if the component calling this hook unmounts in the middle of the upload? We should abort the request in that case or it might lead to issues like a memory leak or something else, right?
Also, it would be better to wrap our function in a useCallback hook to cache our function between re-renders.
So the final code would look like this:

import { useRef, useState, useEffect } from "react";

export const useUploadImage = () => {
  const [uploadProgress, setUploadProgress] = useState(0);
  const uploadRef = useRef<XMLHttpRequest | null>(null);

  useEffect(() => {
    return () => {
      if (uploadRef.current) {
        uploadRef.current.abort();
      }
    };
  }, []);

  const uploadImage = useCallback(
    async ({
      file,
      uploadURL,
      onUploadComplete,
      onUploadError,
    }: {
      file: File;
      uploadURL: string;
      onUploadComplete: (response: any) => void;
      onUploadError?: (error: any) => void;
    }) => {
      const formData = new FormData();
      formData.append("file", file);

      uploadRef.current = new XMLHttpRequest();
      uploadRef.current.open("PUT", uploadURL);
      uploadRef.current.upload.addEventListener(
        "progress",
        ({ loaded, total }) => {
          setUploadProgress((loaded * 100) / total);
        },
      );
      uploadRef.current.send(formData);

      uploadRef.current.onload = async () => {
        const response = uploadRef.current?.response;
        if (response) {
          const jsonResponse = JSON.parse(response);
          uploadRef.current = null;
          onUploadComplete(jsonResponse.data || jsonResponse);
        }
      };
      uploadRef.current.onerror = async () => {
        const error = uploadRef.current?.response;
        if (onUploadError) {
          onUploadError(error);
        }
      };
    },
    [],
  );

  return {
    uploadImage,
    uploadProgress
  }
}

This hook can be extended to also support video uploads, let me know if you want me to make a part 2 with that. I hope you found this helpful, would love to hear your thoughts and suggestions in the comments. Have a bug-free day.