Developing a Single Page App with FastAPI and React

Last updated May 20th, 2022

In this tutorial, you'll be building a CRUD app with FastAPI and React. We'll start by scaffolding a new React app with the Create React App CLI before building the backend RESTful API with FastAPI. Finally, we'll develop the backend CRUD routes along with the frontend, React components.

Final app:

Final Todo App

Dependencies:

  • React v18.1.0
  • Create React App v5.0.1
  • Node v18.2.0
  • npm v8.9.0
  • npx v8.9.0
  • FastAPI v0.78.0
  • Python v3.10

Before beginning this tutorial, you should be familiar with how React works. For a quick refresher on React, review the Main Concepts guide or the Intro to React tutorial.

Contents

Objectives

By the end of this tutorial, you will be able to:

  1. Develop a RESTful API with Python and FastAPI
  2. Scaffold a React project with Create React App
  3. Manage state operations with the React Context API and Hooks
  4. Create and render React components in the browser
  5. Connect a React application to a FastAPI backend

What is FastAPI?

FastAPI is a Python web framework designed for building fast and efficient backend APIs. It handles both synchronous and asynchronous operations and has built-in support for data validation, authentication, and interactive API documentation powered by OpenAPI.

For more on FastAPI, review the following resources:

  1. Official Docs
  2. FastAPI Tutorials

What is React?

React is an open-source, component-based JavaScript UI library that's used for building frontend applications.

For more, review the Getting Started guide from the official docs.

Setting up FastAPI

Start by creating a new folder to hold your project called "fastapi-react":

$ mkdir fastapi-react
$ cd fastapi-react

In the "fastapi-react" folder, create a new folder to house the backend:

$ mkdir backend
$ cd backend

Next, create and activate a virtual environment:

$ python3.10 -m venv venv
$ source venv/bin/activate
$ export PYTHONPATH=$PWD

Feel free to swap out venv and Pip for Poetry or Pipenv. For more, review Modern Python Environments.

Install FastAPI:

(venv)$ pip install fastapi==0.78.0 uvicorn==0.17.6

Uvicorn is an ASGI (Asynchronous Server Gateway Interface) compatible server that will be used for standing up the backend API.

Next, create the following files and folders in the "backend" folder:

└── backend
    ├── main.py
    └── app
        ├── __init__.py
        └── api.py

In the main.py file, define an entry point for running the application:

import uvicorn


if __name__ == "__main__":
    uvicorn.run("app.api:app", host="0.0.0.0", port=8000, reload=True)

Here, we instructed the file to run a Uvicorn server on port 8000 and reload on every file change.

Before starting the server via the entry point file, create a base route in backend/app/api.py:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware


app = FastAPI()

origins = [
    "http://localhost:3000",
    "localhost:3000"
]


app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"]
)


@app.get("/", tags=["root"])
async def read_root() -> dict:
    return {"message": "Welcome to your todo list."}

Why do we need CORSMiddleware? In order to make cross-origin requests -- i.e., requests that originate from a different protocol, IP address, domain name, or port -- you need to enable Cross Origin Resource Sharing (CORS). FastAPI's built-in CORSMiddleware handles this for us.

The above configuration will allow cross-origin requests from our frontend domain and port which will run at localhost:3000.

For more on the handling of CORS in FastAPI, review the official docs.

Run the entry point file from your console:

(venv)$ python main.py

Navigate to http://localhost:8000 in your browser. You should see:

{
  "message": "Welcome to your todo list."
}

Setting up React

Again, we'll be using the Create React App CLI tool to scaffold a new React application via npx.

Within a new terminal window, navigate to the project directory and then generate a new React application:

$ npx [email protected] frontend
$ cd frontend

If this is your first time scaffolding a React application using the Create React App tool, check out the documentation.

To simplify things, remove all files in the "src" folder except the index.js file. index.js is our base component.

Next, install a UI component library called Chakra UI:

$ npm install @chakra-ui/[email protected]
$ npm install @emotion/[email protected] @emotion/[email protected] [email protected]

After the installation, create a new folder called "components" in the "src" folder, which will be used to hold the application's components, along with two components, Header.jsx and Todos.jsx:

$ cd src
$ mkdir components
$ cd components
$ touch {Header,Todos}.jsx

We'll start with the Header component in the Header.jsx file:

import React from "react";
import { Heading, Flex, Divider } from "@chakra-ui/react";

const Header = () => {
  return (
    <Flex
      as="nav"
      align="center"
      justify="space-between"
      wrap="wrap"
      padding="0.5rem"
      bg="gray.400"
    >
      <Flex align="center" mr={5}>
        <Heading as="h1" size="sm">Todos</Heading>
        <Divider />
      </Flex>
    </Flex>
  );
};

export default Header;

After importing React and the Heading, Flex, and Divider components from Chakra UI, we defined a component to render a basic header. The component is then exported for use in the base component.

Next, let's rewrite the base component in index.js. Replace the previous code with:

import React from "react";
import { render } from 'react-dom';
import { ChakraProvider } from "@chakra-ui/react";

import Header from "./components/Header";

function App() {
  return (
    <ChakraProvider>
      <Header />
    </ChakraProvider>
  )
}

const rootElement = document.getElementById("root")
render(<App />, rootElement)

ChakraProvider, imported from the Chakra UI library, serves as the parent component for other components using Chakra UI. It provides a theme to all child components (Header in this case) via React's Context API.

Start your React app from the terminal:

$ npm run start

This will open the React app in your default browser at http://localhost:3000. You should see:

Todo App

What Are We Building?

For the remainder of this tutorial, you'll be building a todo CRUD app for creating, reading, updating, and deleting todos. By the end, your app will look like this:

Final Todo App

GET Route

Backend

Start by adding a list of todos to backend/app/api.py:

todos = [
    {
        "id": "1",
        "item": "Read a book."
    },
    {
        "id": "2",
        "item": "Cycle around town."
    }
]

The list above is just dummy data used for this tutorial. The data simply represents the structure of individual todos. Feel free to wire up a database and store the todos there.

Then, add the route handler:

@app.get("/todo", tags=["todos"])
async def get_todos() -> dict:
    return { "data": todos }

Manually test the new route at http://localhost:8000/todo. Check out the interactive documentation at http://localhost:8000/docs as well:

App Docs

Frontend

In the Todos.jsx component, start by importing React, the useState() and useEffect() hooks, and some Chakra UI components:

import React, { useEffect, useState } from "react";
import {
    Box,
    Button,
    Flex,
    Input,
    InputGroup,
    Modal,
    ModalBody,
    ModalCloseButton,
    ModalContent,
    ModalFooter,
    ModalHeader,
    ModalOverlay,
    Stack,
    Text,
    useDisclosure
} from "@chakra-ui/react";

The useState hook is responsible for managing our application's local state while the useEffect hook allows us to perform operations such as data fetching.

For more on React Hooks, review the Primer on React Hooks tutorial and Introducing Hooks from the official docs.

Next, create a context for managing global state activities across all components:

const TodosContext = React.createContext({
  todos: [], fetchTodos: () => {}
})

In the code block above, we defined a context object via createContext that takes in two provider values: todos and fetchTodos. The fetchTodos function will be defined in the next code block.

Want to learn more about managing state with the React Context API? Check out the React Context API: Managing State with Ease article.

Next, add the Todos component:

export default function Todos() {
  const [todos, setTodos] = useState([])
  const fetchTodos = async () => {
    const response = await fetch("http://localhost:8000/todo")
    const todos = await response.json()
    setTodos(todos.data)
  }
}

Here, we created an empty state variable array, todos, and a state method, setTodos, so we can update the state variable. Next, we defined a function called fetchTodos to retrieve todos from the backend asynchronously and update the todo state variable at the end of the function.

Next, within the Todos component, retrieve the todos using the fetchTodos function and render the data by iterating through the todos state variable:

useEffect(() => {
  fetchTodos()
}, [])

return (
  <TodosContext.Provider value={{todos, fetchTodos}}>
    <Stack spacing={5}>
      {todos.map((todo) => (
        <b>{todo.item}</b>
      ))}
    </Stack>
  </TodosContext.Provider>
)

Todos.jsx should now look like:

import React, { useEffect, useState } from "react";
import {
    Box,
    Button,
    Flex,
    Input,
    InputGroup,
    Modal,
    ModalBody,
    ModalCloseButton,
    ModalContent,
    ModalFooter,
    ModalHeader,
    ModalOverlay,
    Stack,
    Text,
    useDisclosure
} from "@chakra-ui/react";

const TodosContext = React.createContext({
  todos: [], fetchTodos: () => {}
})

export default function Todos() {
  const [todos, setTodos] = useState([])
  const fetchTodos = async () => {
    const response = await fetch("http://localhost:8000/todo")
    const todos = await response.json()
    setTodos(todos.data)
  }
  useEffect(() => {
    fetchTodos()
  }, [])
  return (
    <TodosContext.Provider value={{todos, fetchTodos}}>
      <Stack spacing={5}>
        {todos.map((todo) => (
          <b>{todo.item}</b>
        ))}
      </Stack>
    </TodosContext.Provider>
  )
}

Import the Todos component in index.js file and render it:

import React from "react";
import { render } from 'react-dom';
import { ChakraProvider } from "@chakra-ui/react";

import Header from "./components/Header";
import Todos from "./components/Todos";  // new

function App() {
  return (
    <ChakraProvider>
      <Header />
      <Todos />  {/* new */}
    </ChakraProvider>
  )
}

const rootElement = document.getElementById("root")
render(<App />, rootElement)

Your app at http://localhost:3000 should now look like this:

Todo App

Try adding a new todo to the todos list in backend/app/api.py. Refresh the browser. You should see the new todo. With that, we're done wth the GET request for retrieving all todos.

POST Route

Backend

Start by adding a new route handler to handle POST requests for adding a new todo to backend/app/api.py:

@app.post("/todo", tags=["todos"])
async def add_todo(todo: dict) -> dict:
    todos.append(todo)
    return {
        "data": { "Todo added." }
    }

With the backend running, you can test the POST route in a new terminal tab using curl:

$ curl -X POST http://localhost:8000/todo -d \
    '{"id": "3", "item": "Buy some testdriven courses."}' \
    -H 'Content-Type: application/json'

You should see:

{
    "data: [
        "Todo added."
    ]"
}

You should also see the new todo in the response from the http://localhost:8000/todo endpoint as well as at http://localhost:3000.

As an exercise, implement a check to prevent adding duplicate todo items.

Frontend

Start by adding the shell for adding a new todo to frontend/src/components/Todos.jsx:

function AddTodo() {
  const [item, setItem] = React.useState("")
  const {todos, fetchTodos} = React.useContext(TodosContext)
}

Here, we created a new state variable that will hold the value from the form. We also retrieved the context values, todos and fetchTodos.

Next, add the functions for obtaining the input from the form and handling the form submission to AddTodo:

const handleInput = event  => {
  setItem(event.target.value)
}

const handleSubmit = (event) => {
  const newTodo = {
    "id": todos.length + 1,
    "item": item
  }

  fetch("http://localhost:8000/todo", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(newTodo)
  }).then(fetchTodos)
}

In the handleSubmit function, we added a POST request and sent data to the server with the todo info. We then called fetchTodos to update todos.

Just after the handleSubmit function, return the form to be rendered:

return (
  <form onSubmit={handleSubmit}>
    <InputGroup size="md">
      <Input
        pr="4.5rem"
        type="text"
        placeholder="Add a todo item"
        aria-label="Add a todo item"
        onChange={handleInput}
      />
    </InputGroup>
  </form>
)

In the code block above, we set the form onSubmit event listener to the handleSubmit function that we created earlier. The todo item value is also updated as the input value changes via the onChange listener.

The full AddTodo component should now look like:

function AddTodo() {
  const [item, setItem] = React.useState("")
  const {todos, fetchTodos} = React.useContext(TodosContext)

  const handleInput = event  => {
    setItem(event.target.value)
  }

  const handleSubmit = (event) => {
    const newTodo = {
      "id": todos.length + 1,
      "item": item
    }

    fetch("http://localhost:8000/todo", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(newTodo)
    }).then(fetchTodos)
  }

  return (
    <form onSubmit={handleSubmit}>
      <InputGroup size="md">
        <Input
          pr="4.5rem"
          type="text"
          placeholder="Add a todo item"
          aria-label="Add a todo item"
          onChange={handleInput}
        />
      </InputGroup>
    </form>
  )
}

Next, add the AddTodo component to the Todos component like so:

export default function Todos() {
  const [todos, setTodos] = useState([])
  const fetchTodos = async () => {
    const response = await fetch("http://localhost:8000/todo")
    const todos = await response.json()
    setTodos(todos.data)
  }
  useEffect(() => {
    fetchTodos()
  }, [])
  return (
    <TodosContext.Provider value={{todos, fetchTodos}}>
      <AddTodo />  {/* new */}
      <Stack spacing={5}>
        {todos.map((todo) => (
          <b>{todo.item}</b>
        ))}
      </Stack>
    </TodosContext.Provider>
  )
}

The frontend application should look like this:

Todo App

Test the form by adding a todo:

Add new todo

PUT Route

Backend

Add an update route:

@app.put("/todo/{id}", tags=["todos"])
async def update_todo(id: int, body: dict) -> dict:
    for todo in todos:
        if int(todo["id"]) == id:
            todo["item"] = body["item"]
            return {
                "data": f"Todo with id {id} has been updated."
            }

    return {
        "data": f"Todo with id {id} not found."
    }

So, we checked for the todo with an ID matching the one supplied and then, if found, updated the todo's item with the value from the request body.

Frontend

Start by defining the component UpdateTodo in frontend/src/components/Todos.jsx and passing two prop values, item and id to it:

function UpdateTodo({item, id}) {
  const {isOpen, onOpen, onClose} = useDisclosure()
  const [todo, setTodo] = useState(item)
  const {fetchTodos} = React.useContext(TodosContext)
}

The state variables above are for the modal, which we will create shortly, and to hold the todo value to be updated. The fetchTodos context value is also imported for updating todos after the changes have been made.

Now, let's write the function responsible for sending PUT requests. In the UpdateTodo component body, just after the state and context variables, add the following:

const updateTodo = async () => {
  await fetch(`http://localhost:8000/todo/${id}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ item: todo })
  })
  onClose()
  await fetchTodos()
}

In the asynchronous function above, a PUT request is sent to the backend and then the onClose() method is called to close the modal. fetchTodos() is then invoked.

Next, render the modal:

return (
  <>
    <Button h="1.5rem" size="sm" onClick={onOpen}>Update Todo</Button>
    <Modal isOpen={isOpen} onClose={onClose}>
      <ModalOverlay/>
      <ModalContent>
        <ModalHeader>Update Todo</ModalHeader>
        <ModalCloseButton/>
        <ModalBody>
          <InputGroup size="md">
            <Input
              pr="4.5rem"
              type="text"
              placeholder="Add a todo item"
              aria-label="Add a todo item"
              value={todo}
              onChange={event => setTodo(event.target.value)}
            />
          </InputGroup>
        </ModalBody>

        <ModalFooter>
          <Button h="1.5rem" size="sm" onClick={updateTodo}>Update Todo</Button>
        </ModalFooter>
      </ModalContent>
    </Modal>
  </>
)

In the above code, we created a modal using Chakra UI's Modal components. In the modal body, we listened for changes to the textbox and updated the state object, todo. Lastly, when the button "Update Todo" is clicked, the function updateTodo() is invoked and our todo is updated.

The full component should now look like:

function UpdateTodo({item, id}) {
  const {isOpen, onOpen, onClose} = useDisclosure()
  const [todo, setTodo] = useState(item)
  const {fetchTodos} = React.useContext(TodosContext)

  const updateTodo = async () => {
    await fetch(`http://localhost:8000/todo/${id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ item: todo })
    })
    onClose()
    await fetchTodos()
  }

  return (
    <>
      <Button h="1.5rem" size="sm" onClick={onOpen}>Update Todo</Button>
      <Modal isOpen={isOpen} onClose={onClose}>
        <ModalOverlay/>
        <ModalContent>
          <ModalHeader>Update Todo</ModalHeader>
          <ModalCloseButton/>
          <ModalBody>
            <InputGroup size="md">
              <Input
                pr="4.5rem"
                type="text"
                placeholder="Add a todo item"
                aria-label="Add a todo item"
                value={todo}
                onChange={e => setTodo(e.target.value)}
              />
            </InputGroup>
          </ModalBody>

          <ModalFooter>
            <Button h="1.5rem" size="sm" onClick={updateTodo}>Update Todo</Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </>
  )
}

Before adding the component to the Todos component, let's add a helper component for rendering todos to clean things up a bit:

function TodoHelper({item, id, fetchTodos}) {
  return (
    <Box p={1} shadow="sm">
      <Flex justify="space-between">
        <Text mt={4} as="div">
          {item}
          <Flex align="end">
            <UpdateTodo item={item} id={id} fetchTodos={fetchTodos}/>
          </Flex>
        </Text>
      </Flex>
    </Box>
  )
}

In the component above, we rendered the todo passed to the component and attached an update button to it.

Replace the code in the return block within the Todos component:

return (
  <TodosContext.Provider value={{todos, fetchTodos}}>
    <AddTodo />
    <Stack spacing={5}>
      {
        todos.map((todo) => (
          <TodoHelper item={todo.item} id={todo.id} fetchTodos={fetchTodos} />
        ))
      }
    </Stack>
  </TodosContext.Provider>
)

The browser should have a refreshed look:

Todo App

Verify that it works:

Update todo

DELETE Route

Backend

Finally, add the delete route:

@app.delete("/todo/{id}", tags=["todos"])
async def delete_todo(id: int) -> dict:
    for todo in todos:
        if int(todo["id"]) == id:
            todos.remove(todo)
            return {
                "data": f"Todo with id {id} has been removed."
            }

    return {
        "data": f"Todo with id {id} not found."
    }

Frontend

Let's write a component for deleting a todo, which will be used in the TodoHelper component:

function DeleteTodo({id}) {
  const {fetchTodos} = React.useContext(TodosContext)

  const deleteTodo = async () => {
    await fetch(`http://localhost:8000/todo/${id}`, {
      method: "DELETE",
      headers: { "Content-Type": "application/json" },
      body: { "id": id }
    })
    await fetchTodos()
  }

  return (
    <Button h="1.5rem" size="sm" onClick={deleteTodo}>Delete Todo</Button>
  )
}

Here, we started by invoking the fetchTodos function from the global state object. Next, we created an asynchronous function that sends a DELETE request to the server and then updates the list of todos by, again, calling fetchTodos. Lastly, we rendered a button that when clicked, triggers deleteTodo().

Next, add the DeleteTodo component to the TodoHelper:

function TodoHelper({item, id, fetchTodos}) {
  return (
    <Box p={1} shadow="sm">
      <Flex justify="space-between">
        <Text mt={4} as="div">
          {item}
          <Flex align="end">
            <UpdateTodo item={item} id={id} fetchTodos={fetchTodos}/>
            <DeleteTodo id={id} fetchTodos={fetchTodos}/>  {/* new */}
          </Flex>
        </Text>
      </Flex>
    </Box>
  )
}

The client application should be updated automatically:

Todo App

Now, test the delete button:

Remove todo

Conclusion

This tutorial covered the basics of setting up a CRUD application with FastAPI and React.

Check your understanding by reviewing the objectives from the beginning of this tutorial. You can find the source code in the fastapi-react repo. Thanks for reading.

Looking for some challenges?

  1. Deploy the React app to Netlify using this guide and update the CORS object in the backend so that it's dynamically configured using an environment variable.
  2. Deploy the backend API server to Heroku and replace the connection URL on the frontend. Again, use an environment variable for this. You can learn the basics of deploying FastAPI to Heroku from the Deploying and Hosting a Machine Learning Model with FastAPI and Heroku tutorial. For a beyond the basics look, check out the Test-Driven Development with FastAPI and Docker course.
  3. Set up unit and integration tests with pytest for the backend and React Testing Library for the frontend. The Test-Driven Development with FastAPI and Docker course covers how to test FastAPI with pytest while the Authentication with Flask, React, and Docker details how to test a React application with Jest and React Testing Library.
Featured Course

Test-Driven Development with FastAPI and Docker

In this course, you'll learn how to build, test, and deploy a text summarization service with Python, FastAPI, and Docker. The service itself will be exposed via a RESTful API and deployed to Heroku with Docker.

Featured Course

Test-Driven Development with FastAPI and Docker

In this course, you'll learn how to build, test, and deploy a text summarization service with Python, FastAPI, and Docker. The service itself will be exposed via a RESTful API and deployed to Heroku with Docker.