- Published on
Building a User Interface for Your To-Do Application


- Name
- Chad Wilson
- @NetPenguins
Series
Full Stack Todo App
- 01Todo API Creation with FastAPI
- 02Building a User Interface for Your To-Do Application
- 03Securing FastAPI with OAuth2 and JWT
- 04Build and Deploy the Todo App with Docker Compose
- 05Docker vs Podman: Which One for Your Homelab?
- 06Keycloak SSO with FastAPI
- 07Authentik SSO with FastAPI
Welcome back! In the previous post we built the FastAPI backend with SQLite and SQLAlchemy. Now it is time to build the frontend. We will use Vite and TypeScript, wire everything up to the API, and keep the component structure simple and readable.
Project Setup
Vite is the way to scaffold React projects now. Create React App is no longer maintained, so skip it.
npm create vite@latest todo-ui -- --template react-ts
cd todo-ui
npm install
Install axios for HTTP requests:
npm install axios
Note: We are skipping a UI library to keep the focus on structure and logic. A real project might use shadcn/ui or Tailwind, but that is its own rabbit hole.
After cleanup, the project structure should look like this:
todo-ui/
src/
components/
TodoForm/
TodoForm.tsx
TodoForm.css
TodoItem/
TodoItem.tsx
TodoItem.css
TodoList/
TodoList.tsx
TodoList.css
services/
ApiService.ts
shareContent.ts
types.ts
App.tsx
App.css
main.tsx
index.html
vite.config.ts
Types First
Define the shape of a Todo before touching any components. This gives TypeScript something to check against everywhere. Create src/types.ts:
export interface Todo {
id: string
title: string
description?: string
done: boolean
created_at: string
updated_at: string
}
export interface CreateTodoPayload {
title: string
description?: string
}
export interface UpdateTodoPayload {
title?: string
description?: string
done?: boolean
}
The id field is a string because our backend uses UUIDs, not integers.
API Service
All HTTP calls live in src/services/ApiService.ts. Keeping fetch logic out of components means you can change the base URL or add auth headers in one place later.
import axios from 'axios'
import { CreateTodoPayload, Todo, UpdateTodoPayload } from '../types'
const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
const client = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
export const getAllTodos = async (): Promise<Todo[]> => {
const response = await client.get<Todo[]>('/todos/')
return response.data
}
export const createTodo = async (payload: CreateTodoPayload): Promise<Todo> => {
const response = await client.post<Todo>('/todos/', payload)
return response.data
}
export const updateTodo = async (id: string, payload: UpdateTodoPayload): Promise<Todo> => {
const response = await client.put<Todo>(`/todos/${id}`, payload)
return response.data
}
export const deleteTodo = async (id: string): Promise<void> => {
await client.delete(`/todos/${id}`)
}
import.meta.env.VITE_API_URL is how Vite exposes environment variables to the browser. Any variable prefixed with VITE_ in your .env file will be available at runtime. Create .env at the root of todo-ui/:
VITE_API_URL=http://localhost:8000
For production, swap this for your deployed API URL. Never hardcode it.
There is no try/catch wrapping each function here. The components handle errors at the call site. Wrapping and re-throwing in the service layer just adds noise.
Web Share Service
Create src/services/shareContent.ts. The Web Share API triggers the native OS share sheet. It is the same one you see when you tap share in a mobile browser.
export const shareContent = async (title: string, text: string, url: string): Promise<void> => {
if (!navigator.share) {
console.warn('Web Share API not supported in this browser')
return
}
try {
await navigator.share({ title, text, url })
} catch (error) {
console.error('Error sharing:', error)
}
}
Not all browsers support it, so we check for navigator.share before calling it. Safari and Chrome on mobile have good support. Desktop Firefox does not.
The TodoForm Component
src/components/TodoForm/TodoForm.tsx collects input and calls the API to create a new todo. When the call succeeds, it passes the created todo back to the parent via the onAddTodo callback.
import React, { useState } from 'react'
import { createTodo } from '../../services/ApiService'
import { Todo } from '../../types'
import './TodoForm.css'
interface TodoFormProps {
onAddTodo: (todo: Todo) => void
}
const TodoForm: React.FC<TodoFormProps> = ({ onAddTodo }) => {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
setError(null)
if (!title.trim()) {
setError('Title is required')
return
}
try {
const created = await createTodo({ title, description: description || undefined })
onAddTodo(created)
setTitle('')
setDescription('')
} catch {
setError('Failed to create todo. Is the API running?')
}
}
return (
<section className="todo-form-container">
<form className="todo-form" onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs doing?"
/>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<input
id="description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional details"
/>
</div>
{error && <p className="form-error">{error}</p>}
<button type="submit">Add Todo</button>
</form>
</section>
)
}
export default TodoForm
A few things worth noting:
- We pass
description: description || undefinedso the field is omitted from the request body when empty, matching theOptional[str]backend schema. - Client-side validation is just a guard against accidental empty submits. Real validation lives on the API.
- The error state surfaces API failures instead of silently swallowing them.
The TodoItem Component
src/components/TodoItem/TodoItem.tsx renders a single todo and handles toggle and delete interactions.
import React, { useState } from 'react'
import { Todo } from '../../types'
import { shareContent } from '../../services/shareContent'
import './TodoItem.css'
interface TodoItemProps {
todo: Todo
onDeleteTodo: (id: string) => void
onToggleDone: (todo: Todo) => void
}
const TodoItem: React.FC<TodoItemProps> = ({ todo, onDeleteTodo, onToggleDone }) => {
const [expanded, setExpanded] = useState(false)
const handleShare = () => {
shareContent(
'Check out this todo',
`${todo.title}${todo.description ? ': ' + todo.description : ''}`,
window.location.href
)
}
return (
<li className={`todo-item${todo.done ? ' todo-item--done' : ''}`}>
<div className="todo-item-header">
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggleDone(todo)}
aria-label={`Mark "${todo.title}" as ${todo.done ? 'incomplete' : 'complete'}`}
/>
<span className="todo-title">{todo.title}</span>
<div className="todo-actions">
{todo.description && (
<button
className="todo-expand-button"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
>
{expanded ? 'Less' : 'More'}
</button>
)}
<button className="todo-share-button" onClick={handleShare} aria-label="Share">
Share
</button>
<button
className="todo-delete-button"
onClick={() => onDeleteTodo(todo.id)}
aria-label="Delete"
>
Delete
</button>
</div>
</div>
{expanded && todo.description && (
<p className="todo-description">{todo.description}</p>
)}
<span className="todo-timestamp">
Created {new Date(todo.created_at).toLocaleString()}
</span>
</li>
)
}
export default TodoItem
onDeleteTodo takes just the id string rather than the whole Todo object. Components should ask for only what they need.
The TodoList Component
src/components/TodoList/TodoList.tsx takes the array of todos and renders them. It also handles a grid/list view toggle.
import React, { useState } from 'react'
import { Todo } from '../../types'
import TodoItem from '../TodoItem/TodoItem'
import './TodoList.css'
interface TodoListProps {
todos: Todo[]
onDeleteTodo: (id: string) => void
onToggleDone: (todo: Todo) => void
}
const TodoList: React.FC<TodoListProps> = ({ todos, onDeleteTodo, onToggleDone }) => {
const [isGridView, setIsGridView] = useState(false)
if (todos.length === 0) {
return <p className="todo-empty">Nothing here yet. Add a todo above to get started.</p>
}
return (
<div className="todo-list-container">
<div className="view-toggle">
<button
onClick={() => setIsGridView(false)}
className={!isGridView ? 'active' : ''}
aria-pressed={!isGridView}
>
List
</button>
<button
onClick={() => setIsGridView(true)}
className={isGridView ? 'active' : ''}
aria-pressed={isGridView}
>
Grid
</button>
</div>
{isGridView ? (
<div className="todo-grid">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onDeleteTodo={onDeleteTodo}
onToggleDone={onToggleDone}
/>
))}
</div>
) : (
<ul className="todo-list">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onDeleteTodo={onDeleteTodo}
onToggleDone={onToggleDone}
/>
))}
</ul>
)}
</div>
)
}
export default TodoList
Wiring It All Together in App.tsx
src/App.tsx owns the todos state and coordinates all mutations.
import React, { useEffect, useState } from 'react'
import TodoForm from './components/TodoForm/TodoForm'
import TodoList from './components/TodoList/TodoList'
import { getAllTodos, updateTodo, deleteTodo } from './services/ApiService'
import { Todo } from './types'
import './App.css'
const App: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getAllTodos()
.then(setTodos)
.catch(() => setError('Could not load todos. Make sure the API is running.'))
.finally(() => setLoading(false))
}, [])
const handleAddTodo = (todo: Todo) => {
setTodos((prev) => [todo, ...prev])
}
const handleToggleDone = async (todo: Todo) => {
try {
const updated = await updateTodo(todo.id, { done: !todo.done })
setTodos((prev) => prev.map((t) => (t.id === updated.id ? updated : t)))
} catch {
console.error('Failed to update todo')
}
}
const handleDeleteTodo = async (id: string) => {
try {
await deleteTodo(id)
setTodos((prev) => prev.filter((t) => t.id !== id))
} catch {
console.error('Failed to delete todo')
}
}
return (
<div className="app-container">
<h1>To-Done</h1>
<TodoForm onAddTodo={handleAddTodo} />
{loading && <p>Loading...</p>}
{error && <p className="app-error">{error}</p>}
{!loading && !error && (
<TodoList
todos={todos}
onDeleteTodo={handleDeleteTodo}
onToggleDone={handleToggleDone}
/>
)}
</div>
)
}
export default App
Notice that handleDeleteTodo and handleToggleDone wait for the API response before updating local state. This is the safe choice. The UI only reflects what the backend confirmed.
Running the App
Start the backend first:
cd todo-api
source venv/bin/activate
uvicorn main:app --reload
Then in a separate terminal, start the frontend:
cd todo-ui
npm run dev
Vite will print the local URL, usually http://localhost:5173. If you see a CORS error in the browser console, check that http://localhost:5173 is in the origins list in your FastAPI main.py.
Production Considerations
Environment variables. The VITE_API_URL in .env points to localhost. For production you need a .env.production file pointing at your real API domain. Vite handles the swap automatically when you run npm run build.
HTTPS. The Web Share API requires HTTPS in most browsers. It will silently do nothing on plain HTTP, which is why the shareContent function checks for navigator.share first.
Error boundaries. React's error boundaries are worth adding before any real deployment so unexpected component crashes show a friendly message instead of a blank screen.
No auth yet. Anyone who can reach the API can read and modify all todos. The next post covers FastAPI's OAuth2 support and JWT-based auth so you can protect routes with real user sessions.
Up Next
We have a working full-stack app! FastAPI is talking to SQLite on the backend, React is on the frontend. The obvious gap is auth. In the next post we will walk through FastAPI's OAuth2 + JWT flow: password hashing, token issuance, protected routes, and the tradeoffs you need to understand before shipping any of it.