A walkthrough of client side uploading of multiple images in React/Remix.
In this brief tutorial, I walk you through how I built a multi-image uploader using Remix and react-hooks.
-
In React, you will mostly need to import necessary modules for your component to work. The main imports to note here are
useState
,useEffect
anduseRef
which are exported from thereact
module. They are called React hooks and they are special functions that help us to manage state, event lifecycles and manipulate DOM elements. -
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 aMAX_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:
const fileUploadRef = useRef<HTMLInputElement>(null);
<input type="file" ref={fileUploadRef} accept=".png, .jpg, .jpeg, .svg" onChange={handleFileInput} name="itemImages" id="itemImages" multiple={multiple} hidden />
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
.
(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 ouritemId
andstoreId
are present otherwise invariant will throw an error. -
react-i18next
provides theuseTranslation
hook. As the site supports internationalization, we are using react-i18next to allow us to switch between different json objects of translations.
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 constantuploaded
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 constantMAX_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 calledconstants.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 ifimageList
is not empty, then usesArray.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 theimg
tag even before we upload to our preferred storage source.
const fileUrl = (file: File) => URL.createObjectURL(file);
- 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 theform
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 usingArray.map()
to loop through it and render every image to be shown to the user.
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!!