Skip to main content

Creating Your First Feature

This guide walks you through creating a complete frontend feature from scratch: a task management interface that connects to the backend APIs. You'll learn how to create components, manage state, handle API calls and build a fully interactive user interface.

What We'll Build

A complete task management interface with:

  • Task list displaying all user tasks
  • Form to create new tasks
  • Toggle task completion
  • Real-time updates
  • Type-safe API integration
  • State management with Zustand

Prerequisites

  • Starter app is set up and running
  • Frontend development environment is configured
  • Backend task API is implemented - Complete Backend: Your First Feature first
  • Basic understanding of React and TypeScript

Step 1: Generate API Types

First, ensure your backend is running and generate the latest API types:

# From the project root
cd front
npm run codegen

This creates TypeScript types from your backend OpenAPI schema, including the new task endpoints.

Step 2: Create the Task Store

Create a Zustand store for task state management in front/src/stores/taskStore.ts:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { client } from '@/types/openapi';
import type { Api } from '@/types/openapi';

type Task = Api['TaskDTO'];
type CreateTask = Api['CreateTaskDTO'];
type UpdateTask = Api['UpdateTaskDTO'];

interface TaskState {
// State
tasks: Task[];
loading: boolean;
error: string | null;

// Actions
fetchTasks: () => Promise<void>;
createTask: (taskData: CreateTask) => Promise<void>;
toggleTask: (taskId: string) => Promise<void>;
deleteTask: (taskId: string) => Promise<void>;
updateTask: (taskId: string, taskData: Partial<UpdateTask>) => Promise<void>;
clearError: () => void;
}

export const useTaskStore = create<TaskState>()(
persist(
(set, get) => ({
// Initial state
tasks: [],
loading: false,
error: null,

// Fetch all user tasks
fetchTasks: async () => {
set({ loading: true, error: null });
try {
const { data, error } = await client.GET('/tasks/my-tasks');

if (error) {
set({ error: 'Failed to fetch tasks', loading: false });
return;
}

set({ tasks: data || [], loading: false });
} catch (err) {
set({ error: 'Network error', loading: false });
}
},

// Create a new task
createTask: async (taskData: CreateTask) => {
set({ loading: true, error: null });
try {
const { data, error } = await client.POST('/tasks/my-tasks', {
body: taskData
});

if (error) {
set({ error: 'Failed to create task', loading: false });
return;
}

const currentTasks = get().tasks;
set({
tasks: [...currentTasks, data!],
loading: false
});
} catch (err) {
set({ error: 'Failed to create task', loading: false });
}
},

// Toggle task completion
toggleTask: async (taskId: string) => {
try {
const { data, error } = await client.PATCH('/tasks/{task_id}/toggle', {
params: { path: { task_id: taskId } }
});

if (error) {
set({ error: 'Failed to toggle task' });
return;
}

const currentTasks = get().tasks;
const updatedTasks = currentTasks.map(task =>
task.id === taskId ? data! : task
);
set({ tasks: updatedTasks });
} catch (err) {
set({ error: 'Failed to toggle task' });
}
},

// Delete a task
deleteTask: async (taskId: string) => {
try {
const { error } = await client.DELETE('/tasks/{id}', {
params: { path: { id: taskId } }
});

if (error) {
set({ error: 'Failed to delete task' });
return;
}

const currentTasks = get().tasks;
const filteredTasks = currentTasks.filter(task => task.id !== taskId);
set({ tasks: filteredTasks });
} catch (err) {
set({ error: 'Failed to delete task' });
}
},

// Update a task
updateTask: async (taskId: string, taskData: Partial<UpdateTask>) => {
try {
const { data, error } = await client.PATCH('/tasks/{id}', {
params: { path: { id: taskId } },
body: taskData
});

if (error) {
set({ error: 'Failed to update task' });
return;
}

const currentTasks = get().tasks;
const updatedTasks = currentTasks.map(task =>
task.id === taskId ? data! : task
);
set({ tasks: updatedTasks });
} catch (err) {
set({ error: 'Failed to update task' });
}
},

clearError: () => set({ error: null })
}),
{
name: 'task-store',
// Only persist the tasks, not loading/error states
partialize: (state) => ({ tasks: state.tasks })
}
)
);

Step 3: Create the Task Item Component

Create front/src/components/features/tasks/TaskItem.tsx:

import { useState } from 'react';
import { Button, Text, ActionIcon, Modal, TextInput, Textarea } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Edit, Trash2, Check, Circle } from 'lucide-react';
import { useTaskStore } from '@/stores/taskStore';
import type { Api } from '@/types/openapi';

type Task = Api['TaskDTO'];

interface TaskItemProps {
task: Task;
}

export function TaskItem({ task }: TaskItemProps) {
const { toggleTask, deleteTask, updateTask } = useTaskStore();
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);

// Edit form state
const [editTitle, setEditTitle] = useState(task.title);
const [editDescription, setEditDescription] = useState(task.description || '');

const handleToggle = () => {
toggleTask(task.id);
};

const handleEdit = async () => {
await updateTask(task.id, {
title: editTitle,
description: editDescription || null
});
closeEdit();
};

const handleDelete = async () => {
await deleteTask(task.id);
closeDelete();
};

// Should be in a utils/date.ts
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};

return (
<>
<div className={`border rounded-lg p-4 transition-all duration-200 ${
task.completed
? 'bg-gray-50 border-gray-200'
: 'bg-white border-gray-300 hover:border-blue-300'
}`}>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
{/* Toggle button */}
<button
onClick={handleToggle}
className={`mt-1 flex-shrink-0 transition-colors ${
task.completed
? 'text-green-600 hover:text-green-700'
: 'text-gray-400 hover:text-blue-600'
}`}
>
{task.completed ? (
<Check className="w-5 h-5" />
) : (
<Circle className="w-5 h-5" />
)}
</button>

{/* Task content */}
<div className="flex-1 min-w-0">
<h3 className={`font-medium text-lg ${
task.completed ? 'line-through text-gray-500' : 'text-gray-900'
}`}>
{task.title}
</h3>

{task.description && (
<p className={`mt-1 text-sm ${
task.completed ? 'text-gray-400' : 'text-gray-600'
}`}>
{task.description}
</p>
)}

<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
<span>Created: {formatDate(task.created_at)}</span>
{task.due_date && (
<span className={`${
new Date(task.due_date) < new Date() && !task.completed
? 'text-red-500 font-medium'
: ''
}`}>
Due: {formatDate(task.due_date)}
</span>
)}
</div>
</div>
</div>

{/* Action buttons */}
<div className="flex items-center gap-1 ml-4">
<ActionIcon
variant="subtle"
color="blue"
onClick={openEdit}
disabled={task.completed}
>
<Edit className="w-4 h-4" />
</ActionIcon>

<ActionIcon
variant="subtle"
color="red"
onClick={openDelete}
>
<Trash2 className="w-4 h-4" />
</ActionIcon>
</div>
</div>
</div>

{/* Edit Modal */}
<Modal opened={editOpened} onClose={closeEdit} title="Edit Task">
<div className="space-y-4">
<TextInput
label="Title"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
required
/>

<Textarea
label="Description"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={3}
/>

<div className="flex justify-end gap-2">
<Button variant="outline" onClick={closeEdit}>
Cancel
</Button>
<Button onClick={handleEdit} disabled={!editTitle.trim()}>
Save Changes
</Button>
</div>
</div>
</Modal>

{/* Delete Confirmation Modal */}
<Modal opened={deleteOpened} onClose={closeDelete} title="Delete Task">
<div className="space-y-4">
<Text>
Are you sure you want to delete "{task.title}"? This action cannot be undone.
</Text>

<div className="flex justify-end gap-2">
<Button variant="outline" onClick={closeDelete}>
Cancel
</Button>
<Button color="red" onClick={handleDelete}>
Delete Task
</Button>
</div>
</div>
</Modal>
</>
);
}

Step 4: Create the Task Form Component

Create front/src/components/features/tasks/TaskForm.tsx:

import { useState } from 'react';
import { Button, TextInput, Textarea, Paper } from '@mantine/core';
import { DateTimePicker } from '@mantine/dates';
import { Plus } from 'lucide-react';
import { useTaskStore } from '@/stores/taskStore';

export function TaskForm() {
const { createTask, loading } = useTaskStore();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [dueDate, setDueDate] = useState<Date | null>(null);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

if (!title.trim()) return;

await createTask({
title: title.trim(),
description: description.trim() || null,
due_date: dueDate ? dueDate.toISOString() : null
});

// Reset form
setTitle('');
setDescription('');
setDueDate(null);
};

return (
<Paper p="md" className="border border-gray-200">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<Plus className="w-5 h-5 text-blue-600" />
<h2 className="text-lg font-semibold">Add New Task</h2>
</div>

<TextInput
label="Title"
placeholder="What needs to be done?"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
disabled={loading}
/>

<Textarea
label="Description"
placeholder="Add details about this task..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
disabled={loading}
/>

<DateTimePicker
label="Due Date (optional)"
placeholder="When should this be completed?"
value={dueDate}
onChange={setDueDate}
disabled={loading}
clearable
/>

<Button
type="submit"
disabled={!title.trim() || loading}
loading={loading}
fullWidth
>
{loading ? 'Creating Task...' : 'Create Task'}
</Button>
</form>
</Paper>
);
}

Step 5: Create the Task List Component

Create front/src/components/features/tasks/TaskList.tsx:

import { useEffect, useMemo } from 'react';
import { Text, Alert, LoadingOverlay, Tabs, Badge } from '@mantine/core';
import { AlertCircle } from 'lucide-react';
import { useTaskStore } from '@/stores/taskStore';
import { TaskItem } from './TaskItem';
import type { Api } from '@/types/openapi';

type Task = Api['TaskDTO'];

export function TaskList() {
const { tasks, loading, error, fetchTasks, clearError } = useTaskStore();

useEffect(() => {
fetchTasks();
}, [fetchTasks]);

const taskStats = useMemo(() => {
const completed = tasks.filter(task => task.completed).length;
const pending = tasks.length - completed;
const overdue = tasks.filter(task =>
!task.completed &&
task.due_date &&
new Date(task.due_date) < new Date()
).length;

return { completed, pending, overdue, total: tasks.length };
}, [tasks]);

const filteredTasks = useMemo(() => {
return {
all: tasks,
pending: tasks.filter(task => !task.completed),
completed: tasks.filter(task => task.completed),
overdue: tasks.filter(task =>
!task.completed &&
task.due_date &&
new Date(task.due_date) < new Date()
)
};
}, [tasks]);

if (error) {
return (
<Alert
color="red"
icon={<AlertCircle className="w-4 h-4" />}
onClose={clearError}
closable
>
{error}
</Alert>
);
}

return (
<div className="relative">
<LoadingOverlay visible={loading && tasks.length === 0} />

{/* Task Statistics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-blue-50 p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-blue-600">{taskStats.total}</div>
<div className="text-sm text-blue-700">Total Tasks</div>
</div>

<div className="bg-yellow-50 p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-yellow-600">{taskStats.pending}</div>
<div className="text-sm text-yellow-700">Pending</div>
</div>

<div className="bg-green-50 p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-green-600">{taskStats.completed}</div>
<div className="text-sm text-green-700">Completed</div>
</div>

<div className="bg-red-50 p-3 rounded-lg text-center">
<div className="text-2xl font-bold text-red-600">{taskStats.overdue}</div>
<div className="text-sm text-red-700">Overdue</div>
</div>
</div>

{/* Task Tabs */}
<Tabs defaultValue="all">
<Tabs.List>
<Tabs.Tab value="all">
All Tasks
<Badge size="sm" ml="xs">{filteredTasks.all.length}</Badge>
</Tabs.Tab>
<Tabs.Tab value="pending">
Pending
<Badge size="sm" ml="xs" color="yellow">{filteredTasks.pending.length}</Badge>
</Tabs.Tab>
<Tabs.Tab value="completed">
Completed
<Badge size="sm" ml="xs" color="green">{filteredTasks.completed.length}</Badge>
</Tabs.Tab>
{filteredTasks.overdue.length > 0 && (
<Tabs.Tab value="overdue">
Overdue
<Badge size="sm" ml="xs" color="red">{filteredTasks.overdue.length}</Badge>
</Tabs.Tab>
)}
</Tabs.List>

<Tabs.Panel value="all" pt="md">
<TaskGrid tasks={filteredTasks.all} />
</Tabs.Panel>

<Tabs.Panel value="pending" pt="md">
<TaskGrid tasks={filteredTasks.pending} />
</Tabs.Panel>

<Tabs.Panel value="completed" pt="md">
<TaskGrid tasks={filteredTasks.completed} />
</Tabs.Panel>

{filteredTasks.overdue.length > 0 && (
<Tabs.Panel value="overdue" pt="md">
<TaskGrid tasks={filteredTasks.overdue} />
</Tabs.Panel>
)}
</Tabs>
</div>
);
}

function TaskGrid({ tasks }: { tasks: Task[] }) {
if (tasks.length === 0) {
return (
<div className="text-center py-12">
<Text c="dimmed" size="lg">No tasks found</Text>
<Text c="dimmed" size="sm">Create your first task to get started!</Text>
</div>
);
}

return (
<div className="space-y-3">
{tasks.map(task => (
<TaskItem key={task.id} task={task} />
))}
</div>
);
}

Step 6: Create the Main Tasks Page

Create front/src/pages/Tasks.tsx:

import { Container, Title, Alert } from '@mantine/core';
import { CheckSquare, AlertTriangle } from 'lucide-react';
import PageLayout from '@/components/layout/PageLayout';
import { TaskForm } from '@/components/features/tasks/TaskForm';
import { TaskList } from '@/components/features/tasks/TaskList';
import { useTaskStore } from '@/stores/taskStore';

export default function Tasks() {
const { error } = useTaskStore();

return (
<PageLayout>
<Container size="lg" className="py-6">
<div className="flex items-center gap-3 mb-8">
<CheckSquare className="w-8 h-8 text-blue-600" />
<Title order={1} className="text-3xl font-bold">Task Manager</Title>
</div>

{error && (
<Alert
color="yellow"
icon={<AlertTriangle className="w-4 h-4" />}
mb="md"
>
{error}
</Alert>
)}

<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Task Form */}
<div className="lg:col-span-1">
<TaskForm />
</div>

{/* Task List */}
<div className="lg:col-span-2">
<TaskList />
</div>
</div>
</Container>
</PageLayout>
);
}

Step 7: Add the Route

Add the new route to front/src/routes.tsx:

import {
type RouteConfig,
route,
layout,
} from "@react-router/dev/routes";

export default [
layout("./components/layout/AppLayout.tsx", [
route("/abraxas", "pages/Abraxas.tsx"),
route("/chat", "pages/Chat.tsx"),
route("/ocr", "pages/OCR.tsx"),
route("/embedding", "pages/Embedding.tsx"),
route("/tasks", "pages/Tasks.tsx"), // Add this line, you can remove other routes

// Default: home page
route("*?", "pages/Home.tsx"),
]),
] satisfies RouteConfig;

Step 8: Test Your Component

  1. Start the entire app:

    # From the project root
    bash scripts/start_all.sh
  2. Navigate to http://localhost:3000/tasks in your browser

  3. Test the functionality:

    • Create a new task with title and description
    • Toggle task completion by clicking the circle/check icon
    • Edit a task by clicking the edit button
    • Delete a task by clicking the trash button
    • Filter tasks using the tabs
    • Set due dates and see overdue indicators

Add a link to your task manager in your navigation. In your navigation component:

import { CheckSquare } from 'lucide-react';

// Add this link to your navigation
<NavLink to="/tasks" className="flex items-center gap-2">
<CheckSquare className="w-4 h-4" />
Tasks
</NavLink>

What You've Learned

API Integration: Connected frontend to backend APIs with type safety
State Management: Used Zustand for complex state with persistence
Component Architecture: Built reusable, focused components
Form Handling: Created forms with validation and error handling
UI Components: Used Mantine components with custom styling
Real-time Updates: Implemented optimistic updates and error handling
TypeScript: Leveraged OpenAPI-generated types for safety

Your task management component is now a fully functional feature that demonstrates all the key concepts for building React feature in the starter app!