Upload de Arquivos em React: Fundamentos e Arquitetura
Upload de arquivos é uma das operações mais comuns em aplicações web modernas. Em React, esse processo envolve não apenas enviar o arquivo para o servidor, mas também proporcionar ao usuário uma experiência visual clara com preview, indicador de progresso e validação robusta. A maioria dos projetos implementa essas funcionalidades de forma amadora, negligenciando casos extremos e criando aplicações frágeis.
A estratégia correta começa entendendo que upload não é responsabilidade apenas do React. Seu papel é gerenciar o estado visual e a interação do usuário, enquanto a chamada HTTP (geralmente via fetch ou axios) executa o envio real. O React deve manter sincronizados: o arquivo selecionado, a visualização prévia, o status do envio e possíveis erros. Para isso, usaremos hooks como useState e useRef, além de técnicas nativas de JavaScript para manipulação de files.
Vamos construir um componente robusto do zero, entendendo cada decisão arquitetural. Começaremos com a validação de tipo, depois adicionaremos preview e, por fim, o indicador de progresso. Cada camada será explicada antes do código, não o inverso.
Validação de Tipo de Arquivo
Por que validar tipos?
A validação ocorre em duas camadas: cliente e servidor. A validação no cliente é uma questão de experiência do usuário — previne que alguém tente enviar um PDF quando o formulário aceita apenas imagens. A validação no servidor é questão de segurança — sempre desconfie do cliente. Neste artigo, focamos na validação cliente, mas nunca esqueça: o servidor deve validar novamente.
Em React, acessamos o tipo MIME do arquivo através da propriedade type do objeto File. Contudo, essa propriedade pode ser enganada: um arquivo .exe renomeado como .jpg terá type vazio ou falso. A forma mais confiável é usar a extensão do arquivo em conjunto com a validação de tamanho. Para máxima segurança, o servidor deve re-validar usando bibliotecas como file-type (Node.js), que analisam bytes mágicos do arquivo.
// Hook customizado para validação
const useFileValidation = () => {
const [errors, setErrors] = useState([]);
const validateFile = (file, acceptedMimes, maxSizeMB = 5) => {
const validationErrors = [];
const maxSizeBytes = maxSizeMB * 1024 * 1024;
// Validar tamanho
if (file.size > maxSizeBytes) {
validationErrors.push(
`Arquivo excede ${maxSizeMB}MB. Tamanho atual: ${(file.size / 1024 / 1024).toFixed(2)}MB`
);
}
// Validar MIME type
if (!acceptedMimes.includes(file.type)) {
validationErrors.push(
`Tipo de arquivo não permitido. Aceitos: ${acceptedMimes.join(', ')}`
);
}
// Validar extensão (camada adicional)
const extension = file.name.split('.').pop()?.toLowerCase();
const validExtensions = acceptedMimes
.map(mime => mime.split('/')[1])
.filter(Boolean);
if (!validExtensions.includes(extension)) {
validationErrors.push(
`Extensão ".${extension}" não permitida`
);
}
setErrors(validationErrors);
return validationErrors.length === 0;
};
return { errors, validateFile };
};
Neste hook, implementamos três camadas de validação: tamanho do arquivo, tipo MIME declarado e extensão. A validação de extensão é redundante com o MIME, mas oferece proteção extra contra modificações simples no tipo MIME. Cada erro é armazenado em um array para que possamos exibir múltiplas mensagens ao usuário simultaneamente.
Preview de Imagem e Arquivos
Gerando URLs de visualização
Um preview eficiente em React requer compreender a API FileReader nativa do JavaScript. Quando o usuário seleciona uma imagem, precisamos convertê-la em uma URL que o navegador possa renderizar. O método mais moderno e eficiente é usar URL.createObjectURL(), que cria uma referência para o blob sem necessidade de leitura completa do arquivo. Para outros tipos de arquivo (PDF, documentos), podemos exibir ícones ou informações de metadados.
const FilePreview = ({ file, onClear }) => {
const [preview, setPreview] = useState(null);
const [fileInfo, setFileInfo] = useState(null);
useEffect(() => {
if (!file) return;
// Criar preview para imagens
if (file.type.startsWith('image/')) {
const objectUrl = URL.createObjectURL(file);
setPreview(objectUrl);
// Cleanup
return () => URL.revokeObjectURL(objectUrl);
} else {
// Para outros tipos, exibir metadados
setFileInfo({
name: file.name,
size: (file.size / 1024).toFixed(2),
type: file.type || 'Tipo desconhecido',
});
setPreview(null);
}
}, [file]);
if (!file) return null;
return (
<div className="file-preview">
{preview ? (
<div className="image-preview">
<img src={preview} alt="Preview" style={{ maxWidth: '100%', maxHeight: '300px' }} />
</div>
) : (
<div className="file-info">
<p><strong>Arquivo:</strong> {fileInfo?.name}</p>
<p><strong>Tamanho:</strong> {fileInfo?.size} KB</p>
<p><strong>Tipo:</strong> {fileInfo?.type}</p>
</div>
)}
<button onClick={onClear} className="clear-button">
Remover Arquivo
</button>
</div>
);
};
Observe o detalhe crítico: URL.revokeObjectURL() é chamado no cleanup do useEffect. Sem isso, o objeto URL permanece na memória, causando vazamentos. Isso é especialmente importante em aplicações que permitem múltiplos uploads. Para imagens, usamos a preview visual; para outros tipos, exibimos metadados que ajudam o usuário a confirmar se selecionou o arquivo correto.
Indicador de Progresso e Status do Upload
Implementando progresso com Fetch API
O progresso de upload é frequentemente negligenciado porque a Fetch API não oferece suporte nativo. Precisamos usar XMLHttpRequest ou bibliotecas como axios, que encapsulam essa funcionalidade. Vou mostrar ambas as abordagens, começando com XMLHttpRequest (mais baixo nível, maior controle) e depois axios (mais simples e moderna).
A chave é monitorar o evento upload.progress, que dispara múltiplas vezes durante o envio, fornecendo bytes enviados e totais. Com isso, calculamos a porcentagem e atualizamos o estado React.
const useFileUpload = () => {
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatus, setUploadStatus] = useState('idle'); // idle, uploading, success, error
const [uploadError, setUploadError] = useState(null);
const uploadFileXHR = async (file, endpoint) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
// Monitor progresso
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
setUploadProgress(percentComplete);
}
});
// Sucesso
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
setUploadStatus('success');
setUploadProgress(100);
resolve(JSON.parse(xhr.responseText));
} else {
setUploadStatus('error');
setUploadError(`Erro do servidor: ${xhr.status}`);
reject(new Error(`HTTP ${xhr.status}`));
}
});
// Erro de rede
xhr.addEventListener('error', () => {
setUploadStatus('error');
setUploadError('Falha na conexão de rede');
reject(new Error('Network error'));
});
// Cancelamento
xhr.addEventListener('abort', () => {
setUploadStatus('idle');
setUploadProgress(0);
reject(new Error('Upload cancelado'));
});
setUploadStatus('uploading');
xhr.open('POST', endpoint);
xhr.send(formData);
});
};
const resetProgress = () => {
setUploadProgress(0);
setUploadStatus('idle');
setUploadError(null);
};
return {
uploadProgress,
uploadStatus,
uploadError,
uploadFileXHR,
resetProgress,
};
};
O hook acima encapsula toda a lógica de upload. Observe que monitoramos eventos específicos: progress (durante envio), load (conclusão, sucesso ou erro), error (falhas de rede) e abort (cancelamento do usuário). Cada evento atualiza o estado React de forma apropriada. Essa abordagem oferece controle completo, mas é verbosa. Para projetos modernos, use axios, que abstrai esses detalhes:
import axios from 'axios';
const uploadFileAxios = async (file, endpoint) => {
try {
const formData = new FormData();
formData.append('file', file);
const response = await axios.post(endpoint, formData, {
onUploadProgress: (progressEvent) => {
const percentComplete = (progressEvent.loaded / progressEvent.total) * 100;
setUploadProgress(percentComplete);
},
headers: {
'Content-Type': 'multipart/form-data',
},
});
setUploadStatus('success');
return response.data;
} catch (error) {
setUploadStatus('error');
setUploadError(error.message);
throw error;
}
};
Com axios, o mesmo resultado é alcançado em metade do código. O callback onUploadProgress simplifica o monitoramento. A escolha entre XMLHttpRequest e axios depende das dependências do seu projeto: se já usa axios, não há motivo para XMLHttpRequest; se quer minimizar dependências externas, XMLHttpRequest nativo funciona perfeitamente.
Componente Completo: Integração de Tudo
Juntando validação, preview e progresso
Agora uniremos todos os conceitos em um componente robusto e pronto para produção. Este componente será reutilizável, com configurações via props.
import React, { useState, useCallback } from 'react';
import axios from 'axios';
const FileUploadComponent = ({
endpoint = '/api/upload',
acceptedMimes = ['image/jpeg', 'image/png', 'image/gif'],
maxSizeMB = 5,
onUploadSuccess = () => {},
onUploadError = () => {},
}) => {
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatus, setUploadStatus] = useState('idle');
const [validationErrors, setValidationErrors] = useState([]);
const [uploadError, setUploadError] = useState(null);
// Validação
const validateFile = useCallback((selectedFile) => {
const errors = [];
const maxSizeBytes = maxSizeMB * 1024 * 1024;
if (selectedFile.size > maxSizeBytes) {
errors.push(
`Arquivo excede ${maxSizeMB}MB (tamanho: ${(selectedFile.size / 1024 / 1024).toFixed(2)}MB)`
);
}
if (!acceptedMimes.includes(selectedFile.type)) {
errors.push(
`Tipo não permitido. Aceitos: ${acceptedMimes.map(m => m.split('/')[1]).join(', ')}`
);
}
setValidationErrors(errors);
return errors.length === 0;
}, [acceptedMimes, maxSizeMB]);
// Manipular seleção de arquivo
const handleFileSelect = useCallback((event) => {
const selectedFile = event.target.files?.[0];
if (!selectedFile) return;
if (!validateFile(selectedFile)) {
return;
}
setFile(selectedFile);
setValidationErrors([]);
// Gerar preview para imagens
if (selectedFile.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => setPreview(e.target.result);
reader.readAsDataURL(selectedFile);
}
}, [validateFile]);
// Upload com axios
const handleUpload = useCallback(async () => {
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
setUploadStatus('uploading');
setUploadError(null);
const response = await axios.post(endpoint, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
setUploadProgress(Math.round(percentComplete));
}
},
});
setUploadStatus('success');
onUploadSuccess(response.data);
resetForm();
} catch (error) {
setUploadStatus('error');
const errorMsg = error.response?.data?.message || error.message;
setUploadError(`Upload falhou: ${errorMsg}`);
onUploadError(error);
}
}, [file, endpoint, onUploadSuccess, onUploadError]);
const resetForm = useCallback(() => {
setFile(null);
setPreview(null);
setUploadProgress(0);
setUploadStatus('idle');
setValidationErrors([]);
setUploadError(null);
}, []);
return (
<div className="upload-container" style={{ maxWidth: '500px', margin: '0 auto' }}>
<div className="upload-zone">
<label htmlFor="file-input" className="upload-label">
Selecione um arquivo ou arraste aqui
</label>
<input
id="file-input"
type="file"
accept={acceptedMimes.join(',')}
onChange={handleFileSelect}
disabled={uploadStatus === 'uploading'}
style={{ display: 'none' }}
/>
</div>
{/* Erros de validação */}
{validationErrors.length > 0 && (
<div className="error-messages" style={{ color: 'red', marginTop: '1rem' }}>
{validationErrors.map((error, idx) => (
<p key={idx}>❌ {error}</p>
))}
</div>
)}
{/* Preview */}
{preview && (
<div className="preview-container" style={{ marginTop: '1rem' }}>
<img src={preview} alt="Preview" style={{ maxWidth: '100%', height: 'auto' }} />
</div>
)}
{/* Info do arquivo */}
{file && (
<div className="file-info" style={{ marginTop: '1rem', padding: '0.5rem', backgroundColor: '#f0f0f0', borderRadius: '4px' }}>
<p><strong>Arquivo:</strong> {file.name}</p>
<p><strong>Tamanho:</strong> {(file.size / 1024).toFixed(2)} KB</p>
</div>
)}
{/* Progresso */}
{uploadStatus === 'uploading' && (
<div className="progress-container" style={{ marginTop: '1rem' }}>
<div style={{ width: '100%', height: '20px', backgroundColor: '#e0e0e0', borderRadius: '4px', overflow: 'hidden' }}>
<div
style={{
width: `${uploadProgress}%`,
height: '100%',
backgroundColor: '#4CAF50',
transition: 'width 0.3s',
}}
/>
</div>
<p style={{ marginTop: '0.5rem', textAlign: 'center' }}>{uploadProgress}%</p>
</div>
)}
{/* Status e Erros */}
{uploadStatus === 'success' && (
<p style={{ color: 'green', marginTop: '1rem' }}>✅ Upload realizado com sucesso!</p>
)}
{uploadError && (
<p style={{ color: 'red', marginTop: '1rem' }}>❌ {uploadError}</p>
)}
{/* Botões */}
<div className="action-buttons" style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
{file && uploadStatus !== 'uploading' && (
<>
<button
onClick={handleUpload}
disabled={validationErrors.length > 0 || uploadStatus === 'uploading'}
style={{ padding: '0.5rem 1rem', backgroundColor: '#2196F3', color: 'white', border: 'none', cursor: 'pointer', borderRadius: '4px' }}
>
Upload
</button>
<button
onClick={resetForm}
style={{ padding: '0.5rem 1rem', backgroundColor: '#f44336', color: 'white', border: 'none', cursor: 'pointer', borderRadius: '4px' }}
>
Cancelar
</button>
</>
)}
</div>
</div>
);
};
export default FileUploadComponent;
Este componente integra toda a lógica: validação, preview, progresso e tratamento de erros. Note os detalhes importantes: o input é desabilitado durante upload para evitar seleções simultâneas; erros de validação impedem o upload; o progresso é arredondado para evitar atualizações excessivas; o cleanup (resetForm) prepara para novos uploads.
Conclusão
Os três pilares do upload robusto em React são: validação em camadas (tamanho, MIME, extensão), preview responsivo (usando URL.createObjectURL para imagens e metadados para outros tipos), e monitoramento de progresso (através de onUploadProgress do axios ou eventos progress do XMLHttpRequest). Implementar esses três aspectos simultaneamente evita frustrações do usuário e aumenta a confiabilidade da aplicação.
Um detalhe que muitos desenvolvedores esquecem é sempre validar novamente no servidor, pois toda validação cliente pode ser contornada. O React fornece a experiência visual; o backend fornece a segurança real.