Building React SPA for Base64 images encoding

Intro

Once in a while when building frontend applications you would like to deliver some of the images as separate assets rather than bundle into html / css directly.

Main reasons for including images directly into page could be:

Possibly some other reasons exists. This guide would show how to build SPA which would accept image files and generate base64 encoded payload data url

Contents

Sections in this guide:

Impatient section

Code available at https://gitlab.com/base64img/base64img.gitlab.io

Application deployed at https://base64img.gitlab.io/

Drag & Drop some images or click dropzone for selecting image files

Generate Application with react-scripts

Navigate to some directory. And execute create-react-app without installation:

$ npx create-react-app base64img.gitlab.io --template typescript

This would create application in base64img.gitlab.io folder.

Run fresh application:

$ cd base64img.gitlab.io
$ yarn start

Browser would open http://localhost:3000 automatically.

Check generated README.md within project or even read about more options available for create-react-app here

Adding Drag&Drop dropzone

Next, add react-dropzone to be able to upload files.

Files would remain in browser only. No backend needed for this SPA.

$ yarn add react-dropzone

Modify main src/App.tsx:

import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';

export function App() {
  const [encodedFiles, setEncodedFiles] = useState<File[]>([]);
  const onDrop = useCallback((acceptedFiles: File[]) => {
    setEncodedFiles(acceptedFiles);
  }, []);
  const { acceptedFiles, getRootProps, getInputProps } = useDropzone({
    accept: ["image/jpeg", "image/png", "image/gif", "image/*"],
    maxSize: 100000000, // 100 mb
    multiple: true,
    onDrop
  });
  return (
    <div >
        <div {...getRootProps()}>
          <input {...getInputProps()} />
          <p>Drag 'n' drop some files here, or click to select files</p>
        </div>
        {encodedFiles.map((file: File, idx: number) => {
          return (
            <div key={idx}>
              <b>{file.name}</b> ({file.size} bytes)
            </div>
          )
        })}
    </div>
  );
}

useDropzone is a special hook provided by react-dropzone and gives access to useful properties provided by Drag&Drop. It also accepts properties to configure behavior of file input component:

For more options check documentation

onDrop would be so far just useCallback hook which sets encodedFiles in state.

For UI part there are some styles given by react-dropzone:

const { getRootProps, getInputProps } = useDropzone();
 <div {...getRootProps()}>
    <input {...getInputProps()} />
    <p>Drag 'n' drop some files here, or click to select files</p>
</div>

Selected files then rendered into html:

{encodedFiles.map((file: File, idx: number) => {
    return (
    <div key={idx}>
        <b>{file.name}</b> ({file.size} bytes)
    </div>
    )
})}

Run application:

$ yarn start

Drag&Drop some files to see their names rendered on page.

Add base64 payload encoding

To be able to access files from users file system FileReader would be used. It cant access ANY file, just given ones.

loadFile would wrap everything related to FileReader:

const loadFile = (file: File): Promise<EncodedFile> => new Promise((res, rej) => {
  var reader = new FileReader();
  let base = {
    name: file.name,
    size: file.size,
  }
  reader.addEventListener("abort", e => rej(`File upload aborted:${e}`));
  reader.addEventListener("error", e => rej(`File upload error: ${e}`));
  reader.addEventListener("load", () => res({
    ...base,
    encoded: reader.result as string
  }), false);
  reader.readAsDataURL(file);
})

On “load” event it would return reader.result

Those two types would represent success and error records in application:

interface EncodedFile {
  name: string;
  size: number;
  encoded: string; // base64 encoded content
}
interface ErrorFile {
  name: string;
  size: number;
  error: string; // error description
}

Add some more state and use loadFile function:

  const [encodedFiles, setEncodedFiles] = useState<EncodedFile[]>([]);
  const [errors, setErrors] = useState<ErrorFile[]>([]);

  const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: File[]) => {
    setErrors(rejectFiles(rejectedFiles)); // set/reset errors
    setEncodedFiles([]); // reset UI
    acceptedFiles.forEach((file: File) =>
      loadFile(file)
        .then(encFile => setEncodedFiles(list => [...list, encFile]))
        .catch(error => setErrors(list => [...list, {
          name: file.name,
          size: file.size,
          error
        }]))
    );
  }, []);

Where rejectFiles:

const rejectFiles = (files: File[]): ErrorFile[] => files.map(f => ({
  name: f.name,
  size: f.size,
  error: 'File rejected'
}))

Within modified onDrop:

Images with base64 data would be available within state variable encodedFiles, and uploading errors/rejected files in errors.

Using material-ui

For UI styling would be used material-ui which provide React components and CSS-in-JS solution for components styling.

Add material-ui:

$ yarn add @material-ui/core

App.jsx would have such lines added:

import { makeStyles } from '@material-ui/core/styles';
import { Container, Card, CardContent, Typography, TextField, LinearProgress } from '@material-ui/core';

const useStyles = makeStyles(theme => ({
  root: {
    flexGrow: 1,
  },
  header: {
    padding: '20px 0'
  },
  dropzone: {
    flex: '1',
    display: 'flex',
    flexDirection: 'column',
    alignItems: 'center',
    padding: '20px',
    borderWidth: '2px',
    borderRadius: '2px',
    borderColor: '#eeeeee',
    borderStyle: 'dashed',
    backgroundColor: '#fafafa',
    color: '#bdbdbd',
    outline: 'none',
    transition: 'border .24s ease-in-out'
  },
  cardContent: {
    minHeight: 50,
    minWidth: 50,
    maxWidth: 100,
    maxHeight: 100,
    margin: 20
  }
}));

export function App() {
  const classes = useStyles();
  const [encodedFiles, setEncodedFiles] = useState<EncodedFile[]>([]);
  const [errors, setErrors] = useState<ErrorFile[]>([]);

  const { acceptedFiles, getRootProps, getInputProps } = useDropzone({/** ... **/})
  /** dropzone logic skipped here **/

  return (
    <div className={classes.root}>
      <Container maxWidth="md">
        <Typography variant="h4" className={classes.header}>
          Base 64 encode image to be used inline in "src" attribute
        </Typography>
        <div {...getRootProps({ className: classes.dropzone })}>
          <input {...getInputProps()} />
          <p>Drag 'n' drop some files here, or click to select files</p>
        </div>
        <div>
          {errors && errors.map((err: ErrorFile, idx: number) => (
            <div key={idx}>
              <Typography color="secondary" variant="h5">
                <b>{err.name}</b> ({err.size} bytes): {err.error}
              </Typography>
            </div>
          ))}
        </div>
        <div>
          {acceptedFiles.length !== encodedFiles.length &&
            <div>
              <Typography color="secondary" variant="h5">
                Processing {acceptedFiles.length - encodedFiles.length} files. Wait a moment ...
              </Typography>
              <br />
              <LinearProgress />
              <br />
            </div>
          }
        </div>
        {encodedFiles && encodedFiles.map((file: EncodedFile, idx: number) => (
          <div key={idx}>
            <Card>
              <img src={file.encoded} alt={file.name} className={classes.cardContent} />
              <CardContent>
                <Typography gutterBottom variant="h5" component="h2">
                  <b>{file.name}</b> ({file.size} bytes)
                  </Typography>
                <TextField
                  label="Full img tag"
                  fullWidth
                  value={`<img alt="${file.name}" src="${file.encoded}"/>`}
                  margin="normal"
                  variant="outlined"
                />
                <TextField
                  label="Base64 encoded. Copy-paste into 'src' attribute"
                  fullWidth
                  value={file.encoded}
                  margin="normal"
                  InputLabelProps={{
                    shrink: true,
                  }}
                  variant="outlined"
                  multiline
                  rows="8"
                />
              </CardContent>
            </Card>
          </div>
        ))}
      </Container>
    </div>
  );
}

Errors would be rendered into <Typography color="secondary" variant="h5">

Additional message representing current progress added:

<div>
    {acceptedFiles.length !== encodedFiles.length &&
    <div>
        <Typography color="secondary" variant="h5">
        Processing {acceptedFiles.length - encodedFiles.length} files. Wait a moment ...
        </Typography>
        <br />
        <LinearProgress />
        <br />
    </div>
    }
</div>

acceptedFiles available immediately after files selection and encodedFiles would be available slightly later

Each uploaded file would be represented by Card component which internally would render preview using resulting base64 encoded string:<img src={file.encoded} alt={file.name} />

On this step, full App.jsx could be copied locally from repository

Run full application:

$ yarn start

Browser would open local page http://localhost:3000/.

After selecting some files UI would display results:

base64 SPA

Deploy to gitlab pages

To have nice public Url like this: https://base64img.gitlab.io when using GitLab pages Gitlab account with base64img name should be registered and have repository called base64img.gitlab.io.

This is called User Page at Gitlab Pages.

Other .gitlab-ci.yml contents looks lik regular CD pipeline for deploying pages:

image: node:12

pages:
  stage: deploy
  script:
  - yarn install --frozen-lockfile
  - NODE_ENV=production PUBLIC_URL="https://base64img.gitlab.io/" yarn build
  - rm -rf public/
  - mv build/ public/
  artifacts:
    paths:
    - public
  only:
  - master

CD steps:

  1. Install yarn dependencies based on yarn.lock (because of --frozen-lockfile)

  2. Execute yarn build which would invoke react-scripts and prepare production build of React Application. Important here to provide PUBLIC_URL.

  3. Gitlab pages require to have contents in public folder, so after yarn build, remove public folder with source files. And move build artifacts from build into public

Check more info about Gitlab Pages.

Conclusion

Building SPA’a nowadays is pretty simple with create-react-app. It does setup all necessary pipelines for transpiling typescript and react code into javascript. For styling React UI, material-ui is my first choice due to it’s simple interface and good quality documentation. As an advantage I would mention included CSS-in-JS solution, so no scss,less,css separate files needed at all.

Regarding SPA functionality, react-dropzone is a proven choice with simple interface and many useful options to tweak UI and internal functionality.

When developers should write less code, less errors could potentially be introduced

And as usually Gitlab pages is a good place to host SPA’s with build pipelines.