DEV Community

Cover image for Uploading Images to Django REST Framework from Forms in React
Thom Zolghadr
Thom Zolghadr

Posted on

Uploading Images to Django REST Framework from Forms in React

I have built a number of apps now that use either built-in fetch API or Axios to handle sending JSON data to the back-end. This is usually pretty straight forward with applications like Django REST Framework (DRF). DRF's serializers practically do all the work for you, converting Python data into JSON and back.

An issue I ran into recently was when trying to upload images to one of my applications for the first time. I was getting all sorts of errors such as:

"The submitted data was not a file. Check the encoding type on the form." or "No file was submitted."

This is how I learned about JavaScript's FormData, and that when receiving files, Django REST Framework expects files to come through in this format, with "Content-Type" headers set to "multipart/form-data" and uses parsers to correctly handle this data.

So to minimize digging on my own part in the future, as well as to anyone else who may have spent days like I did trying to understand the problem and figuring out how to proceed, here is how I got image uploads working in my project:

Django
  1. Add media file/set media locations to settings.py
  2. Add media locations to urls.py
  3. Create ImageField on model
  4. Add parsers to Viewset
  5. Add ImageField to Serializer
React
  1. Receive state data from form
  2. Convert data into FormData
  3. Create an Axios call with correct headers
  4. Receive any errors to display on form

Djangoside

1. Add media file/set media locations to settings.py

Add MEDIA_ROOT and MEDIA_URL to settings.py MEDIA_ROOT is where our files are actually stored. MEDIA_URL is where they will be accessed from front end via URL.

settings.py

import os

# Actual directory user files go to
MEDIA_ROOT = os.path.join(os.path.dirname(BASE_DIR), 'mediafiles')

# URL used to access the media
MEDIA_URL = '/media/'
Enter fullscreen mode Exit fullscreen mode

2. Add media locations to urls.py

Add static URL to urls.py in our main project folder. This allows the application to know what folder to access on the server side when receiving a request from the MEDIA_URL. Without this, the application wont know what to do when receiving a urlpattern of 'mysite.com/media/'

urls.py

from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/users/', include('users.urls')),
    path('api/', include('commerce.urls')),

] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Enter fullscreen mode Exit fullscreen mode

3. Create ImageField on model

Next we create our field 'image_url' on the model and set it to an ImageField(). The kwarg upload_to is set to our function of the same name.

models.py


# lets us explicitly set upload path and filename
def upload_to(instance, filename):
    return 'images/{filename}'.format(filename=filename)

class MyModel(models.Model):
    creator = models.ForeignKey(
        User, on_delete=models.CASCADE, related_name="listings")
    title = models.CharField(
        max_length=80, blank=False, null=False)
    description = models.TextField()
    image_url = models.ImageField(upload_to=upload_to, blank=True, null=True)
Enter fullscreen mode Exit fullscreen mode

Don't forget any time we update the models we need to run
python manage.py makemigrations
python manage.py migrate

4. Add parsers to Viewset

Parsers are tools provided by DRF that will automatically be used to parse out FormData. Without this we'll get errors because the data won't be properly decoded for DRF's serializers to read. See 'parser_classes.'

views.py

from .models import MyModel
from .serializers import MyModelSerializer
from rest_framework import permissions
from rest_framework.parsers import MultiPartParser, FormParser

class MyModelViewSet(viewsets.ModelViewSet):
    queryset = MyModel.objects.order_by('-creation_date')
    serializer_class = MyModelSerializer
    parser_classes = (MultiPartParser, FormParser)
    permission_classes = [
        permissions.IsAuthenticatedOrReadOnly]

    def perform_create(self, serializer):
        serializer.save(creator=self.request.user)
Enter fullscreen mode Exit fullscreen mode

5. Add ImageField to Serializer

Add the field definition on our serializer and set it to serializers.ImageField(). Since our model does not require an image_url, we'll add the kwarg 'required=false' to avoid problems when receiving the FormData without an image.

from rest_framework import serializers
from .models import MyModel

class MyModelSerializer(serializers.ModelSerializer):

    creator = serializers.ReadOnlyField(source='creator.username')
    creator_id = serializers.ReadOnlyField(source='creator.id')
    image_url = serializers.ImageField(required=False)

    class Meta:
        model = MyModel
        fields = ['id', 'creator', 'creator_id', 'title', 'description', 'image_url']

Enter fullscreen mode Exit fullscreen mode

That should do it for the back-end! If I didn't forget anything we should now be able to send form-data via Postman and receive either successfully submitted data back, or any errors/missing required fields.


Reactside

1. Receive state data from form

I'm assuming you already have a form and any necessary onChange data. The major mistake I made was not writing a separate handleImageChange or handleFileChange for the file input on my form, since a regular text input is different from a file input.

We use useState hook to create [data, setData] and errors, setErrors

In my title and description input fields you'll see I use just a simple onChange={(e)=>{handleChange(e)}}. handleChange takes the onChange event and appropriately assigns the data[input.name] = input.value.

This won't work for a file though, because files are handled diffrently. So in our file input we need to specify a few things:
We need to set a type of "file" so that it knows to open a file picker dialog box. We tell it to only accept the file formats we want, and our onChange now points to a separate function for handling these files.

This separate function will work almost the same as before but instead of assigning input.value, we're assigning event(e).target.files[0], 0 being the first index of any list of files submitted. We're explicitly only taking in one file here.

 <input type="file" 
    name="image_url"
    accept="image/jpeg,image/png,image/gif"
    onChange={(e) => {handleImageChange(e)}}>
Enter fullscreen mode Exit fullscreen mode

CreateMyModelForm.js

import React, { useState } from "react";

// React-Bootstrap
import Form from "react-bootstrap/Form";
import Button from "react-bootstrap/Button";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import API from "../../API";

const CreateMyModel = () => {

    const [data, setData] = useState({
        title: "",
        description: "",
        image_url: "",
    });
    const [errors, setErrors] = useState({
        title: "",
        description: "",
        image_url: "",
    });


    const handleChange = ({ currentTarget: input }) => {
        let newData = { ...data };
        newData[input.name] = input.value;
        setData(newData);
    };

    const handleImageChange = (e) => {
        let newData = { ...data };
        newData["image_url"] = e.target.files[0];
        setData(newData);
    };

    const doSubmit = async (e) => {
        e.preventDefault();
        const response = await API.createMyModelEntry(data);
        if (response.status === 400) {
            setErrors(response.data);
        }
    };

    return (

        <Form>
            <Row>
                <Form.Group className="mb-3" controlId="titleInput">
                    <Form.Label>Title</Form.Label>
                    <Form.Control
                        type="text"
                        name="title"
                        value={data.title}
                        isInvalid={errors.title}
                        onChange={(e) => {
                            handleChange(e);
                        }}
                        maxLength={80}
                    />
                    {errors.title && (
                        <Form.Text className="alert-danger" tooltip>
                            {errors.title}
                        </Form.Text>
                    )}
                </Form.Group>
            </Row>
            <Row>
                <Form.Group controlId="formFile" className="mb-3">
                    <Form.Label>My Image</Form.Label>
                    <Form.Control
                        type="file"
                        name="image_url"
                        accept="image/jpeg,image/png,image/gif"
                        onChange={(e) => {
                            handleImageChange(e);
                        }}
                    />
                    {errors.image_url && (
                        <Form.Text className="alert-danger" tooltip>
                            {errors.image_url}
                        </Form.Text>
                    )}
                </Form.Group>
            </Row>
            <Form.Group className="mb-3" controlId="descriptionInput">
                <Form.Label>Description</Form.Label>
                <Form.Control
                    as="textarea"
                    rows={10}
                    name="description"
                    value={data.description}
                    isInvalid={errors.description}
                    onChange={(e) => {
                        handleChange(e);
                    }}
                />
                {errors.description && (
                    <Form.Text className="alert-danger" tooltip>
                        {errors.description}
                    </Form.Text>
                )}
            </Form.Group>
            <Button
                variant="primary"
                type="submit"
                onClick={(e) => doSubmit(e)}
            </Button>
        </Form>
    );
};

export default CreateMyModel;
Enter fullscreen mode Exit fullscreen mode

2. Convert data into FormData

I have learned to write my API calls in a separate file as a way to avoid violating the DRY Principle and overall keep cleaner code. Here in my API call I know that I need to submit FormData whenever this particular call is made, so we'll handle the FormData creation here.

In the last step you'll see that our doSubmit sends our data to the API call. Here in the API call we receive that data and explicitly append our state data from the previous step to FormData so it can be properly formatted for our back-end parsers.

Recall from earlier that the image is optional. We can't upload null image data though since that will return an error, so we're only going to append the image to the form data if there is one. If not, we'll just leave it out altogether.

snippet from API.js

...
createMyModelEntry: async (data) => {
    let form_data = new FormData();
    if (data.image_url)
        form_data.append("image_url", data.image_url, 
        data.image_url.name);
    form_data.append("title", data.title);
    form_data.append("description", data.description);
    form_data.append("category", data.category);

... 
};

Enter fullscreen mode Exit fullscreen mode

3. Create an Axios call with correct headers

I'm using Axios to send JWT tokens to the back-end of my application, and so there are already some default settings set up for my project. However I need to make sure I'm sending the correct content type with this particular API call.

I import my axiosInstance from my axios settings file and create a POST request at my mymodels/ endpoint, attach my form data, and overwrite my default "Content-Type": "application/json" with "Content-Type": "multipart/form-data" so that we can send this file, and our parsers in Django Rest Framework will recognize it and know to expect/accept a file.

I return the results and check the status. If we have a successful POST, the status will be '201 CREATED' and I know I can redirect from there. If the data isn't accepted and my serializers on the back-end returned an error, these will be accessible via error.response in my catch block.

API.js

import axiosInstance from "./axios";

const apiSettings = {

createListing: async (data) => {
    let form_data = new FormData();
    if (data.image_url)
        form_data.append("image_url", data.image_url, data.image_url.name);
    form_data.append("title", data.title);
    form_data.append("description", data.description);
    form_data.append("category", data.category);
    form_data.append("start_bid", data.start_bid);
    form_data.append("is_active", true);

const myNewModel = await axiosInstance
        .post(`mymodels/`, form_data, {
            headers: {
                "Content-Type": "multipart/form-data",
            },
        }).then((res) => {
            return res;
        }).catch((error) => {
            return error.response;
        });

    if (myNewModel.status === 201) {
        window.location.href = `/mymodels/${myNewModel.data.id}`;
    }
    return myNewModel;
    },
};

export default apiSettings;

Enter fullscreen mode Exit fullscreen mode

4. Receive any errors to display on form

Finally we make sure any errors returned by Django REST Framework serializers can be displayed in our form.

Back in our doSubmit in CreateMyModelForm.js we were awaiting the response of API.createMyModelEntry(). Recall that this API call returns error.response in the catch block if one is encountered. From here we can call setErrors on response.data.

CreateMyModelForm.js doSubmit() function

...
const doSubmit = async (e) => {
    e.preventDefault();
    const response = await API.createMyModelEntry(data);
    if (response.status === 400) {
        setErrors(response.data);
    }
};
...
Enter fullscreen mode Exit fullscreen mode

DRF's serializers will return a JSON object with field names and their corresponding errors. Below is example output of a blank form sent, and trying to upload a .txt file instead of a valid image. Our errors state will now look like the response.data below:

console.log(errors)

{
    "title": [
        "This field is required."
    ],
    "description": [
        "This field is required."
    ],
    "image_url": [
        "Upload a valid image. The file you uploaded was either not an image or a corrupted image."
    ]
}
Enter fullscreen mode Exit fullscreen mode

So now for each our input fields we can can say if errors.[inputName] is not falsy, there must be an error associated with it that field. Below I am using React-Bootstrap to render my title input.

isInvalid is set to errors.title, which means if errors.title is truthy/has data, then the field will be marked as invalid. Below that we're using JSX to say if errors.title is truthy then to render out a tooltip underneath the field with the text of errors.title.

You can see the other fields doing this in detail back in step one of the React portion of this article.

CreateMyModelForm.js Title input field

...
<Form.Group className="mb-3" controlId="titleInput">
    <Form.Label>Title</Form.Label>
    <Form.Control
        type="text"
        name="title"
        value={data.title}
        isInvalid={errors.title}
        onChange={(e) => { handleChange(e);}}
        maxLength={80}
        />
     {errors.title && (
         <Form.Text className="alert-danger" tooltip>
             {errors.title}
         </Form.Text>
     )}
</Form.Group>
...
Enter fullscreen mode Exit fullscreen mode

Server side validation errors displayed on form fields
Here is an example of all 3 fields displaying errors.

That's it! We have the ability to upload files to our Django Rest Framework back-end via our React front end form.

Top comments (6)

Collapse
 
askunlimited profile image
askunlimited • Edited

Thank you for this, was really helpful, but I get this error when trying to test on Postman to create a product.
{
"detail": "Unsupported media type \"application/json\" in request."
}

but when I remove the Parser_classes from my View it seems to work fine

Collapse
 
askunlimited profile image
askunlimited

I was sending a Json data instead of Form-Data that is why. It's working well. Thanks

Collapse
 
oct_adjeitey profile image
Apostle Selman Pikin🔥😁

Struggled a little with implementation, reason was the field in my serializer does not match the one in my model.

Collapse
 
puk2502 profile image
puk2502

Thanks for this, it was really helpful, but what to do when we have to upload multiple files?

Collapse
 
faaraaad profile image
faaraaad

that's great. thank you Thom. Is possible to share repo with us?

Collapse
 
alisiddique profile image
Alisiddique

Great post Thom!! Is there a github repo with this code?