Projekt: React mit TypeScript
Baue eine React-Anwendung mit TypeScript - typisierte Components, Hooks und State Management.
Aktualisiert:
Projekt: React mit TypeScript
In diesem Projekt lernst du, wie du React-Anwendungen mit TypeScript entwickelst. Wir bauen eine Task-Management App mit typisierten Components, Custom Hooks und State Management.
Projekt Setup
npm create vite@latest task-app -- --template react-ts
cd task-app
npm install
Projekt Struktur
src/
├── components/
│ ├── TaskList.tsx
│ ├── TaskItem.tsx
│ ├── TaskForm.tsx
│ └── FilterBar.tsx
├── hooks/
│ ├── useTasks.ts
│ └── useLocalStorage.ts
├── types/
│ └── task.ts
├── App.tsx
└── main.tsx
Typ-Definitionen
src/types/task.ts
export type TaskPriority = "low" | "medium" | "high";
export type TaskStatus = "todo" | "in-progress" | "done";
export interface Task {
id: string;
title: string;
description?: string;
priority: TaskPriority;
status: TaskStatus;
dueDate?: Date;
createdAt: Date;
updatedAt: Date;
}
export type CreateTaskInput = Pick<Task, "title" | "description" | "priority" | "dueDate">;
export type UpdateTaskInput = Partial<CreateTaskInput> & { status?: TaskStatus };
export interface TaskFilters {
status?: TaskStatus | "all";
priority?: TaskPriority | "all";
search?: string;
}
Custom Hooks
src/hooks/useLocalStorage.ts
import { useState, useEffect } from "react";
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// State initialisieren
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Value in localStorage speichern
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
src/hooks/useTasks.ts
import { useState, useMemo, useCallback } from "react";
import { useLocalStorage } from "./useLocalStorage";
import { Task, CreateTaskInput, UpdateTaskInput, TaskFilters } from "../types/task";
function generateId(): string {
return Math.random().toString(36).substring(2, 15);
}
export function useTasks() {
const [tasks, setTasks] = useLocalStorage<Task[]>("tasks", []);
const [filters, setFilters] = useState<TaskFilters>({
status: "all",
priority: "all",
search: ""
});
// Gefilterte Tasks
const filteredTasks = useMemo(() => {
return tasks.filter(task => {
// Status Filter
if (filters.status && filters.status !== "all" && task.status !== filters.status) {
return false;
}
// Priority Filter
if (filters.priority && filters.priority !== "all" && task.priority !== filters.priority) {
return false;
}
// Search Filter
if (filters.search) {
const search = filters.search.toLowerCase();
const matchesTitle = task.title.toLowerCase().includes(search);
const matchesDescription = task.description?.toLowerCase().includes(search);
if (!matchesTitle && !matchesDescription) {
return false;
}
}
return true;
});
}, [tasks, filters]);
// Task erstellen
const addTask = useCallback((input: CreateTaskInput) => {
const now = new Date();
const newTask: Task = {
id: generateId(),
...input,
status: "todo",
createdAt: now,
updatedAt: now
};
setTasks(prev => [...prev, newTask]);
return newTask;
}, [setTasks]);
// Task aktualisieren
const updateTask = useCallback((id: string, input: UpdateTaskInput) => {
setTasks(prev => prev.map(task => {
if (task.id !== id) return task;
return {
...task,
...input,
updatedAt: new Date()
};
}));
}, [setTasks]);
// Task löschen
const deleteTask = useCallback((id: string) => {
setTasks(prev => prev.filter(task => task.id !== id));
}, [setTasks]);
// Status ändern
const toggleStatus = useCallback((id: string) => {
setTasks(prev => prev.map(task => {
if (task.id !== id) return task;
const statusOrder: Task["status"][] = ["todo", "in-progress", "done"];
const currentIndex = statusOrder.indexOf(task.status);
const nextStatus = statusOrder[(currentIndex + 1) % statusOrder.length];
return {
...task,
status: nextStatus,
updatedAt: new Date()
};
}));
}, [setTasks]);
return {
tasks: filteredTasks,
allTasks: tasks,
filters,
setFilters,
addTask,
updateTask,
deleteTask,
toggleStatus
};
}
Components
src/components/TaskForm.tsx
import { useState, FormEvent } from "react";
import { CreateTaskInput, TaskPriority } from "../types/task";
interface TaskFormProps {
onSubmit: (task: CreateTaskInput) => void;
}
export function TaskForm({ onSubmit }: TaskFormProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TaskPriority>("medium");
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!title.trim()) return;
onSubmit({
title: title.trim(),
description: description.trim() || undefined,
priority
});
// Form zurücksetzen
setTitle("");
setDescription("");
setPriority("medium");
};
return (
<form onSubmit={handleSubmit} className="task-form">
<div className="form-group">
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Neue Aufgabe..."
className="task-input"
required
/>
</div>
<div className="form-group">
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Beschreibung (optional)"
className="task-textarea"
rows={2}
/>
</div>
<div className="form-row">
<select
value={priority}
onChange={e => setPriority(e.target.value as TaskPriority)}
className="priority-select"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select>
<button type="submit" className="btn-primary">
Hinzufügen
</button>
</div>
</form>
);
}
src/components/FilterBar.tsx
import { TaskFilters, TaskStatus, TaskPriority } from "../types/task";
interface FilterBarProps {
filters: TaskFilters;
onFilterChange: (filters: TaskFilters) => void;
taskCounts: {
total: number;
todo: number;
inProgress: number;
done: number;
};
}
export function FilterBar({ filters, onFilterChange, taskCounts }: FilterBarProps) {
const handleStatusChange = (status: TaskStatus | "all") => {
onFilterChange({ ...filters, status });
};
const handlePriorityChange = (priority: TaskPriority | "all") => {
onFilterChange({ ...filters, priority });
};
const handleSearchChange = (search: string) => {
onFilterChange({ ...filters, search });
};
return (
<div className="filter-bar">
<div className="status-filters">
<button
className={`filter-btn ${filters.status === "all" ? "active" : ""}`}
onClick={() => handleStatusChange("all")}
>
Alle ({taskCounts.total})
</button>
<button
className={`filter-btn ${filters.status === "todo" ? "active" : ""}`}
onClick={() => handleStatusChange("todo")}
>
Offen ({taskCounts.todo})
</button>
<button
className={`filter-btn ${filters.status === "in-progress" ? "active" : ""}`}
onClick={() => handleStatusChange("in-progress")}
>
In Arbeit ({taskCounts.inProgress})
</button>
<button
className={`filter-btn ${filters.status === "done" ? "active" : ""}`}
onClick={() => handleStatusChange("done")}
>
Erledigt ({taskCounts.done})
</button>
</div>
<div className="additional-filters">
<select
value={filters.priority || "all"}
onChange={e => handlePriorityChange(e.target.value as TaskPriority | "all")}
className="priority-filter"
>
<option value="all">Alle Prioritäten</option>
<option value="high">Hoch</option>
<option value="medium">Mittel</option>
<option value="low">Niedrig</option>
</select>
<input
type="text"
value={filters.search || ""}
onChange={e => handleSearchChange(e.target.value)}
placeholder="Suchen..."
className="search-input"
/>
</div>
</div>
);
}
src/components/TaskItem.tsx
import { Task } from "../types/task";
interface TaskItemProps {
task: Task;
onToggleStatus: (id: string) => void;
onDelete: (id: string) => void;
}
const priorityLabels: Record<Task["priority"], string> = {
low: "Niedrig",
medium: "Mittel",
high: "Hoch"
};
const statusLabels: Record<Task["status"], string> = {
"todo": "Offen",
"in-progress": "In Arbeit",
"done": "Erledigt"
};
export function TaskItem({ task, onToggleStatus, onDelete }: TaskItemProps) {
return (
<div className={`task-item task-${task.status} priority-${task.priority}`}>
<div className="task-content">
<h3 className="task-title">{task.title}</h3>
{task.description && (
<p className="task-description">{task.description}</p>
)}
<div className="task-meta">
<span className={`priority-badge priority-${task.priority}`}>
{priorityLabels[task.priority]}
</span>
<span className={`status-badge status-${task.status}`}>
{statusLabels[task.status]}
</span>
</div>
</div>
<div className="task-actions">
<button
onClick={() => onToggleStatus(task.id)}
className="btn-status"
title="Status ändern"
>
{task.status === "done" ? "↩️" : "✓"}
</button>
<button
onClick={() => onDelete(task.id)}
className="btn-delete"
title="Löschen"
>
🗑️
</button>
</div>
</div>
);
}
src/components/TaskList.tsx
import { Task } from "../types/task";
import { TaskItem } from "./TaskItem";
interface TaskListProps {
tasks: Task[];
onToggleStatus: (id: string) => void;
onDelete: (id: string) => void;
}
export function TaskList({ tasks, onToggleStatus, onDelete }: TaskListProps) {
if (tasks.length === 0) {
return (
<div className="empty-state">
<p>Keine Aufgaben gefunden.</p>
</div>
);
}
return (
<div className="task-list">
{tasks.map(task => (
<TaskItem
key={task.id}
task={task}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
))}
</div>
);
}
App Component
src/App.tsx
import { useMemo } from "react";
import { useTasks } from "./hooks/useTasks";
import { TaskForm } from "./components/TaskForm";
import { FilterBar } from "./components/FilterBar";
import { TaskList } from "./components/TaskList";
import "./App.css";
function App() {
const {
tasks,
allTasks,
filters,
setFilters,
addTask,
deleteTask,
toggleStatus
} = useTasks();
// Task-Zählung für Filter
const taskCounts = useMemo(() => ({
total: allTasks.length,
todo: allTasks.filter(t => t.status === "todo").length,
inProgress: allTasks.filter(t => t.status === "in-progress").length,
done: allTasks.filter(t => t.status === "done").length
}), [allTasks]);
return (
<div className="app">
<header className="app-header">
<h1>Task Manager</h1>
<p>Verwalte deine Aufgaben mit TypeScript und React</p>
</header>
<main className="app-main">
<TaskForm onSubmit={addTask} />
<FilterBar
filters={filters}
onFilterChange={setFilters}
taskCounts={taskCounts}
/>
<TaskList
tasks={tasks}
onToggleStatus={toggleStatus}
onDelete={deleteTask}
/>
</main>
</div>
);
}
export default App;
TypeScript Best Practices in React
1. Props Interface vs Type
// Interface für Props (empfohlen für Components)
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
}
// Type für komplexere Fälle
type ButtonVariant = "primary" | "secondary" | "danger";
2. Event Handler Typen
import { ChangeEvent, FormEvent, MouseEvent } from "react";
// Input Change
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
// Form Submit
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
// Button Click
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
console.log("Clicked!");
};
3. Generic Components
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}
// Verwendung
<List
items={users}
renderItem={user => <span>{user.name}</span>}
keyExtractor={user => user.id}
/>
Was du gelernt hast
- Typisierte Props mit Interfaces
- Custom Hooks mit TypeScript
- Event Handler richtig typisieren
- Generic Components für Wiederverwendbarkeit
- Local Storage mit Typ-Sicherheit
Im nächsten Projekt verbinden wir Frontend und Backend zu einer Full-Stack App!