Building a Step-Based File Upload Progress with React MUI
When developing modern web applications, providing visual feedback during file uploads is crucial for a good user experience. Users want to know how their upload is progressing, especially for larger files. Material UI (MUI) offers several progress components that can be leveraged to create intuitive and visually appealing upload indicators.
In this comprehensive guide, I'll walk you through building a step-based file upload progress system using React MUI's Progress components. By the end of this tutorial, you'll understand how to implement a robust file upload interface with real-time progress tracking, error handling, and a polished user experience.
What You'll Learn
- Understanding MUI's various Progress components and their use cases
- Building a multi-step file upload progress indicator
- Implementing real-time progress updates with axios interceptors
- Handling upload states (pending, uploading, success, error)
- Customizing Progress components with MUI theming and styling
- Creating accessible progress indicators
- Best practices for file upload UX
Deep Dive into MUI Progress Components
Before we start building, let's understand the Progress components offered by MUI. Material UI provides two main types of progress indicators: determinate and indeterminate.
Linear Progress Component
The LinearProgress component is a horizontal progress indicator that represents the completion of a process. It's perfect for file uploads as it clearly shows the percentage of completion.
Key Props
Prop | Type | Default | Description |
---|---|---|---|
color | string | 'primary' | The color of the component. Options include 'primary', 'secondary', 'error', 'info', 'success', 'warning', or a custom color. |
value | number | 0 | The value of the progress indicator for the determinate variant (0-100). |
variant | string | 'indeterminate' | The variant to use. Options include 'determinate', 'indeterminate', 'buffer', or 'query'. |
valueBuffer | number | - | The value for the buffer variant (used for the buffer bar). |
sx | object | - | The system prop that allows defining custom styles. |
Variants
- Determinate: Shows a fixed progress value (0-100). Perfect for when you know the exact progress percentage.
- Indeterminate: Continuously animates, suitable when progress isn't quantifiable.
- Buffer: Shows both progress and a buffer bar, useful for streaming operations.
- Query: Indicates a loading state before the progress starts.
import { LinearProgress } from '@mui/material';
// Determinate - For known progress
<LinearProgress variant="determinate" value={75} />
// Indeterminate - For unknown progress duration
<LinearProgress variant="indeterminate" />
// Buffer - For operations with buffering (like video streaming)
<LinearProgress variant="buffer" value={60} valueBuffer={80} />
// Query - For initial loading state
<LinearProgress variant="query" />
Circular Progress Component
The CircularProgress component displays a circular animation. It's useful for compact spaces or as a more subtle indicator.
Key Props
Prop | Type | Default | Description |
---|---|---|---|
color | string | 'primary' | The color of the component. Options include 'primary', 'secondary', 'error', 'info', 'success', 'warning', or a custom color. |
size | number | string | 40 | The size of the circle (in pixels or with CSS unit). |
thickness | number | 3.6 | The thickness of the circle (as a percentage of size). |
value | number | 0 | The value of the progress indicator for the determinate variant (0-100). |
variant | string | 'indeterminate' | The variant to use. Options include 'determinate' or 'indeterminate'. |
disableShrink | boolean | false | If true, the shrink animation won't be applied (indeterminate variant only). |
import { CircularProgress } from '@mui/material';
// Determinate
<CircularProgress variant="determinate" value={75} />
// Indeterminate
<CircularProgress />
// Custom size and thickness
<CircularProgress size={60} thickness={4.5} />
Accessibility Considerations
Both progress components are designed with accessibility in mind. They include appropriate ARIA attributes by default:
role="progressbar"
is automatically appliedaria-valuenow
,aria-valuemin
, andaria-valuemax
are set for determinate variants- For indeterminate variants,
aria-busy="true"
is used
However, you should also consider adding descriptive text for screen readers:
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<CircularProgress
variant="determinate"
value={75}
aria-label="File upload progress"
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography
variant="caption"
component="div"
color="text.secondary"
>{75}%</Typography>
</Box>
</Box>
Step-by-Step Guide: Building a File Upload Progress System
Now that we understand the components, let's build a complete file upload system with progress tracking. We'll break this down into manageable steps.
Step 1: Setting Up the Project
First, let's create a new React project and install the necessary dependencies.
# Create a new React project
npx create-react-app file-upload-progress
# Navigate to the project directory
cd file-upload-progress
# Install required dependencies
npm install @mui/material @emotion/react @emotion/styled @mui/icons-material axios
These packages provide:
- MUI components and styling system
- Icon components for visual indicators
- Axios for handling HTTP requests with progress tracking
Step 2: Creating the File Upload Component Structure
Let's start by creating a basic file upload component with a dropzone area and progress indicator.
// src/components/FileUploader.jsx
import React, { useState } from 'react';
import {
Box,
Button,
Typography,
Paper,
LinearProgress,
Stack,
Alert,
} from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
const FileUploader = () => {
const [file, setFile] = useState(null);
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState(null); // 'success', 'error', or null
const handleFileChange = (event) => {
const selectedFile = event.target.files[0];
if (selectedFile) {
setFile(selectedFile);
// Reset states when a new file is selected
setProgress(0);
setUploading(false);
setUploadStatus(null);
}
};
const handleUpload = () => {
// We'll implement this in the next step
console.log("Upload functionality will be implemented next");
};
return (
<Paper
elevation={3}
sx={{
p: 3,
maxWidth: 500,
mx: 'auto',
mt: 4,
borderRadius: 2
}}
>
<Typography variant="h5" gutterBottom align="center">
File Upload with Progress
</Typography>
<Box
sx={{
border: '2px dashed #ccc',
borderRadius: 2,
p: 3,
textAlign: 'center',
mb: 3,
bgcolor: 'background.paper',
cursor: 'pointer',
'&:hover': {
borderColor: 'primary.main',
}
}}
onClick={() => document.getElementById('file-input').click()}
>
<input
id="file-input"
type="file"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<CloudUploadIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 1 }} />
<Typography variant="body1">
{file ? file.name : "Drag and drop a file here or click to browse"}
</Typography>
{file && (
<Typography variant="caption" display="block" color="text.secondary">
{(file.size / 1024 / 1024).toFixed(2)} MB
</Typography>
)}
</Box>
{file && !uploading && !uploadStatus && (
<Button
variant="contained"
fullWidth
onClick={handleUpload}
sx={{ mb: 2 }}
>
Upload File
</Button>
)}
{uploading && (
<Box sx={{ width: '100%', mt: 2, mb: 2 }}>
<LinearProgress
variant="determinate"
value={progress}
sx={{ height: 10, borderRadius: 5 }}
/>
<Typography variant="body2" sx={{ mt: 1, textAlign: 'center' }}>
Uploading: {progress}%
</Typography>
</Box>
)}
{uploadStatus === 'success' && (
<Alert severity="success" sx={{ mt: 2 }}>
File uploaded successfully!
</Alert>
)}
{uploadStatus === 'error' && (
<Alert severity="error" sx={{ mt: 2 }}>
Error uploading file. Please try again.
</Alert>
)}
</Paper>
);
};
export default FileUploader;
This component sets up:
- A styled dropzone area for file selection
- States to track the selected file, upload progress, and status
- A progress bar that will display during upload
- Status alerts for success and error states
Step 3: Implementing File Upload with Progress Tracking
Now, let's implement the actual file upload functionality with progress tracking using Axios.
// src/components/FileUploader.jsx
// Add this import at the top
import axios from 'axios';
// Then update the handleUpload function
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setProgress(0);
setUploadStatus(null);
const formData = new FormData();
formData.append('file', file);
try {
// Replace with your actual upload endpoint
await axios.post('https://your-api-endpoint.com/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setProgress(percentCompleted);
},
});
setUploadStatus('success');
} catch (error) {
console.error('Upload error:', error);
setUploadStatus('error');
} finally {
setUploading(false);
}
};
This implementation:
- Creates a FormData object to send the file
- Uses Axios's
onUploadProgress
callback to track and update progress - Handles success and error states appropriately
- Sets the uploading state to false when complete
Step 4: Creating a Multi-Step Upload Progress Indicator
Let's enhance our uploader with a multi-step progress indicator that shows different stages of the upload process.
// src/components/StepProgressUploader.jsx
import React, { useState } from 'react';
import {
Box,
Button,
Typography,
Paper,
Stepper,
Step,
StepLabel,
LinearProgress,
CircularProgress,
Alert,
Stack,
} from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import axios from 'axios';
const steps = ['Select File', 'Prepare Upload', 'Uploading', 'Processing', 'Complete'];
const StepProgressUploader = () => {
const [file, setFile] = useState(null);
const [activeStep, setActiveStep] = useState(0);
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState(null); // 'success', 'error', or null
const handleFileChange = (event) => {
const selectedFile = event.target.files[0];
if (selectedFile) {
setFile(selectedFile);
setActiveStep(1); // Move to "Prepare Upload" step
setProgress(0);
setUploading(false);
setUploadStatus(null);
}
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setActiveStep(2); // Move to "Uploading" step
setProgress(0);
const formData = new FormData();
formData.append('file', file);
try {
// Simulate upload preparation (in real app, you might validate file or get upload URL)
await new Promise(resolve => setTimeout(resolve, 1000));
// Start the actual upload
// Replace with your actual upload endpoint
await axios.post('https://httpbin.org/post', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setProgress(percentCompleted);
},
});
// Move to processing step
setActiveStep(3);
// Simulate server-side processing
await new Promise(resolve => setTimeout(resolve, 2000));
// Complete
setActiveStep(4);
setUploadStatus('success');
} catch (error) {
console.error('Upload error:', error);
setUploadStatus('error');
} finally {
setUploading(false);
}
};
const resetUpload = () => {
setFile(null);
setActiveStep(0);
setProgress(0);
setUploading(false);
setUploadStatus(null);
};
// Determine the step status (completed, in progress, or pending)
const getStepStatus = (step) => {
if (uploadStatus === 'error' && activeStep === step) {
return 'error';
}
if (step < activeStep) {
return 'completed';
}
if (step === activeStep) {
return 'active';
}
return 'pending';
};
return (
<Paper
elevation={3}
sx={{
p: 3,
maxWidth: 600,
mx: 'auto',
mt: 4,
borderRadius: 2
}}
>
<Typography variant="h5" gutterBottom align="center">
Multi-Step File Upload
</Typography>
<Stepper activeStep={activeStep} alternativeLabel sx={{ mb: 4 }}>
{steps.map((label, index) => {
const stepStatus = getStepStatus(index);
return (
<Step key={label} completed={stepStatus === 'completed'}>
<StepLabel
error={stepStatus === 'error'}
StepIconProps={{
icon: index + 1,
}}
>
{label}
</StepLabel>
</Step>
);
})}
</Stepper>
{/* Step 0: Select File */}
{activeStep === 0 && (
<Box
sx={{
border: '2px dashed #ccc',
borderRadius: 2,
p: 3,
textAlign: 'center',
mb: 3,
bgcolor: 'background.paper',
cursor: 'pointer',
'&:hover': {
borderColor: 'primary.main',
}
}}
onClick={() => document.getElementById('file-input').click()}
>
<input
id="file-input"
type="file"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<CloudUploadIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 1 }} />
<Typography variant="body1">
Drag and drop a file here or click to browse
</Typography>
</Box>
)}
{/* Step 1: Prepare Upload */}
{activeStep === 1 && (
<Box sx={{ textAlign: 'center', mb: 3 }}>
<Typography variant="body1" gutterBottom>
Selected file: {file.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Size: {(file.size / 1024 / 1024).toFixed(2)} MB
</Typography>
<Typography variant="body2" gutterBottom sx={{ mt: 2 }}>
Click the button below to start the upload process.
</Typography>
<Button
variant="contained"
onClick={handleUpload}
sx={{ mt: 2 }}
>
Start Upload
</Button>
</Box>
)}
{/* Step 2: Uploading */}
{activeStep === 2 && (
<Box sx={{ textAlign: 'center', mb: 3 }}>
<Typography variant="body1" gutterBottom>
Uploading {file.name}...
</Typography>
<LinearProgress
variant="determinate"
value={progress}
sx={{ height: 10, borderRadius: 5, my: 2 }}
/>
<Typography variant="body2" color="text.secondary">
{progress}% complete
</Typography>
</Box>
)}
{/* Step 3: Processing */}
{activeStep === 3 && (
<Box sx={{ textAlign: 'center', mb: 3 }}>
<CircularProgress size={48} thickness={4} sx={{ mb: 2 }} />
<Typography variant="body1">
Processing your file...
</Typography>
<Typography variant="body2" color="text.secondary">
This may take a moment
</Typography>
</Box>
)}
{/* Step 4: Complete */}
{activeStep === 4 && (
<Box sx={{ textAlign: 'center', mb: 3 }}>
{uploadStatus === 'success' ? (
<>
<CheckCircleIcon
sx={{ fontSize: 48, color: 'success.main', mb: 2 }}
/>
<Typography variant="h6" gutterBottom>
Upload Complete!
</Typography>
<Typography variant="body1" gutterBottom>
Your file has been successfully uploaded and processed.
</Typography>
</>
) : uploadStatus === 'error' ? (
<>
<ErrorIcon
sx={{ fontSize: 48, color: 'error.main', mb: 2 }}
/>
<Typography variant="h6" gutterBottom>
Upload Failed
</Typography>
<Typography variant="body1" gutterBottom>
There was an error processing your file. Please try again.
</Typography>
</>
) : null}
<Button
variant="outlined"
onClick={resetUpload}
sx={{ mt: 2 }}
>
Upload Another File
</Button>
</Box>
)}
{uploadStatus === 'error' && activeStep !== 4 && (
<Alert severity="error" sx={{ mt: 2 }}>
Error during the upload process. Please try again.
<Button
size="small"
onClick={resetUpload}
sx={{ ml: 2 }}
>
Restart
</Button>
</Alert>
)}
</Paper>
);
};
export default StepProgressUploader;
This enhanced component:
- Uses a Stepper to show the different stages of the upload process
- Provides visual feedback at each step
- Simulates a complete upload flow including server-side processing
- Handles errors at any stage
- Allows restarting the process
Step 5: Integrating with Your App
Now, let's integrate our uploader into the main App component:
// src/App.js
import React from 'react';
import {
CssBaseline,
ThemeProvider,
createTheme,
Container,
Typography,
Box,
Tabs,
Tab
} from '@mui/material';
import FileUploader from './components/FileUploader';
import StepProgressUploader from './components/StepProgressUploader';
// Create a custom theme
const theme = createTheme({
palette: {
primary: {
main: '#2196f3',
},
secondary: {
main: '#f50057',
},
},
components: {
MuiLinearProgress: {
styleOverrides: {
root: {
borderRadius: 5,
},
},
},
},
});
function App() {
const [tabValue, setTabValue] = React.useState(0);
const handleTabChange = (event, newValue) => {
setTabValue(newValue);
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Container maxWidth="md">
<Box sx={{ my: 4 }}>
<Typography variant="h4" component="h1" gutterBottom align="center">
React MUI File Upload Progress Demo
</Typography>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs
value={tabValue}
onChange={handleTabChange}
centered
>
<Tab label="Simple Uploader" />
<Tab label="Multi-Step Uploader" />
</Tabs>
</Box>
{tabValue === 0 && <FileUploader />}
{tabValue === 1 && <StepProgressUploader />}
</Box>
</Container>
</ThemeProvider>
);
}
export default App;
This App component:
- Sets up a custom theme for consistent styling
- Creates a tabbed interface to showcase both uploader variants
- Provides a clean, responsive container for our components
Step 6: Creating a Custom Progress Component
Let's create a more advanced custom progress component that combines both linear and circular progress indicators for a richer UI:
// src/components/CustomProgressIndicator.jsx
import React from 'react';
import {
Box,
Typography,
LinearProgress,
CircularProgress,
} from '@mui/material';
const CustomProgressIndicator = ({
progress,
size = 80,
thickness = 4,
variant = 'determinate',
showPercentage = true,
label,
sx = {}
}) => {
return (
<Box sx={{ position: 'relative', display: 'inline-flex', ...sx }}>
<CircularProgress
variant={variant}
value={progress}
size={size}
thickness={thickness}
aria-label={label || "Progress indicator"}
/>
{variant === 'determinate' && showPercentage && (
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography
variant="caption"
component="div"
color="text.secondary"
sx={{ fontWeight: 'bold' }}
>
{progress}%
</Typography>
</Box>
)}
{label && (
<Typography
variant="body2"
component="div"
sx={{ mt: 1, textAlign: 'center' }}
>
{label}
</Typography>
)}
</Box>
);
};
// A component that combines both circular and linear progress
export const CombinedProgress = ({
progress,
label,
sx = {}
}) => {
return (
<Box sx={{ textAlign: 'center', ...sx }}>
<CustomProgressIndicator
progress={progress}
label={label}
sx={{ mb: 2 }}
/>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 8,
borderRadius: 4,
width: '100%',
maxWidth: 300,
mx: 'auto'
}}
/>
</Box>
);
};
export default CustomProgressIndicator;
Now let's integrate this custom component into our StepProgressUploader:
// In StepProgressUploader.jsx, add this import
import { CombinedProgress } from './CustomProgressIndicator';
// Then replace the uploading step (Step 2) with:
{/* Step 2: Uploading */}
{activeStep === 2 && (
<Box sx={{ textAlign: 'center', mb: 3 }}>
<Typography variant="body1" gutterBottom>
Uploading {file.name}...
</Typography>
<CombinedProgress
progress={progress}
label="Upload in progress"
sx={{ my: 2 }}
/>
<Typography variant="body2" color="text.secondary">
Please don't close this window
</Typography>
</Box>
)}
This custom component:
- Combines circular and linear progress for a more engaging UI
- Provides configurable options for size, thickness, and labels
- Maintains accessibility with appropriate ARIA attributes
- Can be easily reused throughout your application
Step 7: Implementing a Chunked File Upload with Progress
For large files, chunked uploads are more reliable. Let's implement a chunked upload with progress tracking:
// src/components/ChunkedUploader.jsx
import React, { useState, useCallback } from 'react';
import {
Box,
Button,
Typography,
Paper,
LinearProgress,
Alert,
Stack,
Divider,
} from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import axios from 'axios';
const ChunkedUploader = () => {
const [file, setFile] = useState(null);
const [chunkSize, setChunkSize] = useState(1024 * 1024); // 1MB chunks
const [totalChunks, setTotalChunks] = useState(0);
const [currentChunk, setCurrentChunk] = useState(0);
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState(null);
const [uploadId, setUploadId] = useState('');
const handleFileChange = (event) => {
const selectedFile = event.target.files[0];
if (selectedFile) {
setFile(selectedFile);
const chunks = Math.ceil(selectedFile.size / chunkSize);
setTotalChunks(chunks);
setCurrentChunk(0);
setProgress(0);
setUploading(false);
setUploadStatus(null);
setUploadId('');
}
};
const uploadChunk = useCallback(async (chunk, chunkIndex, uploadId) => {
const formData = new FormData();
formData.append('file', chunk, file.name);
formData.append('chunkIndex', chunkIndex);
formData.append('totalChunks', totalChunks);
if (uploadId) {
formData.append('uploadId', uploadId);
}
// Replace with your actual chunked upload endpoint
const response = await axios.post('https://httpbin.org/post', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
// In a real implementation, your server would return an uploadId
// for the first chunk, which you'd use for subsequent chunks
return response.data;
}, [file, totalChunks]);
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setProgress(0);
setCurrentChunk(0);
try {
let currentUploadId = uploadId;
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const chunk = file.slice(start, end);
// Upload the chunk
const response = await uploadChunk(chunk, chunkIndex, currentUploadId);
// In a real implementation, get the uploadId from the first chunk response
if (chunkIndex === 0 && !currentUploadId) {
// This is a simulation - in reality, you'd get this from your server
currentUploadId = 'sim-upload-' + Date.now();
setUploadId(currentUploadId);
}
// Update progress
setCurrentChunk(chunkIndex + 1);
const newProgress = Math.round(((chunkIndex + 1) / totalChunks) * 100);
setProgress(newProgress);
}
// All chunks uploaded successfully
setUploadStatus('success');
} catch (error) {
console.error('Chunk upload error:', error);
setUploadStatus('error');
} finally {
setUploading(false);
}
};
return (
<Paper
elevation={3}
sx={{
p: 3,
maxWidth: 500,
mx: 'auto',
mt: 4,
borderRadius: 2
}}
>
<Typography variant="h5" gutterBottom align="center">
Chunked File Upload
</Typography>
<Box
sx={{
border: '2px dashed #ccc',
borderRadius: 2,
p: 3,
textAlign: 'center',
mb: 3,
bgcolor: 'background.paper',
cursor: 'pointer',
'&:hover': {
borderColor: 'primary.main',
}
}}
onClick={() => document.getElementById('chunked-file-input').click()}
>
<input
id="chunked-file-input"
type="file"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<CloudUploadIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 1 }} />
<Typography variant="body1">
{file ? file.name : "Select a large file for chunked upload"}
</Typography>
{file && (
<Typography variant="caption" display="block" color="text.secondary">
{(file.size / 1024 / 1024).toFixed(2)} MB • {totalChunks} chunks
</Typography>
)}
</Box>
{file && !uploading && !uploadStatus && (
<Button
variant="contained"
fullWidth
onClick={handleUpload}
sx={{ mb: 2 }}
>
Start Chunked Upload
</Button>
)}
{uploading && (
<Box sx={{ width: '100%', mt: 2, mb: 2 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
Overall Progress:
</Typography>
<Typography variant="body2" color="text.secondary">
{progress}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={progress}
sx={{ height: 10, borderRadius: 5, mb: 2 }}
/>
<Divider sx={{ my: 2 }} />
<Typography variant="body2" sx={{ mb: 1 }}>
Uploading chunk {currentChunk} of {totalChunks}
</Typography>
<LinearProgress
variant="indeterminate"
sx={{ height: 6, borderRadius: 5 }}
/>
</Box>
)}
{uploadStatus === 'success' && (
<Alert severity="success" sx={{ mt: 2 }}>
All chunks uploaded successfully! Your file is complete.
</Alert>
)}
{uploadStatus === 'error' && (
<Alert severity="error" sx={{ mt: 2 }}>
Error uploading chunks. Please try again.
</Alert>
)}
</Paper>
);
};
export default ChunkedUploader;
This chunked uploader:
- Divides large files into manageable chunks (1MB by default)
- Tracks both overall progress and individual chunk uploads
- Simulates a complete chunked upload flow
- Provides detailed progress information to the user
Step 8: Implementing Drag and Drop Functionality
Let's enhance our uploader with proper drag and drop support:
// src/components/DragDropUploader.jsx
import React, { useState, useCallback } from 'react';
import {
Box,
Button,
Typography,
Paper,
LinearProgress,
Alert,
Stack,
} from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import { CombinedProgress } from './CustomProgressIndicator';
import axios from 'axios';
const DragDropUploader = () => {
const [file, setFile] = useState(null);
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState(null);
const [dragActive, setDragActive] = useState(false);
const handleDrag = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const droppedFile = e.dataTransfer.files[0];
setFile(droppedFile);
setProgress(0);
setUploading(false);
setUploadStatus(null);
}
}, []);
const handleFileChange = (event) => {
const selectedFile = event.target.files[0];
if (selectedFile) {
setFile(selectedFile);
setProgress(0);
setUploading(false);
setUploadStatus(null);
}
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setProgress(0);
const formData = new FormData();
formData.append('file', file);
try {
await axios.post('https://httpbin.org/post', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setProgress(percentCompleted);
},
});
setUploadStatus('success');
} catch (error) {
console.error('Upload error:', error);
setUploadStatus('error');
} finally {
setUploading(false);
}
};
return (
<Paper
elevation={3}
sx={{
p: 3,
maxWidth: 500,
mx: 'auto',
mt: 4,
borderRadius: 2
}}
>
<Typography variant="h5" gutterBottom align="center">
Drag & Drop File Upload
</Typography>
<Box
sx={{
border: '2px dashed',
borderColor: dragActive ? 'primary.main' : '#ccc',
borderRadius: 2,
p: 3,
textAlign: 'center',
mb: 3,
bgcolor: dragActive ? 'action.hover' : 'background.paper',
cursor: 'pointer',
transition: 'all 0.2s ease-in-out',
'&:hover': {
borderColor: 'primary.main',
}
}}
onClick={() => document.getElementById('drag-drop-file-input').click()}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
id="drag-drop-file-input"
type="file"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<CloudUploadIcon sx={{
fontSize: 48,
color: dragActive ? 'primary.main' : 'text.secondary',
mb: 1,
transition: 'color 0.2s ease-in-out'
}} />
<Typography variant="body1">
{file ? file.name : "Drag and drop a file here or click to browse"}
</Typography>
{file && (
<Typography variant="caption" display="block" color="text.secondary">
{(file.size / 1024 / 1024).toFixed(2)} MB
</Typography>
)}
</Box>
{file && !uploading && !uploadStatus && (
<Button
variant="contained"
fullWidth
onClick={handleUpload}
sx={{ mb: 2 }}
>
Upload File
</Button>
)}
{uploading && (
<Box sx={{ width: '100%', mt: 2, mb: 2 }}>
<CombinedProgress
progress={progress}
label="Uploading..."
sx={{ my: 2 }}
/>
</Box>
)}
{uploadStatus === 'success' && (
<Alert severity="success" sx={{ mt: 2 }}>
File uploaded successfully!
</Alert>
)}
{uploadStatus === 'error' && (
<Alert severity="error" sx={{ mt: 2 }}>
Error uploading file. Please try again.
</Alert>
)}
</Paper>
);
};
export default DragDropUploader;
This drag and drop uploader:
- Provides visual feedback when files are dragged over the drop zone
- Handles all the necessary drag and drop events
- Uses smooth transitions for a polished user experience
- Integrates with our custom progress indicator
Advanced Customization and Theming
MUI's progress components can be extensively customized using the theme system and the sx
prop. Let's explore some advanced customization options.
Custom Progress Colors and Animations
// Custom theme with progress customizations
const theme = createTheme({
components: {
MuiLinearProgress: {
styleOverrides: {
root: {
borderRadius: 10,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
},
bar: {
borderRadius: 10,
// Create a gradient effect
backgroundImage: 'linear-gradient(to right, #4facfe 0%, #00f2fe 100%)',
},
},
},
MuiCircularProgress: {
styleOverrides: {
circle: {
// Make the circle stroke rounded at the ends
strokeLinecap: 'round',
},
},
},
},
});
Creating a Themed Progress Bar Component
// src/components/ThemedProgress.jsx
import React from 'react';
import { Box, LinearProgress, Typography, useTheme } from '@mui/material';
const progressColors = {
low: '#f44336', // Red
medium: '#ff9800', // Orange
high: '#4caf50', // Green
};
const ThemedProgress = ({ value, label, variant = 'determinate', size = 'medium' }) => {
const theme = useTheme();
// Determine color based on progress value
const getProgressColor = (value) => {
if (value < 30) return progressColors.low;
if (value < 70) return progressColors.medium;
return progressColors.high;
};
// Determine height based on size
const getHeight = (size) => {
switch (size) {
case 'small': return 4;
case 'medium': return 8;
case 'large': return 12;
default: return 8;
}
};
const progressColor = variant === 'determinate' ? getProgressColor(value) : theme.palette.primary.main;
return (
<Box sx={{ width: '100%' }}>
{label && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">{label}</Typography>
{variant === 'determinate' && (
<Typography variant="body2" color="text.secondary">{value}%</Typography>
)}
</Box>
)}
<LinearProgress
variant={variant}
value={value}
sx={{
height: getHeight(size),
borderRadius: 5,
backgroundColor: 'rgba(0, 0, 0, 0.08)',
'& .MuiLinearProgress-bar': {
borderRadius: 5,
backgroundColor: progressColor,
},
}}
/>
</Box>
);
};
export default ThemedProgress;
This ThemedProgress component:
- Changes color based on the progress value
- Supports different sizes
- Includes an optional label with percentage display
- Uses custom styling via the
sx
prop
Best Practices and Common Issues
When implementing file uploads with progress indicators, there are several best practices to follow and common issues to avoid.
Best Practices
-
Provide Clear Feedback
- Always show the current progress percentage
- Include file name and size information
- Indicate when processing is happening on the server side
-
Handle Edge Cases
- Implement proper error handling for network issues
- Allow users to retry failed uploads
- Provide clear error messages when uploads fail
-
Optimize for Large Files
- Use chunked uploads for large files
- Implement resumable uploads when possible
- Consider implementing file compression before upload
-
Accessibility Considerations
- Ensure progress indicators have proper ARIA attributes
- Provide text alternatives for visual indicators
- Make sure keyboard users can interact with upload controls
-
Performance Optimizations
- Debounce progress updates for smoother animations
- Avoid unnecessary re-renders during progress updates
- Use web workers for heavy client-side processing
Common Issues and Solutions
Issue: Progress Jumps or Appears Inconsistent
Solution: Use a throttled or debounced update function to smooth out progress updates:
import { useCallback, useState } from 'react';
import { throttle } from 'lodash';
function useThrottledProgress() {
const [progress, setProgress] = useState(0);
// Throttle progress updates to max once per 100ms
const updateProgress = useCallback(
throttle((newProgress) => {
setProgress(newProgress);
}, 100),
[]
);
return [progress, updateProgress];
}
// Usage in component
const [progress, updateProgress] = useThrottledProgress();
// In axios request
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
updateProgress(percentCompleted);
}
Issue: No Progress Events in Some Browsers or Network Conditions
Solution: Implement a fallback indeterminate progress:
const [progress, setProgress] = useState(0);
const [progressAvailable, setProgressAvailable] = useState(true);
const progressTimeout = useRef(null);
// Set a timeout - if no progress events after 3 seconds, switch to indeterminate
useEffect(() => {
progressTimeout.current = setTimeout(() => {
setProgressAvailable(false);
}, 3000);
return () => {
if (progressTimeout.current) {
clearTimeout(progressTimeout.current);
}
};
}, []);
// In your render function
{uploading && (
<LinearProgress
variant={progressAvailable ? "determinate" : "indeterminate"}
value={progress}
/>
)}
Issue: Memory Leaks from Progress Updates after Component Unmount
Solution: Use a cleanup function with useEffect:
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const isMounted = useRef(true);
// Set up cleanup when component unmounts
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
const handleUpload = async () => {
setUploading(true);
try {
await axios.post('https://your-api.com/upload', formData, {
onUploadProgress: (progressEvent) => {
if (isMounted.current) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setProgress(percentCompleted);
}
},
});
} catch (error) {
console.error(error);
} finally {
if (isMounted.current) {
setUploading(false);
}
}
};
Issue: Progress Resets on Network Fluctuations
Solution: Implement a progress that never decreases during a single upload:
const [progress, setProgress] = useState(0);
const updateProgress = (newProgress) => {
// Only update if the new progress is higher than current
setProgress(prev => Math.max(prev, newProgress));
};
// In axios request
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
updateProgress(percentCompleted);
}
Wrapping Up
In this comprehensive guide, we've explored how to use React MUI's Progress components to build a robust and user-friendly file upload system. We've covered everything from basic progress indicators to advanced multi-step uploaders with chunked file handling.
By leveraging MUI's flexible components and styling system, we've created visually appealing and accessible progress indicators that provide clear feedback to users during the upload process. We've also addressed common challenges and best practices to ensure your implementation is reliable and performant.
The techniques demonstrated here can be adapted for various use cases beyond file uploads, such as form submissions, data processing, or any long-running operation where visual feedback improves the user experience.