Skip to content

Instantly share code, notes, and snippets.

@j33n
Last active April 28, 2023 20:59
Show Gist options
  • Save j33n/84e14dd1ec5957ab252cb3aa4b055d70 to your computer and use it in GitHub Desktop.
Save j33n/84e14dd1ec5957ab252cb3aa4b055d70 to your computer and use it in GitHub Desktop.
Multi Image Upload using Remix

Multi Image Upload Using Remix/React

A walkthrough of client side uploading of multiple images in React/Remix.

Code

Sample Code

Introduction

In this brief tutorial, I walk you through how I built a multi-image uploader using Remix and react-hooks.

Imports

  • In React, you will mostly need to import necessary modules for your component to work. The main imports to note here are useState, useEffect and useRef which are exported from the react module. They are called React hooks and they are special functions that help us to manage state, event lifecycles and manipulate DOM elements.

    Line 1 - 22

  • We are using useState, to manage state in our component.

    For instance on the following line:

    const [imageList, setImageList] = useState<File[]>([]);
  • We are initializing a new state for our component that will be used to store files. In our case we don't store actual files but blobs. The useState function returns an array composed of the current state and a function to manipulate that state.

  • useEffect is for keeping track of our component state and tree for updates, it is very helpful when tracking event changes i.e, DOM changes, animations, api calls, ... We are using it to compare our imageList array and a MAX_COUNT constant whenever there is a state change in imageList.

    useEffect(() => {
        setFileLimit(imageList.length >= MAX_COUNT);
    }, [imageList]);
  • Lastly, useRef is used to target a specific element in the DOM and perform different actions to the element's properties depending on the use case.

    In our case, we are using useRef to target our input element:

    Line 38

    const fileUploadRef = useRef<HTMLInputElement>(null);

    Line 134 - 133

    <input
      type="file"
      ref={fileUploadRef}
      accept=".png, .jpg, .jpeg, .svg"
      onChange={handleFileInput}
      name="itemImages"
      id="itemImages"
      multiple={multiple}
      hidden
    />

Component

A React component can be a class or a function depending on your choice or use case. We opted for a function in this case.

  • We will first declare our React states:

    const [imageList, setImageList] = useState<File[]>([]);
    const fileUploadRef = useRef<HTMLInputElement>(null);
    const [imageHovered, setImageHovered] = useState<number | null>(null);
    const [fileLimit, setFileLimit] = useState(false);
    const [uploadError, setUploadError] = useState("");
  • And our input file ref, which we will default to null

    const fileUploadRef = useRef<HTMLInputElement>(null);
  • If you're using JSX you can ignore the values enclosed between <>, they are types because we are using TypeScript. This would change our variable declarations to become:

    const [imageList, setImageList] = useState([]);
    const fileUploadRef = useRef(null);
    const [imageHovered, setImageHovered] = useState(null);
    const [fileLimit, setFileLimit] = useState(false);
    const [uploadError, setUploadError] = useState("");
    const fileUploadRef = useRef(null);

    But also the file extension becomes .jsx rather than .tsx.

Other modules involved

(This is optional and specific to our example's use case)

  • Remix provides us a React hook called useParams. It is the same as the one found in React Router. useParams returns an object of all the argument available in your url.

  • invariant is a small module that helps us validate different variables and throws an error if the comparison fails. We are using invariant to validate if both our itemId and storeId are present otherwise invariant will throw an error.

  • react-i18next provides the useTranslation hook. As the site supports internationalization, we are using react-i18next to allow us to switch between different json objects of translations.

Custom Functions

handleFileInput: This function will handle our event change when we select image(s) to be uploaded using a file input.

  • First, let's prevent normal behaviour of the input element so that we can apply our own magic, then reset the error state so that any previous errors can be removed.

    const handleFileInput = () => {
      event.preventDefault();
      setUploadError("");
      (...)
    }
  • Second, let's check if our event contains files. Since the input type of file returns FileList instead of our usual array. We will use Array.prototype.slice.call() to get us an array. Then we can declare a new constant uploaded that will contain our files and use the spread operator to assign it our imageList. We are creating a new copy of the array because React doesn't recommend mutating the state.

      if (event.currentTarget.files && event.currentTarget.files.length > 0) {
          const files = Array.prototype.slice.call(event.target.files);
          const uploaded = [...imageList];
          let limitExceeded = false;
          (...)
      }
  • We can use files.some() to loop through our files and check if none of our files have the same name. findIndex helps us search our array of files and returns an index of an element matching a condition otherwise -1.

      files.some((file) => {
        if (uploaded.findIndex((f) => f.name === file.name) === -1) {
        }
      }
  • If our condition passes and we don't have an image with the same name, let's push our new upload to the uploaded array.

  • As our uploaded array keeps growing we need to validate if our limit is not exceeded by using our constant MAX_COUNT. Ideally this value should be coming from some type of storage since it's likely to be changed depending on user or system preference. In our case, it should be in a separate file called constants.ts or whichever name makes sense. This allows us to avoid accidental modifications and it's accessible easily accross the codebase as well.

  • Lastly, let's set different states depending on the condition met and bring everything together.

    const handleFileInput = (event: React.ChangeEvent<HTMLInputElement>) => {
      event.preventDefault();
      setUploadError("");
      if (event.currentTarget.files && event.currentTarget.files.length > 0) {
        const files = Array.prototype.slice.call(event.target.files);
        const uploaded = [...imageList];
        let limitExceeded = false;
    
        files.some((file) => {
          if (uploaded.findIndex((f) => f.name === file.name) === -1) {
            uploaded.push(file);
    
            if (uploaded.length > MAX_COUNT) {
              limitExceeded = true;
              setUploadError(`Only ${MAX_COUNT} images allowed`);
            }
          } else {
            setUploadError(`Image ${file.name} already uploaded`);
          }
        });
    
        if (!limitExceeded) setImageList(uploaded);
      }
    };
  • handleRemoveImage: This function allows us to remove an image using it's index in case we change our mind before uploading. This function checks if imageList is not empty, then uses Array.filter to create a new array which we will store in our imageList. Again, here we are creating a new array because it's not advisable to mutate the React state.

    const handleRemoveImage = (idx, e) => {
      e.preventDefault();
      if (!imageList || imageList.length === 0) return null;
      const newImageList = imageList.filter((image, index) => idx !== index);
      setImageList(newImageList);
    };
  • fileUrl: This function allows us to convert a file into a url that can be rendered by the img tag even before we upload to our preferred storage source.

  const fileUrl = (file: File) => URL.createObjectURL(file);

JSX

  • components in React are normal functions or classes that return jsx.
  • Since we are using Remix we can call our backend directly using the action argument of the form tag. It's important not to miss this part if working with Remix. If you have worked with monolith applications you most likely have encoutered this approach.
  <form
    method="post"
    action={`/app/stores/${storeId}/items/${itemId}/uploads`}
    encType="multipart/form-data"
  >
    (...)
  </form>
  • Notice our tag elements, starts with <Styled, this is a naming convention that tells us that we are rendering an element styled using styled-components.

  • The main part of our jsx body is line 112 - 131. If you remember our imageList array, we are using Array.map() to loop through it and render every image to be shown to the user.

Conclusion

Eventhough this was implemented in Remix, the same logic works for React, the only exception is that you have to adjust how you call your backend.

This is a small tutorial on image upload with React or Remix. There's a lot of room for improvement both in terms of the code and features. I hope it can serve as a foundation the next time you're building your own image upload feature. Thank you!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment