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;
}
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[]>([]);
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({});
...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);
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>
);
}
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>
</>
);
}
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, []);
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;
};
};
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;
});
};
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',
}));
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]
);
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]);
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]
);
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();
and then map over files just like that:
{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!
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!
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.
Top comments (0)