Try   HackMD

Dynamic forms with DRF & React

Connecting REST APIs with frontend components is often a time-consuming task. Developers typically grapple with crafting HTML forms, implementing val``idation (using libraries like Yup, Zod, or Joi), and integrating them with the API. This process can be significantly streamlined by leveraging the power of reusable components and Django DRF's exceptional capabilities.

In this article, we'll explore how to expedite this process and enhance development efficiency. By combining well-structured reusable components with Django DRF's robust features, you can dramatically reduce development time and create a more maintainable codebase.

Discover how to supercharge your frontend development with our expert Django development services.

Backend setup

We first create a django Model that will be used to store the data. lets use a Books API model.

Language: python
Path: books/models.py

from django.db import models
from django.contrib.auth.models import User

class Book(models.Model):
    title = models.CharField(max_length=255)
    author = models.CharField(max_length=255)
    description = models.TextField()
    sales = models.IntegerField(default=0)
    published = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    user = models.ForeignKey(User, related_name="books", on_delete=models.CASCADE)

We need a serializer to convert the model to JSON.

Language: python
Path: books/serializers.py

from rest_framework import serializers
from .models import Book

class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = (
            "id",
            "title",
            "author",
            "description",
            "sales",
            "published",
            "created_at",
            "updated_at",
        )

The view for this file is located in books/views.py

Language: python
Path: books/views.py

from rest_framework import generics
from rest_framework.response import Response

class BookList(generics.ListCreateAPIView):
    queryset = Book.objects.all()
    serializer_class = BookSerializer

    def perform_create(self, serializer):
        serializer.save(user=self.request.user)

the urls for the API are created in the same file.

Language: python
Path: books/urls.py

from django.urls import path
from . import views

router = routers.DefaultRouter()
# register the urls for the API
router.register(r'books', views.BookList, basename='books')

urlpatterns = [
    path('', include(router.urls)),
]

This is the basic setup on the django backend. this should give us a usable API endoint /api/books/. (based on how we setup the project)

Frontend setup

We are now ready to start building our frontend. We will use React and tailwindCSS build our frontend.
reference: https://tailwindcss.com/docs/installation

For forms we will be using the react-hook-form library.

Lets start by creating a new form component.

Standard method

Language: javascript
Path: components/books/forms.js

import React, { useState } from "react";
import { useForm } from "react-hook-form";

const BookForm = () => {
  const {
    register,
    handleSubmit,
    formstate: { errors },
  } = useForm();

  const onSubmit = async (data) => {
    // your submit logic here
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div className="flex flex-col">
        <label htmlFor="title">Title</label>
        <input
          type="text"
          name="title"
          className={`${errors.title && "border-red-500"}`}
          {...register(title)}
        />
        <span className="text-red-500">{errors[title]?.message}</span>
      </div>
      <div className="flex flex-col">
        <label htmlFor="author">Author</label>
        <input
          type="text"
          name="author"
          className={`${errors.author && "border-red-500"}`}
          {...register(author)}
        />
        <span className="text-red-500">{errors[author]?.message}</span>
      </div>
      <div className="flex flex-col">
        <label htmlFor="description">Description</label>
        <textarea
          name="description"
          className={`${errors.description && "border-red-500"}`}
          {...register(description)}
        />
        <span className="text-red-500">{errors[description]?.message}</span>
      </div>
      <>...</>
      <>...</>
      <>Other form fields</>
      <>...</>
      <>...</>
      <div className="flex flex-col">
        <button
          type="submit"
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
          disabled={isSubmitting}
        >
          {isSubmitting ? "Submitting..." : "Submit"}
        </button>
      </div>
    </form>
  );
};

Problems

  • the form is very verbose and quite repetitive.
  • there are a lot of fields that are not required.
  • the form is not connected to the backend directly / for eg if a field in backend is changed, the form will not update.
  • developers have to manually update the form when the backend changes.

Lets try and fix these problems.

Solution

1 Reusable components

Lets start by creating input components which play nice with react hook forms and are reusable.

Language: javascript
Path: components/forms/inputs.js

import React from "react";

const Input = ({ register, label, type, name, errors, ...props }) => {
  // a generic input component that can be used for any field
  return (
    <div className="my-3 flex w-full flex-col items-start justify-between">
      <span className="form-title">{label}</span>
      <input
        type={type}
        className={`input-bordered input w-full ${
          errors[name] && "input-error"
        } bg-gray-50 px-2 py-2`}
        {...register(name)}
        {...props}
      />
      <span className="text-sm text-red-500">{errors[name]?.message}</span>
    </div>
  );
};

const BooleanInput = ({ register, label, name, errors, ...props }) => {
  // a checkbox component that can be used for boolean fields
  return (
    <div className="my-3 flex w-full flex-col items-start justify-between">
      <span className="form-title">{label}</span>
      <div className="w-full">
        <input
          type="checkbox"
          className={`input-bordered input toggle-primary toggle toggle-lg ${
            errors[name] && "input-error"
          } bg-gray-50 px-2 py-2`}
          {...register(name)}
          {...props}
        />
        <span className="text-sm text-red-500">{errors[name]?.message}</span>
      </div>
    </div>
  );
};

const SearchableSelect = ({ label, options, onChange, ...props }) => {
  // a searchable select component that can be used for any choice fields
  return (
    <div className="my-3 w-full">
      <span className="form-title">{label}</span>
      <Select options={options} onChange={onChange} {...props} />
    </div>
  );
};

function TextAreaInput({ register, label, type, name, errors, ...props }) {
  // a textarea component that can be used for any text fields
  return (
    <div className="my-3 flex w-full flex-col items-start">
      <span className="form-title">{label}</span>
      <div className="w-full">
        <textarea
          rows="3"
          className={`textarea-bordered textarea w-full ${
            errors[name] && "input-error"
          } bg-gray-50 px-2 py-2`}
          {...register(name)}
          {...props}
        />
        <span className="text-sm text-red-500">{errors[name]?.message}</span>
      </div>
    </div>
  );
}

2 Form component

lets build a form component that will be used to create new dyanamic forms composed of the above components.

Language: javascript
Path: components/forms/form.js

const Form = ({ fields, hook }) => {
  // destructuring the hook object to get the form state and the form methods
  const {
    register,
    setValue,
    formState: { errors },
  } = hook;

  // map over the fields array and create a form component for each field
  return fields.map((item) => {
    if (item.type == "select")
      return (
        <div key={item.name}>
          <SearchableSelect
            placeholder={
              item.placeholder ? item.placeholder : `select ${item.label}`
            }
            onChange={(option) => setValue(item.name, option.value)}
            {...item}
          />
          <span className="text-sm text-red-500">
            {errors[item.name]?.message}
          </span>
        </div>
      );
    if (item.type == "textarea")
      return (
        <TextAreaInput
          key={item.name}
          register={register}
          errors={errors}
          {...item}
        />
      );

    if (item.type == "checkbox")
      return (
        <BooleanInput
          key={item.name}
          register={register}
          errors={errors}
          {...item}
        />
      );

    return (
      <Input key={item?.name} register={register} errors={errors} {...item} />
    );
  });
};

3 Dynamic form

Lets build a dynamic form that will be used to create new dyanamic forms composed of the above components.

Language: javascript
Path: components/forms/dynamic-form.js

export const AutoForm = ({
  fields,
  formHook,
  onSubmit,
  submitButton,
  ...props
}) => {
  const { handleSubmit } = formHook;
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div {...props}>
        <Form fields={fields} hook={formHook} {...props} />
      </div>
      <div className="mt-8 flex justify-end">
        <button type="submit" className="btn flex gap-2">
          {submitButton ? submitButton : "submit"}
        </button>
      </div>
    </form>
  );
};

This component will be used to create dynamic forms. when we pass in a object array of fields, it will create a form with the fields. fields array will typically look like this

const fields = [
  {
    name: "title",
    label: "Title of the book",
    type: "text",
    placeholder: "Enter name",
  },
  {
    name: "author",
    label: "Author of the book",
    type: "text",
    placeholder: "Enter author",
  },
  {
    name: "description",
    label: "Description of the book",
    type: "textarea",
    placeholder: "Enter description",
  },
  {
    name: "published",
    label: "Published",
    type: "checkbox",
    placeholder: "Is the book published?",
  },
  {
    name: "sales",
    label: "Sales",
    type: "number",
    placeholder: "Enter sales",
  }
  {
    name: "created_at",
    label: "Created",
    type: "date",
    placeholder: "Enter date",
  },
];

4 Getting form data

If we send a request using OPTIONS method to the api, django will return the expected shape of data it will accept. if we send this to our books api, it will return the following

Language: javascript
Path: components/forms/form-data.js

const res = await fetch("/api/books/", {
  method: "OPTIONS",
  headers: {
    "Content-Type": "application/json",
  },
});
const data = await res.json();
console.log(data);
{
  "title": {
    "required": true,
    "type": "string",
    "max_length": 100
  },
  "author": {
    "required": true,
    "type": "string",
    "max_length": 100
  },
  "description": {
    "required": false,
    "type": "string",
    "max_length": 1000
  },
  "published": {
    "type": "boolean"
  },
  "sales": {
    "required": false,
    "type": "number"
  },
  "created_at": {
    "type": "date"
  }
}

We can use this JSON to create a dynamic form. But first we need transform the JSON to a form array. that we are expecting to see in the form.

5 Form array

Here we will do some transformation on the JSON to create a form array.

Language: javascript
Path: pages/sample.js

const SamplePage = () => {
  let url = "/api/books/";

  const res = await fetch(url, {
    method: "OPTIONS",
    headers: {
      "Content-Type": "application/json",
    },
  });
  const data = await res.json();

  let JSONschema = data.actions.POST;

  const { formFields, schema } = buildForm({
    JSONschema,
    exclude: ["created_at", "created_by"],
  });

  const formHook = useForm({ resolver: yupResolver(schema) });

  const { formstate } = formHook;
  console.log(formstate?.errors);
  const onSubmit = (data) => {
    console.log("submit", data);
    // POST data to the api
    // and other specific logic
  };

  return (
    <div className="bg-gray-100 px-16 py-8">
      <h1 className="mb-8 border-t-2 pt-8">AutoForm</h1>
      <AutoForm
        fields={formFields}
        formHook={formHook}
        onSubmit={onSubmit}
        className={"grid gap-4 md:grid-cols-2"}
      />
    </div>
  );
};

export default SamplePage;

6 Helper functions

lets take a look at buildForm function. which does all the heavy lifting.
this function takes in a JSON schema from the django DRF API and returns a form array and a schema.

Language: javascript
Path: components/forms/build-form.js

import { generateYupSchema } from "./yupSchemaGenerator";

export const removeKeys = (schema, excludeFields = []) => {
  return Object.keys(schema)
    .filter((key) => !schema[key].read_only) // remove read_only fields
    .reduce((acc, key) => {
      // remove excluded fields
      if (!excludeFields.includes(key)) {
        // delete the read_only attribute of object, this causes errors in react
        delete schema[key].read_only;
        acc[key] = schema[key];
      }
      return acc;
    }, {});
};

export const transformInputTypes = (schema) => {
  Object.keys(schema).forEach((key) => {
    if (
      schema[key].type === "integer" ||
      schema[key].type === "decimal" ||
      schema[key].type === "float" ||
      schema[key].type === "number"
    ) {
      schema[key].type = "number";
    }
    if (
      schema[key].type === "file upload" ||
      schema[key].type === "image upload"
    ) {
      schema[key].type = "file";
    }
    if (schema[key].type === "boolean") {
      schema[key].type = "checkbox";
    }

    if (schema[key].type === "nested object") {
      schema[key].type = "text";
      // deleting the nested object so that it doesn't get rendered in the form
      delete schema[key].children;
    }

    if (schema[key].type === "string") {
      schema[key].type = "text";
    }

    if (schema[key].type === "datetime") {
      schema[key].type = "datetime-local";
    }

    if (schema[key].type === "choice") {
      schema[key].type = "select";
      schema[key].options = schema[key].choices.map((choice) => ({
        value: choice.value,
        label: choice.display_name,
      }));
    }

    if (key === "description" || key === "comment") {
      schema[key].type = "textarea";
    }
  });
  return schema;
};

export const buildFormShape = (schema) => {
  return Object.keys(schema).map((key) => {
    return {
      name: key,
      ...schema[key],
    };
  });
};

export const buildForm = ({ JSONschema, exclude = [] }) => {
  let filtered = removeKeys(JSONschema, exclude);
  let updatedSchema = transformInputTypes(filtered);

  let schema = generateYupSchema(updatedSchema);
  let formFields = buildFormShape(updatedSchema);

  return { formFields, schema };
};

we are using generateYupSchema to generate a yup schema from the JSON schema.

Language: javascript
Path: components/forms/yupSchemaGenerator.js

import * as Yup from "yup";

const getYupType = (schemaObject) => {
  switch (schemaObject.type) {
    case "text":
      return Yup.string();
    case "number":
      return Yup.number();
    case "boolean":
      return Yup.boolean();
    case "file":
      return Yup.mixed();
    case "array":
      return Yup.array();
    case "object":
      return Yup.object();
    case "date":
      return Yup.date();
    case "time":
      return Yup.time();
    case "datetime":
      return Yup.datetime();
    default:
      return Yup.string();
  }
};

const checkRequired = (schemaObject, yupObject) => {
  if (schemaObject.required) {
    return yupObject.required();
  }
  return yupObject;
};

const addValidation = (schemaObject, yupObject) => {
  let obj = yupObject;
  if (schemaObject.max_length && schemaObject.type !== "file") {
    obj = obj.max(
      schemaObject.max_length,
      `${schemaObject.title} must be less than ${schemaObject.max_length} characters`
    );
  }
  return obj;
};

export const generateYupSchema = (schema) => {
  let shape = {};
  Object.keys(schema).forEach((key) => {
    let schemaObject = schema[key];
    let yupObject = getYupType(schemaObject);
    yupObject = checkRequired(schemaObject, yupObject);
    yupObject = addValidation(schemaObject, yupObject);
    shape = { ...shape, ...{ [key]: yupObject } };
  });
  let yupSchema = Yup.object().shape(shape);
  return yupSchema;
};

Conclusion

On the sample page, we have demonstrated how to use the AutoForm component.
We have also defined a url variable. by changing this url we can get form from another API like /api/authors/, /api/users/, etc. without having to write separate forms for them.
simply change the url and we are on our way to create Dynamic forms driven by our API.