I have a React code that lets you add image files, preview, delete on click and add more. I'm happy with the functionality but I noticed some performance issues.
function App() {
const [selectedFiles, setSelectedFiles] = React.useState([])
function GenerateGuid() {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c => {
const randomValue = crypto.getRandomValues(new Uint8Array(1))[0];
const shiftedValue = (randomValue & 15) >> (+c / 4);
return shiftedValue.toString(16);
});
}
const handleImageDelete = (id) => {
const updatedFiles = selectedFiles.filter((file) => file.id !== id);
setSelectedFiles(updatedFiles);
};
const handleChange = (e) => {
const files = Array.from(e.target.files)
const filesObject = files.map(file => {
return (
{ id: GenerateGuid(), fileObject: file }
)
})
setSelectedFiles([...selectedFiles, ...filesObject])
}
return (
<div className='App'>
<h1>Hello React.</h1>
<h2>Start editing to see some magic happen!</h2>
<input accept="image/png, image/gif, image/jpeg" type="file" onChange={handleChange} multiple />
{selectedFiles.map(
(file, index) => {
return (
<img
key={file.id}
onClick={() => handleImageDelete(file.id)}
style={{ height: "5rem", backgroundColor: "black" }}
src={URL.createObjectURL(file.fileObject)}
alt={"training spot"}
/>
)
}
)}
</div>
);
}
ReactDOM.render(<App />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div id="root"></div>
Works fine in small file amounts, but let's review the following scenario.
Is there a clever way to prevent remaining images from reloading?
So far just testing in the sandbox and looking for solution.
The main issue would probably be src={URL.createObjectURL(file.fileObject)}
. To understand why let's first look at the documentation of URL.createObjectURL()
:
The
URL.createObjectURL()
static method creates a string containing a URL representing the object given in the parameter.The URL lifetime is tied to the
document
in the window on which it was created. The new object URL represents the specifiedFile
object orBlob
object.To release an object URL, call
revokeObjectURL()
.
As you can see the URL has the lifetime of the document
. In your scenario each time you render App
, new URLs are created, without releasing the old URLs. Even whenever App
would be unmounted the created URLs live on.
You can see this in action by inspecting the images. Whenever you delete a single image all the remaining images get assigned a new src
.
To fix this don't create the URLs on render, but instead whenever you add the image to the state, just like you do with the ids. Then whenever an image is removed, revoke the URL. (And you most likely want to revoke the URL on component unmount as well.)
function App() {
const [selectedFiles, setSelectedFiles] = React.useState([]);
const urls = React.useRef(new Set());
const handleImageDelete = (id) => {
setSelectedFiles(selectedFiles.filter((file) => {
if (file.id !== id) return true; // keep in state
// delete URL from register (Set) and revoke it
urls.current.delete(file.url);
URL.revokeObjectURL(file.url);
return false; // remove from state
}));
};
const handleChange = (e) => {
const newFiles = Array.from(e.target.files, (fileObject) => {
const id = crypto.randomUUID();
// create URL and add it to the register (Set)
const url = URL.createObjectURL(fileObject);
urls.current.add(url);
return { id, url, fileObject };
});
setSelectedFiles([...selectedFiles, ...newFiles])
}
React.useEffect(() => {
// revoke register URLs on unmount
return () => {
for (const url of urls.current) URL.revokeObjectURL(url);
};
}, []);
return (
<div className='App'>
<h1>Hello React.</h1>
<h2>Start editing to see some magic happen!</h2>
<input accept="image/png, image/gif, image/jpeg" type="file" onChange={handleChange} multiple />
{selectedFiles.map(
(file, index) => {
return (
<img
key={file.id}
onClick={() => handleImageDelete(file.id)}
style={{ height: "5rem", backgroundColor: "black" }}
src={file.url}
alt={"training spot"}
/>
)
}
)}
</div>
);
}
ReactDOM.render(<App />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div id="root"></div>
I've replace GenerateGuid()
with crypto.randomUUID()
to simplify the snippet to only relevant code.
In the code above you'll see that the URL is generated whenever the file is added to the file list. It's no longer generated for each render. I also revoke the URL whenever the the image is deleted. These changes go a long way.
However to ensure the URLs are revoked whenever the component unmounts we also have to add an useEffect
hook. At first you might be tempted to do something like this:
React.useEffect(() => {
return () => {
selectedFiles.forEach(file => URL.revokeObjectURL(file.url));
};
}, [selectedFiles]);
But the above does NOT work, because it does not only run on unmount, but also whenever selectedFiles
changes. Meaning that whenever you'll add files, the URLs are created and intermediately after render revoked.
To solve for this issue I use the useRef
hook. This will create an object that is unique for this instance of the component of which the identity is stable (the object reference never changes). This ref holds a Set
instance that I use as an URL register. This allows us access to the URLs without the need to specify dependencies for the useEffect
hook.
React.useEffect(() => {
// revoke register URLs on unmount
return () => {
for (const url of urls.current) URL.revokeObjectURL(url);
};
}, []); // <- no dependencies, the returned function only runs on unmount
This does mean that we do need to add/remove the URL entries to/from the register whenever we create or revoke an URL, making the adding/removing files a bit more complex.