0x74 0x68 0x65 0x72 0x65 0x20 0x61 0x72 0x65 0x20 0x31 0x30 0x20 0x74 0x79 0x70 0x65 0x73 0x20 0x6f 0x66 0x20 0x70 0x65 0x6f 0x70 0x6c 0x65 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x75 0x6e 0x64 0x65 0x72 0x73 0x74 0x61 0x6e 0x64 0x20 0x62 0x69 0x6e 0x61 0x72 0x79 0x20 0x61 0x6e 0x64 0x20 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x64 0x6f 0x6e 0x74
01110100 01101000 01100101 01110010 01100101 00100000 01100001 01110010 01100101 00100000 00110001 00110000 00100000 01110100 01111001 01110000 01100101 01110011 0xdeadbeef 0xcafebabe 0x1337
0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x75 0x6e 0x64 0x65 0x72 0x73 0x74 0x61 0x6e 0x64 0x20 0x62 0x69 0x6e 0x61 0x72 0x79 0x20 0x61 0x6e 0x64 0x20 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x64 0x6f 0x6e 0x74 0x74 0x68 0x65 0x72 0x65 0x20 0x61 0x72 0x65 0x20 0x31 0x30 0x20 0x74 0x79 0x70 0x65 0x73 0x20 0x6f 0x66 0x20 0x70 0x65 0x6f 0x70 0x6c 0x65
01010111 01100101 00100000 01101000 01100001 01100011 01101011 00100000 01110100 01101000 01100101 00100000 01110000 01101100 01100001 01101110 01100101 01110100 01100110 00110000 01111000 01100110 00110100 01100100 01100101
0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x64 0x72 0x6f 0x70 0x20 0x79 0x6f 0x75 0x72 0x20 0x73 0x68 0x65 0x6c 0x6c 0x20 0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x6b 0x69 0x6c 0x6c 0x20 0x79 0x6f 0x75 0x72 0x20 0x74 0x68 0x72 0x65 0x61 0x64 0x20 0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x6c 0x6f 0x73 0x65 0x20 0x79 0x6f 0x75 0x72 0x20 0x70 0x61 0x63 0x6b 0x65 0x74 0x20 0x61 0x6e 0x64 0x20 0x64 0x65 0x73 0x65 0x72 0x74 0x20 0x79 0x6f 0x75 0x72 0x20 0x71 0x75 0x65 0x75 0x65
Published on

Building a User Interface for Your To-Do Application

Building a User Interface for Your To-Do Application

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 || undefined so the field is omitted from the request body when empty, matching the Optional[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.