Lukáš Hudec24.2.2017Back

Uploading and resizing images with ReactJS: Part 1 - Client Side

Introduction to the problem

During my development experience on a specific project, I was unable to find any good tutorials or guides on how to make a simple and effective image uploading when your application is running on ReactJS & Redux with a NodeJS-based server, which in our case was powered by AWS.

This is why I decided to write this article: to give you a simple cookbook and save a lot of your time searching for information on Google or Stack Overflow.

All client-side code in this article is written in ECMAScript 2015 mostly known as ES6, which gives you features like arrow functions, object/array destructuring, etc. I am also using JSX syntax which allows you to write a more readable code for React components.

So let’s move on to the subject.

Problem definition

The idea is that you need a simple mechanism which allows the user to submit his or her image and resize/crop it on the server. First you need to upload the image to the server and then the user can select his region of interest in the image. After the user is done with it, the crop parameters will be sent to the server. The server processes the image based on the parameters received and returns a link to the final image.

But there are a few problems you need to solve. What if the image is too large or too small to start with? You need to put a barrier for newbie users that could try to upload very large files (> 2MB) directly from the camera, as processing such an image and displaying it would probably require a lot of effort from the browser. If the image is too large, it’s going to be downscaled to the defined maximum sizes.

If the image is too small, there are many ways to deal with this problem. You could set some minimum specifications on the image directly in the client. Or maybe you could upscale the image on the server. For the sake of simplifying this tutorial, you are not going to deal with it in this section.

Another typical configuration option is how you allow the crop region to be defined.  You usually need to choose between a fixed aspect ratio of the crop area (ideal for avatar images) and free form selection box (backgrounds, content images). This is solved with a used crop library, where you can set the aspect ratio any way you want.

Client-side frameworks and libraries

Our application is using a very trending JavaScript framework ReactJS with a predictable state container library Redux and ImmutableJS for managing the state of your app.

For image cropping I have used React-cropper. This allows you to use the already implemented React component, which is using the famous jQuery cropper plugin. You can check the demo. And you can easily access the component via ref.

Some of the main cropper image manipulation features:

  • Crop
  • Rotation
  • Zoom
  • Aspect Ratio

Let's define the Cropper component with a ref attribute, which is wrapped in a parent component:

<Cropper 
    ref='cropper'
    src={PATH_TO_IMAGE_SOURCE}
    aspectRatio={16 / 9} 
/> 

And you can simply access Cropper’s methods in a parent component by:

this.refs.cropper.getData();

This is all you need from the front-end point of view.

Server-side implementation

We use AWS setup, with S3 for file storage and node.js lambdas for communication (REST).

In fact, the server-side code and full-depth explanation is planned as a part-two article on this matter.

Communication between client (ReactJS & Redux) and server (node.js, AWS)

Let me explain the flow by a simple figure:

Pretty easy right? Now let’s dive deeper into it. Here’s a complete example for uploading the logo of a company.

So, like in the picture above, the image upload consists of 2 phases in total. At first the user selects which file he or she wants to upload as the company logo. You check to see if the selected file is an image by checking the File object’s type property. And then you send it to the AWS. The file is now saved temporarily on the server. And the server gives a link to the saved file. Now you can set the crop parameters in the client. There are two options: with a fixed aspect ratio (for profile pictures (1:1), cover images (21:9), …) and free-to-choose.

Phase two begins after you decide how to crop the image. You send the request with the required crop parameters as the start-position coords and width/height. Then the image will be cropped on the server and saved there. The response you get is a link to the cropped image.

Sounds good? Let’s implement it.

Redux Asynchronous Actions

We expect you to be familiar with Redux and actions to understand what’s coming.

As I wrote before, you need two asynchronous actions for uploading a file to the server and for crop handling. Here I’m using Dan Abramov’s Redux-thunk middleware library to make sure what you dispatch is not an action object, but a function, which will in turn dispatch actions with response data as vanilla Redux reducers expect.

So… you set up 2 asynchronous actions:

export const sendFileToS3 = (credentials, file) => {
   // ...
}

Parameters:

  • credentials is an object containing required AWS credentias (access key id, secret access key, session token)
  • file is HTML5’s File API object.

And the second one:

export const cropImage = (action, key, data) => {
   // ...
}

Parameters:

  • action is an action which wil be dispatched after you get a response from the server
  • key is the ink to the cropped file
  • data is an object which contains crop vaues like x, y coords and width and height.

Now to the details. First we create a function for uploading the file to server. Where you need to setup AWS credentials and other required stuff. So let’s start working on the sendFileToS3 function:

// sendFileToS3
    const bucket = /* Your S3 bucket name */
    const maxSize = 1024 * 1024 * 1024; // 10MB
    AWS.config.credentials = {/* your AWS credentials */};
    AWS.config.region = /* your AWS region */    
    const bucket = new AWS.S3({ params: { Bucket: bucket } });

Then, a thunk returns a function instead of an action object. When the file is uploaded successfully, you get a link to the temporary file, which you are going to crop.

// sendFileToS3
    return (dispatch) => {
        if (file) {
            if (file.size < maxSize) {
                const params = {
                    /* params required by your server API */
                };
		  bucket.upload(params).on('httpUploadProgress', (evt) => {
                    // ... Upload status handling
            }).send((err, data) => {
                    if (err){
                        // error handling
                    } else {
                    	   dispatch(actions.saveTempFile(data.link,     
                               data.key));
                    }
                });
            } else {
                // error handling - File is too big
            }
        }
    }
};

And now the cropImage action. This action is fired after the crop area is defined, and you hit the “Crop” button. Then you send crop data to the server, which will crop the image physically and send you back the final link. Notice the apiGlobalClient, it’s an instance of SDK generated from AWS to use the server’s API endpoints.

export const cropImage = (action, key, data) => {
    return (dispatch) => {
        apiGlobalClient.filePhase2Post({
            /* params required by your server API */
        }, {
            "data": {
                "key":  key,
                "x0":   data.x,
                "y0":   data.y,
                "w":    data.width,
                "h":    data.height,
            }
        }).then((response) => {
	     dispatch(action(response.data)));
        }).catch((error) => {
            // error handling
        })
    }
};

Finally, when you get successful response you dispatch an action so you can save the data in store. The action will be handled by several composed reducers. For easy demonstration how reducer looks like, here is a simple example. Notice, that state is an immutable object from library immutable.js as I mentioned at beginning, and you are calling an update method which updates profileImageSource property and creates new immutable object with updated property.

export const exampleReducer(state, action) => {
    switch(action.type) {
        //… other cases
        case “SET_MEMBER_PROFILE_IMAGE”:
            return state.update(‘profileImageSource’, () =>    
    action.payload);
    }
}

And simple action creator:

export const setMemberProfileImage = data => ({
    type: “SET_MEMBER_PROFILE_IMAGE”,
    payload: data,
});

Binding actions to a button

Let me give an example of a profile picture image crop handling. Here you simply get data from your cropper component via its method getData(), when the user hits the “Crop” button.

<button 
    onClick={() =>     
        this.props.handleSubmit(this.refs.cropper.getData(true))}>
    Crop
</button>

And your handleSubmit implementation is in mapDispatchToProps function, which returns an object of functions, and injects a store’s dispatch to them.

const mapDispatchToProps = dispatch => ({
    handleSubmit: data => dispatch(actions.cropImage(              
        actions.setMemberProfileImage,            
        this.props.temp.get('key'),
        data,
    )),
});

And then you simply put it into react-redux’s connect function, which decorates your component by access to store and dispatch. You can also pass the mapStateToProps function to the connect. Its purpose is to slice a part of an app’s state to the component, so you can easily manage which data goes to the component.

You can select data by what is called selector functions, which take state as a parameter and returns the data you want. In this case I have saved a temporary link to the image in temp state.

const mapStateToProps = state => ({
    temp: state => state.get('temp'),
});

And finally here is how you use connect with all these mapping functions:

export default connect(
    mapStateToProps, 
    mapDispatchToProps,
)(ImageInputDialog);

The whole component code is here:

import React from 'react';
import { connect } from 'react-redux'; 
import Cropper from 'react-cropper';
import '../../../../../node_modules/cropperjs/dist/cropper.css';
import styles from './styles.css';
import * as actions from '../actions';

class ImageInputDialog extends React.Component {
    constructor(props) {
        super(props);
    };

    render = () => {
        const { 
            imageType, 
            temp, 
            handleCancel, 
            handleSubmit, 
            memberId 
        } = this.props;
        const uploadStatus = temp.get('uploadStatus');
        let aspectRatio = null;

        switch (imageType) {
            case 'logo':
            case 'profile':
                aspectRatio = 1;
                break;
            case 'cover':
                aspectRatio = 21 / 9;
                break;
            case 'other':
                aspectRatio = null;
                break;
        }

        return (
            <div> 
                {uploadStatus !== 'finished' ?
                    <div className='loading'>
			            Working... Please wait
                    </div>
                :
                    <Cropper
                        ref='cropper'
                        src={temp.get('link')}
                        style={{height: 500, width: '100%', overflow: 'hidden'}}
                        // Cropper.js options
                        aspectRatio={aspectRatio}
                        guides={false} />
                    <button
                        disabled={uploadStatus !== 'finished'}
                        onClick={handleCancel}
                    >
                        Cancel
                    </button>
                    <button
                        onClick={() => handleSubmit(
                            this.refs.cropper.getData(true),    
                            memberId
                        )} 
                    >
                        Crop
                    </button>
                }
            </div>
        );
    }
}

ImageInputDialog.propTypes = {
    ImageType: React.PropTypes.string,
    temp: React.PropTypes.object,
    handleSubmit: React.PropTypes.func,
    handleCancel: React.PropTypes.func,
};

const mapStateToProps = state => ({
    temp: state => state.get('temp'),
});

const mapDispatchToProps = dispatch => ({
    handleSubmit: data => dispatch(actions.cropImage(              
        actions.setMemberProfileImage,            
        this.props.temp.get('key'),
        data,
    )),
});

export default connect(
    mapStateToProps, 
    mapDispatchToProps,
)(ImageInputDialog);

Final words

I hope that this topic was helpful for you and makes it easier to solve this kind of problem. Look forward to the second part of the series, where we dig into the back-end code.

Lukáš Hudec

Front-end developer

Send Us a Message