Sitewide file uploads with React Context
Tags: react, typescript, tutorial
Published: 2021-05-24 16:20:38

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).

```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;
}
```

`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.

```ts
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[]>([]);
```

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:

```js
const UploadContext = createContext({});
```

...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:

```ts
export const useUpload = (): UploadManager => useContext(UploadContext);
export const useUploadFiles = (): UploadFile[] => useContext(UploadFilesContext);
```

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.

```tsx
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>
  );
}
```

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:

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

      <SiteStyles>
        <UploadContextWrapper>
          <Sidebar />

          <PageWrapper>{children}</PageWrapper>
        </UploadContextWrapper>
      </SiteStyles>
    </>
  );
}
```

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.

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

const generateUID = useMemo(getNewUIDGenerator, []);
```

`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?

```ts
export const getNewUIDGenerator = (): (() => number) => {
  let lastID = -1;
  return () => {
    lastID += 1;
    return lastID;
  };
};
```

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.

```ts
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;
    });
  };
```

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.

```ts
const id = 5;
const updateFile = updateFileFactory(id);
updateFile(oldFile => ({
  ...oldFile,
  status: 'success',
}));
```

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:

```ts
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]
);
```

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.

```ts
useEffect(() => {
  if (needsRefreshing) {
    refreshCallbacks.forEach(cb => cb());
    setNeedsRefreshing(false);
  }
}, [needsRefreshing, refreshCallbacks]);
```

## 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`.

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

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:

```ts
const uploadFiles = useUploadFiles();
```

and then map over files just like that:

```tsx
{uploadFiles.map(file => (
  <UploadFileTile key={file.id} file={file} />
))}
```

*`UploadFileTile` not included*

### Upload files

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

```ts
const { upload } = useUpload();

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

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!

```ts
const { addRefreshCallback, removeRefreshCallback } = useUpload();
 
useEffect(() => {
  addRefreshCallback(fetchFiles);
  return () => removeRefreshCallback(fetchFiles);
}, [fetchFiles, addRefreshCallback, removeRefreshCallback]);
```

*`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.