Build a Modern, Customized File Uploading User Interface in React with Plain CSS

Now is the time to make React do great things

Building a user interface around a file input component is a very handy skill to learn as you can go from a ‘90s look to a more modern finish to complement your web pages that depend on it — especially when we can’t style it like any normal HTML element. When users are using your technology, they’re not just using it — they’re probably judging your app and its technology as well.

Here’s the thing: we can easily show them a file input, let them select files using the default HTML element, have them submit files, and call it a day. But what is happening in between? What do users like to see when something is happening? An interface that doesn’t tell them anything, or an interface that tells them everything?

What if the user’s internet disconnects? What if the server doesn’t respond with anything? What if file 8 of 14 is big for them? What if the user was waiting for the upload process to finish for 10 minutes and wanted to see how far it has gotten from there? Or which files have already been uploaded?

In a previous tutorial (you can find it if you search my posts), I went over building the logic of putting this API in place. The point of that post was to teach the logic. You can stop there and build your own custom user interface around it. Or you can build the logic part yourself and read this piece for ideas on how to implement UX for any file uploading component. These posts were created for two separate reasons but are perfectly compatible. I’m just going to provide the logic in this post so we can focus on the user interface. The decision is yours!

While I was coding the user interface, it was getting long and I was contemplating if I should bring the number of components down and show a basic UX version. However, since a lot of pieces these days don’t go deeply into a subject, I’d like to take this opportunity to look more deeply into the implementations and have a bit of fun.

I was thinking of using my favorite CSS library styled-components to make this tutorial, however, I decided not to in order to demonstrate how a complex user interface can be built without additional tools. The tools are just a convenience. You just need to learn a little bit of CSS, not the tools.

And last but not least, here is a preview of what we will be building in this post:

Without further ado, let’s get started!

In this tutorial, we are going to quickly generate a React project with create-react-app.

Go ahead and create a project using the command below. For this tutorial, I’ll call our project upload-app:

npx create-react-app upload-app

Once it’s done, go into its directory:

cd upload-app

I promised to just provide the logic of the file uploading implementation, so we can immediately get started with building the user interface. So here is a custom hook we’ll be using, called useApp.js :

src/useApp.js

Explanation

Here is a quick summary of what is going on here.

When users select files, the onChange handler is invoked. The e argument contains the files we want, accessible by e.target.files . These will be the files that will be rendered one by one in the interface. However, this files object isn’t an array — it’s actually a FileList . This is a problem because we can’t simply map over this or we’ll get an error. So we convert it to an array and attach it to state.files, allowing the UI to render them row by row in the UI. When the user submits the form, the onSubmit hander gets invoked. It dispatches an action which sends a signal to one or more useEffects that it’s time to start. There are several useEffects and each of them are assigned different tasks and conditions. One is used to start the flow, one is used to continue the flow, and one is used to end the flow.

What we are going to do next is open up the App.js file and replace the default code with:

And here is our starting CSS file: src/styles.css

If you run the app, it will look like this:

This is pretty basic. There’s really no information about these images and the UI looks like a page from the 90s.

When you click submit, you can check in the console messages just to be sure that they are being processed one by one:

Once it finishes, you can continue the flow of the app with anything — such as redirecting the user to a success page or showing them dog pictures in a modal.

The problem is that the user doesn’t know what is going on— they could be waiting for 10 minutes and the page would still stay the same.

So we’re going to change this up a bit. We want our users to be up-to-date with everything that is going on, from the moment of instantiation to the end of the uploading process.

Let’s customize the file input so that it looks better. We want our users to think of us as unique and the best, so we must go above and beyond!

Currently, our file input looks like this:

Now, we don’t want the user to hit the exit button and never come back, so we have to design this further. There are several ways to customize a file input.

This file input component that we are going to make next won’t actually be the real input element, but it will disguise itself as the input element by allowing the file browser to be opened when a user clicks on it.

Create a file called FileUploader.js and place this code in it:

The real file input is the child of the root div element here. The triggerInput is a function that allows us to tap into the inputRef ref that’s attached to the file input element. We will look at this in the hook shortly.

Now, if we render this component and pass in a children , the hiddenInputStyle will be applied to the real file input so that it will forcefully show our custom component instead to the UI. This is how we override the default file input in the interface.

Inside our hook we defined the triggerInput handler inside: src/useApp.js

const triggerInput = (e) => {

e.persist()

inputRef.current.click()

}

Returning it at the end so the caller can access it: src/useApp.js

return {

...state,

onSubmit,

onChange,

triggerInput,

}

Great! Now we are going to make the component that will disguise itself as the real file input. It can be anything, but for the sake of this tutorial, it will be a mini “screen” to the user — guiding them to upload their files and taking them to the next screen by using graphical and textual updates. Since we were rendering children in the render method of FileUploader , we can render this screen as a child of FileUploader . We want this whole screen to be able to open the file browser when we need it to.

This screen will display text with a background. I’m going to use an image as a background by creating a folder called images in the src directory. I'll be placing images used throughout the tutorial here so we can import images from it.

Make another file called FileUploaderScreen.js :

Here are the styles I used for the component:

Since we’re allowed to pass in the imported image as a string to the backgroundImage style property, I used it as the value for the background image.

We mentioned that we want this screen to open up a file browser when clicked so we’re going to have to render this inside the FileUploader.

Let's go ahead and put this FileUploader and FileUploaderScreen inside our App.js file now:

src/App.js

Now when you click the file upload screen, you should be able to select files:

Let's make the background image switch to a different one when the user selects files.

How do we do that?

This is where we have to use that status state property we defined in our custom hook earlier:

const initialState = {

files: [],

pending: [],

next: null,

uploading: false,

uploaded: {},

status: IDLE,

}

If you look back at our useEffects and reducer, we made the useEffects dispatch actions depending on what was happening:

src/useApp.js

src/useApp.js

In addition, if you look back at the onChange handler, you will see one of these action types being dispatched:

Since we know that dispatching ‘load’ will update state.status to ‘LOADED’ we can use that in our FileUploaderScreen to change images whenever state.status updates to ‘LOADING’.

So what we’ll do is use a switch case to assign the src to the backgroundImage style property depending on the value of state.status:

We might as well define some other images to use for other statuses as well:

Every time the user does something, the image will be different. This is so that we don’t bore the user so they’re constantly occupied. Do whatever you want to make them stay on your website instead of bouncing away. Just keep it rated G of course :).

Anyways, If you try to select files right now the screen will not update. That is because we need to pass down the status prop to FileUploaderScreen:

src/App.js

I don’t know about you, but I really think those ugly, disproportionate thumbnails need to be tackled next. This isn’t the ‘90s anymore, we have React!

So what we’re going to do is we’re going to scale them down to fit in file row components (list of rows). In each row, the thumbnail will have a width size of 50px and the height size of 50px. This will ensure that we have enough room on the right to display the file name and file sizes to the user in a clean and professional way.

Create a new file called FileRow.js and add this in:

Styles I used:

Here’s what’s happening:

We defined a FileRow component that will receive the necessary props to render its children components. file, src, id, and index comes from the state.files array set by the onChange handler inside our useApp custom hook. isUploading’s purpose here is to render an “Uploading…” text and a loading spinner right above it when a file is being uploaded. isUploaded’s purpose is to shade out rows when their file object is inside state.uploaded — mapped by their id. (This was why we had state.uploaded if you were wondering) Since we don’t want each row to render each time a state is updated, we had to wrap it with a React.memo to memoize the props so that they update only when index, isUploading or isUploaded changes. While these files are uploading, these props will never change unless something important happened, so it is safe to apply these conditions. getReadableSizeFromBytes was provided so that we render a human-readable file size. Otherwise, users will be reading numbers like 83271328. Spinner is a loading spinner.

For the purposes of this tutorial, I used react-md-spinner. Also, I used the classnames package to combine/conditionally render class names for conditional styling for more ease of control.

Note: If you decide to follow through with react-md-spinner/classnames and get this error:

Cannot find module babel-preset-react-app/node_modules/@babel/runtime

Then you might need to install @babel/runtime.

src/Spinner.js

Styles I used:

Now if you try to select files, the interface looks a lot smoother than before:

What we need to do next is make the screen display textual updates so that users aren’t confused about what is happening. Otherwise, the file uploader screen is useless because it’s just rotating images right now.

The trick here is to use the powerful state.status property like we did with the image rotations.

Knowing this, we can make it render custom components on each status update.

Go to the FileUploaderScreen.js file and start by conditionally rendering the "init/idle" component:

It seems like our image is a little bright right now. So we’re going to define a couple of class styles to update brightnesses depending on which image is rendered:

src/FileUploaderScreen.js

It should be easier to see now:

Using the same concept as we did with the Init component earlier, we can implement the rest of the components the same way:

src/FileUploaderScreen.js

Here are all the styles used for them:

The Loaded component is rendered when state.status' value is ‘LOADED’. The odd thing here is that the “Upload More” button is being wrapped by the FileUploader that we created in the beginning. “What is that doing there?” you might ask.

After the file upload screen gets past the initial step, we no longer want the entire component to trigger the file browser anymore. I’ll go over this a little more very soon.

The Pending component is used to show that uploading is in process so that they know something is happening while they are waiting. This part is very important for our users!

The Success component is displayed immediately after the upload process is done.

And finally, the Error component is displayed when there was an error while uploading. This is to help the user understand what the current situation is without having them find out themselves.

The next thing we are going to do is update App.js :

src/App.js

We added a new function getFileUploaderProps to our useApp hook:

The reason why we extracted this part out to a separate function is that in the initial file uploader screen we applied the triggerInput and onChangehandler directly on the root component in FileUploader. After the first screen changes, we don’t want the whole file uploader screen component to trigger file browser anymore (since we did provided an Upload More button on the second screen).

That is why we just had this in the App component:

const initialFileUploaderProps = getFileUploaderProps({

triggerInput: status === 'IDLE' ? triggerInput : undefined,

onChange: status === 'IDLE' ? onChange : undefined,

})

And used it to spread its arguments to FileUploader:

<FileUploader {...initialFileUploaderProps}>

<FileUploaderScreen

triggerInput={triggerInput}

getFileUploaderProps={getFileUploaderProps}

files={files}

pending={pending}

status={status}

/>

</FileUploader>

Now, FileUploader will have all 4 arguments passed in like normal but will have undefined values from props.triggerInput and props.onChange for the rest of the screens. In react, onClick handlers won’t fire when they are undefined. This disables the click handler so we can instead assign the Upload More button to be the new handler for selecting files.

Here’s what the app looks like now:

So far so good. But it seems like the loading spinner in the file rows list are awkwardly pushing things to the side when their file is being uploaded.

Did you notice there was a flex-center property applied on the Spinner component?

Yes, we’re missing the CSS. So let's slap that right into the CSS file: