DEV Community

BlueAlien99
BlueAlien99

Posted on

Sitewide file uploads with React Context

React is awesome, right? It's fast, lightweight and comes with a relatively simple API... at a cost. Up until recently React didn't have a built-in solution for application state management. For many years Redux was a go-to library which solved that problem. But things have changed, React evolved and now we've got Hooks! One of them is especially interesting. Everyone, welcome useContext!

If you're building a small web app, you might not need to use Redux for managing your application's state, React already comes with Context and in this tutorial I'll show you how to handle sitewide file uploads, so that you can freely navigate in your app without having to worry that that 1GB file which you've been uploading for the last half an hour suddenly stops and you'll need to upload it again... and again.

Prerequisites

Basic knowledge of React and functional components, TypeScript and frontend development is necessary.

Defining interfaces

Let's start by creating a new file named UploadContext.ts. This file will in fact contain two contexts. I'll explain that in a moment, but because we use TypeScript, let's define the necessary interfaces. I hope they'll make everything easier to understand (if you're familiar with TS).

type FetchState = 'idle' | 'pending' | 'success' | 'failed';

export interface UploadManager {
  upload: (files: FileList) => void;
  addRefreshCallback: (cb: () => void) => void;
  removeRefreshCallback: (cb: () => void) => void;
}

export interface UploadFile {
  id: number;
  name: string;
  status: FetchState;
  loaded: number;
  total: number;
}
Enter fullscreen mode Exit fullscreen mode

FetchState is a helper type used in property status of UploadFile interface to indicate current file uploading status.

UploadManager is an interface which will be used by the first context. It provides 3 functions. upload is used to start a file (or files) upload and the other two are used to add and remove callbacks which are called when any file upload finishes. It can be useful if you have a component which lists all the files on a server and want it to automatically refresh (fetch files) whenever a new file is uploaded.

UploadFile is an interface which describes all the necessary information about a file which is currently being uploaded. It'll be used by the second context.

Creating contexts

Now we'll create two contexts.

const UploadContext = createContext<UploadManager>({
  upload: () => {
    throw Error('UploadContext has no Provider!');
  },
  addRefreshCallback: () => {
    throw Error('UploadContext has no Provider!');
  },
  removeRefreshCallback: () => {
    throw Error('UploadContext has no Provider!');
  },
});

const UploadFilesContext = createContext<UploadFile[]>([]);
Enter fullscreen mode Exit fullscreen mode

You might be wondering: What is that? Why do I need that? Why are those function doing literally nothing?. You're right! Let me explain. In a few moments we'll define a context wrapper -- a component. As of now the contexts were created outside of any component and that means that we have no data to pass into them. If we were using JavaScript, we could've written:

const UploadContext = createContext({});
Enter fullscreen mode Exit fullscreen mode

...but we can't, because TypeScript will complain... which is a good thing! That means, that if we forget to pass correct values to our contexts in context wrapper, we'll be provided with default values which we've just defined. That way, if we try to upload a file, we'll get a meaningful message instead of just Uncaught TypeError: uploadManager.upload is not a function.

Now it's a perfect moment to explain why we need two contexts. We could put everything into a single context and it would work, but that would have a negative impact on the performance. If a component uses values provided by a context, it will rerender every time those values change. Now, let's assume we have two components: a large page component which contains a button for file uploading and another small component which displays current upload progress. File upload progress will change many times a second, because (as you'll see later) we'll keep track of how many bytes were already uploaded. If we decided to put file data into UploadContext, our large page component would rerender many times a second during file upload, because data in a context would change that often. This would be terrible for the performance of our app!

Custom hooks

How to get data from a context? Just use useContext! But to make it more readable and easier to use, we'll define two custom hooks, one for every context. Custom hooks sounds scary, right? Take a look:

export const useUpload = (): UploadManager => useContext(UploadContext);
export const useUploadFiles = (): UploadFile[] => useContext(UploadFilesContext);
Enter fullscreen mode Exit fullscreen mode

Now instead of writing useContext(NameOfYourContext) you can write useYourContext(). Awesome!

Context wrapper component

As I've mentioned earlier, we need a component which will provide data to the contexts.

interface UploadContextWrapperProps {
  children: JSX.Element | JSX.Element[];
}

export function UploadContextWrapper({ children }: UploadContextWrapperProps): JSX.Element {

  // more code will go there in a moment

  return (
    <UploadContext.Provider value={uploadManager}>
      <UploadFilesContext.Provider value={files}>
        {children}
      </UploadFilesContext.Provider>
    </UploadContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Our component accepts children because only components that are inside context providers can receive context data. You're probably getting errors saying that uploadManager and files are not defined. That's fine, we'll define them in a moment. But first let's elaborate a little bit more about how and where to include UploadContextWrapper. If you're building your app with Gatsby, go to Layout.tsx. It should look like this:

export default function Layout({ children }: PageProps): JSX.Element {
  return (
    <>
      <GlobalStyles />
      <Typography />

      <SiteStyles>
        <UploadContextWrapper>
          <Sidebar />

          <PageWrapper>{children}</PageWrapper>
        </UploadContextWrapper>
      </SiteStyles>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

As you can see, UploadContextWrapper is the outermost component in layout. GlobalStyles, Typography and SiteStyles are styled-components components and are there only to provide site styles, so we don't need to worry about them.

Defining state

Let's go back to UploadContextWrapper and define some states.

const [files, setFiles] = useState<UploadFile[]>([]);
const [refreshCallbacks, setRefreshCallbacks] = useState<Array<() => void>>([]);
const [needsRefreshing, setNeedsRefreshing] = useState<boolean>(false);

const generateUID = useMemo(getNewUIDGenerator, []);
Enter fullscreen mode Exit fullscreen mode

files and refreshCallbacks are rather self-explanatory. needsRefreshing will be used to trigger useEffect hook which will call every callback from refreshCallbacks if true. generateUID will be used to generate ids for new files to upload. How is it implemented?

export const getNewUIDGenerator = (): (() => number) => {
  let lastID = -1;
  return () => {
    lastID += 1;
    return lastID;
  };
};
Enter fullscreen mode Exit fullscreen mode

The implementation is very straightforward. It makes use of closures in JavaScript. Then we remember the result of calling this function by using useMemo hook, so that this function is called only once in a lifetime of our app. If we didn't use useMemo, every time state of UploadContextWrapper changed, getNewUIDGenerator would be called again and we would get a new function, which would start counting from 0 (and so the ids wouldn't be unique).

Defining helper function

Before we define upload function, let's define a helper function.

const updateFileFactory = (id: number) => (getUpdated: (oldFile: UploadFile) => UploadFile) => {
    setFiles(oldFiles => {
      const oldFile = oldFiles.find(f => f.id === id);
      if (oldFile) {
        return oldFiles
          .filter(f => f.id !== id)
          .concat([getUpdated(oldFile)])
          .sort((a, b) => b.id - a.id);
      }
      return oldFiles;
    });
  };
Enter fullscreen mode Exit fullscreen mode

If you're not familiar with arrow functions and functional programming, you're going to hate this implementation, but in my opinion it's beautiful. updateFileFactory is a function, which when given file id returns another function, which takes a getUpdated projection function, to which it passes a file object with a given (at the beginning) id. Perhaps an example will make it slightly more clearer.

const id = 5;
const updateFile = updateFileFactory(id);
updateFile(oldFile => ({
  ...oldFile,
  status: 'success',
}));
Enter fullscreen mode Exit fullscreen mode

First you call updateFileFactory with an id of a file you want to update. It returns a function, which we assign to updateFile variable. Now, if you want to update the file, you can call updateFile with a function, which takes the file and returns a new file. Old file will be replaced by the result of the (arrow) function.

upload function

This one is going to be kinda messy, I know. You can split it into smaller functions, but generally upload function looks like this:

const upload = useCallback(
  (fileList: FileList) => {
    Array.from(fileList).forEach(file => {
      const id = generateUID();
      const updateFile = updateFileFactory(id);
      const data = new FormData();
      data.append('file', file);

      setFiles(oldFiles =>
        oldFiles.concat([
          {
            id,
            name: file.name,
            status: 'pending',
            loaded: 0,
            total: file.size,
          },
        ])
      );

      axios
        .post(`/api/file?name=${file.name}`, data, {
          onUploadProgress: (e: ProgressEvent) =>
            updateFile(oldFile => ({
              ...oldFile,
              loaded: e.loaded,
              total: e.total,
            })),
        })
        .then(() => {
          updateFile(oldFile => ({
            ...oldFile,
            status: 'success',
          }));
          setNeedsRefreshing(true);
        })
        .catch(() => {
          updateFile(oldFile => ({
            ...oldFile,
            status: 'failed',
          }));
        });
    });
  },
  [generateUID]
);
Enter fullscreen mode Exit fullscreen mode

What is going on? First we put everything in useCallback hook. This makes sure that whenever UploadContextWrapper rerenders (because of the state change), upload will always hold the same function reference and thus won't cause unnecessary rerenders of components using UploadContext.

Inside the function, which takes fileList of type FileList (which is a value type used by inputs with type="file" attribute), we iterate over every file queued for upload and then: prepare necessary data (including form data), add file to files state and send the request (i.e. start upload). When calling post method we pass onUploadProgress callback, which will update our file object when the upload progresses. That way we'll be able to visualize file upload progress with a smooth progress bar.

Refresh needed!

Next we'll define mentioned earlier useEffect hook, which will call refresh callbacks after a file has been successfully uploaded.

useEffect(() => {
  if (needsRefreshing) {
    refreshCallbacks.forEach(cb => cb());
    setNeedsRefreshing(false);
  }
}, [needsRefreshing, refreshCallbacks]);
Enter fullscreen mode Exit fullscreen mode

Defining uploadManager

Finally, we can define uploadManager with all the necessary functions. As you might have noticed, here we also use useMemo hook, so the reference to the object stays the same throughout all rerenders of UploadContextWrapper and doesn't cause unnecessary rerenders of components using UploadContext.

const uploadManager: UploadManager = useMemo(
  () => ({
    upload,
    addRefreshCallback: cb => {
      setRefreshCallbacks(oldCbs => [...oldCbs, cb]);
    },
    removeRefreshCallback: cb => {
      setRefreshCallbacks(oldCbs => oldCbs.filter(oldCb => oldCb !== cb));
    },
  }),
  [upload]
);
Enter fullscreen mode Exit fullscreen mode

That's everything when it comes to UploadContext.tsx!

How do I use it?

It's simple, but let's break it down to 3 main parts.

File upload progress

If you want to render a component which will show file upload progress just write:

const uploadFiles = useUploadFiles();
Enter fullscreen mode Exit fullscreen mode

and then map over files just like that:

{uploadFiles.map(file => (
  <UploadFileTile key={file.id} file={file} />
))}
Enter fullscreen mode Exit fullscreen mode

UploadFileTile not included

Upload files

If you want to upload some files, here's a piece of code which will do just that!

const { upload } = useUpload();

const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
  if (e.target.files) {
    upload(e.target.files);
  }
  e.target.value = '';
};
Enter fullscreen mode Exit fullscreen mode

Just remember to wire it up to a correct input element!

Auto refresh

If you want your component to refetch files from the server when a file finishes uploading, I've got your back!

const { addRefreshCallback, removeRefreshCallback } = useUpload();

useEffect(() => {
  addRefreshCallback(fetchFiles);
  return () => removeRefreshCallback(fetchFiles);
}, [fetchFiles, addRefreshCallback, removeRefreshCallback]);
Enter fullscreen mode Exit fullscreen mode

fetchFiles not included

Summary

As you can see, you can achieve quite a lot with just React Context. It has a different philosophy and use cases than Redux, but for a small web app it's a completely valid solution, especially for an unexperienced frontend developer who hasn't learned Redux yet.

Top comments (0)