Upload de Arquivos em React: Preview, Progress e Validação de Tipo na Prática Já leu

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 ou ) 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 e , 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

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.

Referências


Artigos relacionados