Zum Inhalt springen
TypeScript Fortgeschritten 60 min

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!

Zurück zum TypeScript Kurs