In a containerized app, React and Chakra UI provide a robust and accessible user interface, while Rust delivers performance and safety on the backend.
A Dockerized todo app is a to-do list application packaged and run within a Docker container. Using Docker to create a container for the app makes running, scaling, and deploying easier.
By using Docker, you can ensure consistent behavior across different environments, from development to production. In this guide, we will walk you through how to build a Dockerized todo app using React for the front end, Chakra UI for styling, and Rust for the backend development using Docker and a Docker Compose file.
What each technology is used for:
- Frontend: React + Chakra UI: React provides a powerful and efficient frontend framework, while Chakra UI offers a set of accessible and customizable components for rapid UI development.
- Backend: Rust is highly regarded for its performance and safety features, serving as a robust backend language.
- Deployment: Docker and Docker Compose: Dockerizing this stack ensures easy deployment and scalability, allows for efficient resource utilization, and simplifies the development process by providing a consistent environment for all team members.
- Database: MongoDB provides a flexible, document-based data model that’s ideal for storing and retrieving to-do items.
Step 1: Setting Up Your Environment: Install Docker, Docker Compose, Rust and Node
Ensure you have Docker, Node and Rust installed in your development environment. If you have the software installed, you can skip this step and move to Step 2.
If not, run the following commands in your terminal to install Docker, Node and Rust.
To install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
To install Docker
Follow this link: https://docs.docker.com/engine/install/
More information can be found on the following Rust, Node.js and Docker websites.
Step 2a: Create React App
Create your React project by using this command:
create-react-app
npx create-react-app frontend
cd frontend
Step 2b: Install Chakra UI
Use the command below to install Chakra UI on your React project.
npm install @chakra-ui/react @emotion/react @emotion/styled framer-motion axios
Step 3: Build the Todo List UI
Modify src/App.Js to include the todo app functionality. Here is a simple sample script below:
import React, { useState, useEffect } from 'react';
import { ChakraProvider, Box, Heading, Input, Button, VStack, HStack, Text, Checkbox, Select } from '@chakra-ui/react';
import axios from 'axios';
const backendUrl = "http://localhost:8000"; // Adjust based on Rust backend URL
function App() {
const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState('');
const [filter, setFilter] = useState('all');
const [editIndex, setEditIndex] = useState(null);
const [editTodo, setEditTodo] = useState('');
useEffect(() => {
fetchTodos();
}, []);
const fetchTodos = async () => {
try {
const response = await axios.get(`${backendUrl}/list`);
setTodos(response.data);
} catch (error) {
console.error('Error fetching todos:', error);
}
};
const addTodo = async () => {
if (newTodo.trim()) {
try {
const todo = { task: newTodo, is_complete: false };
await axios.post(`${backendUrl}/add`, todo);
setTodos([...todos, todo]);
setNewTodo('');
} catch (error) {
console.error('Error adding todo:', error);
}
}
};
const toggleComplete = async (index) => {
const newTodos = [...todos];
newTodos[index].is_complete = !newTodos[index].is_complete;
setTodos(newTodos);
};
const deleteTodo = async (index) => {
setTodos(todos.filter((_, i) => i !== index));
};
const handleEdit = (index) => {
setEditIndex(index);
setEditTodo(todos[index].task);
};
const saveEdit = async (index) => {
const newTodos = [...todos];
newTodos[index].task = editTodo;
setTodos(newTodos);
setEditIndex(null);
setEditTodo('');
};
const filterTodos = () => {
if (filter === 'completed') return todos.filter(todo => todo.is_complete);
if (filter === 'active') return todos.filter(todo => !todo.is_complete);
return todos;
};
return (
<ChakraProvider>
<Box w="100%" p={5}>
<VStack spacing={5}>
<Heading mb={4}>Todo List</Heading>
<HStack>
<Input
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add new task"
/>
<Button colorScheme="teal" onClick={addTodo}>
Add Task
</Button>
</HStack>
<Select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="completed">Completed</option>
<option value="active">Active</option>
</Select>
{filterTodos().map((todo, index) => (
<HStack key={index} justify="space-between" w="100%" p={2} borderBottom="1px" borderColor="gray.200">
<Checkbox isChecked={todo.is_complete} onChange={() => toggleComplete(index)}>
{editIndex === index ? (
<Input value={editTodo} onChange={(e) => setEditTodo(e.target.value)} />
) : (
<Text as={todo.is_complete ? 'del' : ''}>{todo.task}</Text>
)}
</Checkbox>
{editIndex === index ? (
<Button size="sm" colorScheme="blue" onClick={() => saveEdit(index)}>Save</Button>
) : (
<Button size="sm" colorScheme="gray" onClick={() => handleEdit(index)}>Edit</Button>
)}
<Button size="sm" colorScheme="red" onClick={() => deleteTodo(index)}>Delete</Button>
</HStack>
))}
</VStack>
</Box>
</ChakraProvider>
);
}
export default App;
Explanation:
- State management: useState is used to manage the todos and new task input.
- Chakra UI components: VStack, HStack, Box, Button and Input are used to create the layout.
- Todo functions: addTodo, toggleComplete and deleteTodo handle the main functionality.
Step 4: Run the React App Without Using Docker
To start the development server, run the following command:
npm start
Step 5: Dockerizing the React App
Navigate to the frontend folder and create a new Dockerfile with the code below:
Stage 1: Build the React app
FROM node:18-alpine as build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
Stage 2: Serve the app using NGINX:
FROM nginx:alpine
COPY--from=build /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Step 6: Setting Up the Rust Backend
Next, you’ll need to use Warp, which is a modern, lightweight and fast web framework for building web applications and APIs written in Rust. Find more information on Warp here. To create a backend for your project using Rust, run the command below:
cargo new backend
cd backend
Create a file with the name Cargo.toml (if it does not exist) and copy and paste the code below in the new file Cargo.toml:
[package]
name = "todo_rust_react_chakra_ui_example"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
tokio = { version = "1.40.0", features = ["full"] } # Tokio runtime for async
warp = "0.3.7" # Warp web framework
mongodb = { version = "2.8.1", features = ["tokio-runtime"] } # MongoDB driver for async with tokio
serde = { version = "1.0.210", features = ["derive"] } # Serde for serialization/deserialization
serde_json = "1.0.129" # Serde JSON support
futures = "0.3.31" # Futures for async operations
futures-util = "0.3"
# Define the binary target
[[bin]]
name = "todo_rust_react_chakra_ui_example"
path = "src/main.rs"
Step 7: Implement the Todo API Backend
Replace the contents of the src/main.rs with the code below, which defines your two functions and creates the routes add and list. The backend code connects to the MongoDB service running in the Docker Compose setup.
use warp::Filter;
use mongodb::{Client, options::ClientOptions, bson::{doc, oid::ObjectId}};
use serde::{Serialize, Deserialize};
use std::sync::Arc;
use warp::reject::{self, Reject};
use warp::http::StatusCode;
use futures_util::stream::StreamExt;
// Struct to hold error types for custom rejections
#[derive(Debug)]
struct DatabaseError;
impl Reject for DatabaseError {}
// Struct to represent a Todo item
#[derive(Debug, Serialize, Deserialize)]
struct Todo {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
id: Option<ObjectId>,
task: String,
is_complete: bool,
}
// Struct to hold the MongoDB client
struct MongoRepo {
client: mongodb::Client,
}
// Health check route handler
async fn health_check() -> Result<impl warp::Reply, warp::Rejection> {
Ok(warp::reply::with_status("Server is healthy", StatusCode::OK))
}
// Add a new Todo to the database
async fn add(todo: Todo, db: Arc<MongoRepo>) -> Result<impl warp::Reply, warp::Rejection> {
let collection = db.client.database("todo_db").collection::<Todo>("todos");
let new_todo = Todo {
id: None,
task: todo.task,
is_complete: todo.is_complete,
};
match collection.insert_one(new_todo, None).await {
Ok(insert_result) => Ok(warp::reply::with_status(
warp::reply::json(&insert_result),
StatusCode::CREATED,
)),
Err(_) => Err(reject::custom(DatabaseError)), // Custom rejection for db errors
}
}
// List all Todos in the database
async fn list(db: Arc<MongoRepo>) -> Result<impl warp::Reply, warp::Rejection> {
let collection = db.client.database("todo_db").collection::<Todo>("todos");
let mut todos = vec![];
let mut cursor = match collection.find(None, None).await {
Ok(cursor) => cursor,
Err(_) => return Err(reject::custom(DatabaseError)), // Handle db find error
};
while let Some(todo) = cursor.next().await {
if let Ok(todo) = todo {
todos.push(todo);
}
}
Ok(warp::reply::json(&todos))
}
// Function to handle rejections (including DatabaseError)
async fn handle_rejection(err: warp::Rejection) -> Result<impl warp::Reply, warp::Rejection> {
if let Some(_) = err.find::<DatabaseError>() {
Ok(warp::reply::with_status(
"Internal Server Error".to_string(),
StatusCode::INTERNAL_SERVER_ERROR,
))
} else {
// If the error is not a DatabaseError, return a generic 404 error
Ok(warp::reply::with_status(
"Not Found".to_string(),
StatusCode::NOT_FOUND,
))
}
}
#[tokio::main]
async fn main() {
// Set up MongoDB client
let client_options = ClientOptions::parse("mongodb://mongodb:27017").await.unwrap();
let client = Client::with_options(client_options).unwrap();
let db = Arc::new(MongoRepo { client });
// Define filters for routing
let db_filter = warp::any().map(move || db.clone());
// Health check route
let health_route = warp::path("health")
.and(warp::get())
.and_then(health_check);
// Add new Todo route
let add_route = warp::path("add")
.and(warp::post())
.and(warp::body::json())
.and(db_filter.clone())
.and_then(add);
// List all Todos route
let list_route = warp::path("list")
.and(warp::get())
.and(db_filter.clone())
.and_then(list);
// Combine all routes and set up error handling
let routes = health_route
.or(add_route)
.or(list_route)
.recover(handle_rejection);
// Set up CORS
let cors = warp::cors()
.allow_any_origin() // Allow requests from any origin
.allow_methods(vec!["GET", "POST"]) // Allow GET and POST methods
.allow_headers(vec!["content-type"]); // Allow the Content-Type header
// Start the Warp server with CORS enabled
warp::serve(routes.with(cors))
.run(([0, 0, 0, 0], 8000))
.await;
}
Step 8: Create the Dockerfile for the Backend
Navigate to the backend folder and create a new Dockerfile with the code below:
FROM rust:alpine3.20
# Install necessary build dependencies
RUN apk add --no-cache musl-dev gcc openssl-dev
WORKDIR /app
# Copy the Cargo.toml and Cargo.lock files to build the dependency cache
COPY Cargo.toml ./
COPY Cargo.lock ./
# Now copy the actual source code
COPY ./src ./src
# Build the Rust application in release mode
RUN cargo build --release
# Ensure the binary is executable
RUN chmod +x ./target/release/todo_rust_react_chakra_ui_example
# Set environment variables for Warp (if needed)
ENV APP_HOST=0.0.0.0
ENV APP_PORT=8000
# Expose the Warp server port
EXPOSE 8000
# Start the application (assuming the binary is in `./target/release`)
CMD ["./target/release/todo_rust_react_chakra_ui_example"]
Step 9: Create the Dockerfile for the Frontend, Backend and Database
You will configure Docker Compose to spin up the frontend (React), backend (Rust) and database (MongoDB).
Create a new file called docker-compose.yml in the root project, which will contain the definition of the services.
Copy and paste the code snippet below in the newly created file docker-compose.yml:
version: '3.8'
services:
frontend:
build:
context: ./frontend
ports:
- "3000:80"
depends_on:
- backend
backend:
build:
context: ./backend
command: ["cargo", "run"]
ports:
- "8000:8000"
depends_on:
- mongodb
environment:
- RUST_LOG=debug
- MONGODB_URI=mongodb://mongodb:27017
mongodb:
image: mongo:8.0.1
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
volumes:
mongo-data:
Step 10: Running the App with Docker Compose
You can now run the entire application stack using Docker Compose. Run the command below:
docker-compose up --build
The command above will:
- Build and run the Rust backend API on localhost:8000
- Build the React front-end server on localhost:3000
- Set up MongoDB on localhost:27017
Conclusion
Using React, Chakra UI and Rust together creates an efficient, manageable application architecture.
Docker plays a significant role in containerizing your application, allowing for efficient deployment across different environments. By choosing React and Chakra UI for your front end, you’ll benefit from a robust and accessible user interface, while Rust can deliver strong performance and safety on the back end.
To see the complete code and learn more, don’t forget to check out the project on GitHub.
This article was first published on https://thenewstack.io/building-a-dockerized-todo-app-with-react-chakra-ui-and-rust/