Build a Kanban Board with TypeScript
Build a type-safe Kanban board using React, TypeScript, and useReducer.
Introduction
30 Second Summary
Every team organizes work the same way: tasks move from "to do" to "in progress" to "done." The tool that makes this visible is a Kanban board. Building one yourself is the perfect way to learn how a type system catches bugs before your code ever runs.
In this project, you will build a Kanban task board in React using TypeScript to enforce type safety across every component, action, and state transition.
What You'll Build
A React application where you create tasks with titles, descriptions, and priority levels, then move them between To Do, In Progress, and Done columns using a fully typed state management system.
By the end of this project, you'll have:
- A type system using interfaces, literal types, and discriminated unions to model the board's data.
- A typed useReducer pattern managing all state transitions with compile-time validation of every action.
- Typed React components with properly typed props, event handlers, and a generic reusable list component.
- Secret Mission: Add localStorage persistence with a type-safe serialization layer.
Are there any prerequisites?
This project assumes you have JavaScript experience and basic React knowledge (components, props, state, and JSX). You do not need prior TypeScript experience.
Set Up the Project
Before you can build a Kanban board, you need a working React project with TypeScript enabled. Vite is a build tool that scaffolds a ready-to-go project in seconds.
In this step, you will scaffold a Vite project, clean out the default demo code, and set up the folder structure you will use for the rest of the project.
In this step, get ready to:
- Scaffold a React + TypeScript project with Vite.
- Clean the default scaffold for a fresh start.
- Create the folder structure for types, components, and reducers.
Scaffold the project using Vite
Vite's react-ts template gives you a React project with TypeScript already configured. No manual setup needed.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the project by running this command:
npm create vite@latest kanban-board -- --template react-ts
What does this command do?
npm create vite@latest runs the Vite project scaffolder.
kanban-board is the name of the folder it creates. The --template react-ts flag tells Vite to use the React + TypeScript starter.
- Move into the new project folder and install dependencies by running these commands:
cd kanban-board
npm install
Seeing a Node.js version error?
Vite requires Node.js version 20.19+ or 22.12+. Run node --version to check yours. If it is lower, update Node.js from nodejs.org before continuing.
Help me fix my Node.js version
- Now open the kanban-board folder in VS Code. Click File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop, select the kanban-board folder, then click Open.
- Open the integrated terminal again by pressing Ctrl+`.
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:5173.
- You should see the default Vite + React page with a counter button and spinning logos.
Page not loading?
Make sure the terminal still shows the dev server running. Check for any error messages in the terminal output. If port 5173 is in use, Vite will pick a different port and display it in the terminal.
Help me fix my dev server
Clean the scaffold for a fresh start
The default Vite scaffold includes demo code you do not need. You will clear it out so you have a blank canvas to build on.
- In the VS Code file explorer on the left, click on src/App.tsx to open it.
- Select all the code (Cmd+A on macOS or Ctrl+A on Windows) and replace it with the following:
function App() {
return <div>Kanban Board</div>;
}
export default App;
What does this code do?
This is the simplest possible React component. It renders a single div with the text "Kanban Board" so you can confirm the app loads.
- Save src/App.tsx (Cmd+S on macOS or Ctrl+S on Windows).
- Check your browser. You should see only the text "Kanban Board" on a blank page.
- Now open src/App.css in the editor.
- Select all the code and delete it. Leave the file completely empty.
- Save src/App.css.
- Open src/index.css in the editor.
- Select all the code and replace it with the following:
:root {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
What does this code do?
These are font-rendering optimizations that make text look crisp across browsers. You are removing the Vite demo styles and keeping only the settings you actually need.
- Save src/index.css.
- Finally, delete the src/assets/ folder. Right-click it in the VS Code file explorer and select Delete.
- If VS Code asks you to confirm, click Move to Trash (macOS) or Move to Recycle Bin (Windows).
Browser showing an error after deleting assets?
If your browser shows an error, it is because main.tsx or App.tsx still imports a file from src/assets/. Make sure you saved your updated App.tsx (the version above does not import any assets).
Help me fix the import error
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is what src/App.tsx should look like:
function App() {
return <div>Kanban Board</div>;
}
export default App;
src/App.css should be completely empty (no code at all).
Here is what src/index.css should look like:
:root {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
Create the project file structure
Your Kanban board will have three layers: types that define data shapes, a reducer that manages state, and components that render the UI. You will create a folder for each one.
- Open a new terminal tab by clicking the + icon in the top-right corner of the terminal panel.
- Create the three directories by running these commands:
mkdir src/types src/components src/reducers
- Confirm the folders exist by running:
ls src/
- You should see types, components, and reducers listed alongside App.tsx, App.css, and the other source files.
Now verify that TypeScript error detection is working in VS Code.
- Open src/App.tsx in the editor.
- Add this line at the very top of the file (above the function App() line):
const test: string = 123;
- You should see a red underline under 123. Hover over it and VS Code shows an error like "Type 'number' is not assignable to type 'string'."
- Delete the const test: string = 123; line you just added.
- Save src/App.tsx.
Why test for type errors?
TypeScript catches bugs before your code runs by analyzing types at compile time. If VS Code shows red underlines for type mismatches, your TypeScript setup is working correctly. This is the foundation the rest of the project builds on.
Not seeing a red underline?
Make sure the file is named App.tsx (not .ts or .js). The .tsx extension tells VS Code to use TypeScript. Also check the bottom-right corner of VS Code to confirm it shows "TypeScript" as the language mode.
Help me fix TypeScript detection
Your project is scaffolded, cleaned, and ready to go. Next up, you will define the type system that powers every piece of your Kanban board.
Define the Type System
Your project is scaffolded and the dev server is running. Now it is time to design the foundation that every other file will depend on.
In TypeScript, you define your data shapes up front. The compiler then uses those shapes to catch bugs before your code ever runs. In this step, you will create the type system that describes tasks, board state, and every action the user can take.
In this step, get ready to:
- Define the core data model (Task, Priority, Status, BoardState).
- Create a discriminated union of action types for state management.
- Verify TypeScript's type narrowing catches errors at compile time.
Create the core data model
Every task on the board has properties like a title, priority, and which column it belongs to. By defining these as TypeScript types, the compiler will enforce that you never accidentally assign an invalid priority or forget a required field.
- In VS Code, open the src/types/ folder in the Explorer sidebar.
- Create a new file called index.ts inside src/types/.
- Add the following code to define the data model:
export type Priority = 'low' | 'medium' | 'high';
export type Status = 'todo' | 'in-progress' | 'done';
export interface Task {
id: string;
title: string;
description: string;
priority: Priority;
status: Status;
createdAt: number;
}
export interface BoardState {
tasks: Task[];
}
What does this code do?
Priority and Status are literal union types. They restrict a value to a specific set of strings. If you try to assign 'urgent' to a Priority field, TypeScript will flag it as an error immediately.
The Task interface describes the shape of a single task card. BoardState wraps the full collection of tasks into one state object, which the reducer will manage.
- Save src/types/index.ts.
- Confirm there are no red underlines in the file. If VS Code shows zero errors, your types are syntactically valid.
Seeing red underlines?
Check that the file is saved inside src/types/index.ts and not in the project root. TypeScript resolves imports based on file location.
Make sure every string in the union types uses single quotes and each line ends with a semicolon.
Why are my types showing errors in VS Code?
Define the discriminated union action types
Your board needs to support four operations: adding a task, moving it between columns, editing it, and deleting it. Instead of passing loose strings around, you will define each action as a typed object with a unique type field. TypeScript uses that field to narrow which properties are available on each action.
- In src/types/index.ts, add the following code below the BoardState interface:
export type AddTaskAction = {
type: 'ADD_TASK';
payload: Omit<Task, 'id' | 'createdAt'>;
};
export type MoveTaskAction = {
type: 'MOVE_TASK';
id: string;
status: Status;
};
export type EditTaskAction = {
type: 'EDIT_TASK';
id: string;
updates: Partial<Task>;
};
export type DeleteTaskAction = {
type: 'DELETE_TASK';
id: string;
};
export type BoardAction =
| AddTaskAction
| MoveTaskAction
| EditTaskAction
| DeleteTaskAction;
What does this code do?
- Each action type has a unique type field (like 'ADD_TASK' or 'MOVE_TASK'). This is what makes it a discriminated union. TypeScript can look at the type value and know exactly which other fields exist.
- Omit<Task, 'id' | 'createdAt'> is a utility type that creates a new type with those two fields removed. The reducer will generate id and createdAt automatically, so the form only needs to supply the rest.
- Partial<Task> makes every field in Task optional. This lets EditTaskAction update only the fields the user changed, without requiring all of them.
- BoardAction combines all four into a single type. The reducer will accept this union and use a switch statement to handle each case.
- Save src/types/index.ts.
- Confirm VS Code shows no red underlines in the file. All four action types and the BoardAction union should be error-free.
Seeing an error on Omit or Partial?
Make sure the Task interface is defined above the action types in the same file. Omit<Task, 'id' | 'createdAt'> and Partial<Task> both reference Task, so it must exist first.
Why is my Omit or Partial type showing an error?
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
export type Priority = 'low' | 'medium' | 'high';
export type Status = 'todo' | 'in-progress' | 'done';
export interface Task {
id: string;
title: string;
description: string;
priority: Priority;
status: Status;
createdAt: number;
}
export interface BoardState {
tasks: Task[];
}
export type AddTaskAction = {
type: 'ADD_TASK';
payload: Omit<Task, 'id' | 'createdAt'>;
};
export type MoveTaskAction = {
type: 'MOVE_TASK';
id: string;
status: Status;
};
export type EditTaskAction = {
type: 'EDIT_TASK';
id: string;
updates: Partial<Task>;
};
export type DeleteTaskAction = {
type: 'DELETE_TASK';
id: string;
};
export type BoardAction =
| AddTaskAction
| MoveTaskAction
| EditTaskAction
| DeleteTaskAction;
Validate the type system works
Types only help if the compiler actually enforces them. You will now add temporary test code to confirm that TypeScript narrows the discriminated union correctly. When the type field is 'ADD_TASK', only payload should be available on the object.
- Open src/App.tsx in VS Code.
- Add the following temporary test code above the function App() line:
import { BoardAction } from './types';
// Temporary test: verify type narrowing
const testAction: BoardAction = {
type: 'ADD_TASK',
payload: {
title: 'Test',
description: 'Testing types',
priority: 'high',
status: 'todo',
},
};
if (testAction.type === 'ADD_TASK') {
// TypeScript knows payload exists here
console.log(testAction.payload.title);
}
What is this testing?
Inside the if block, TypeScript narrows testAction from the full BoardAction union down to just AddTaskAction. That is why testAction.payload.title works without errors. If you tried to access testAction.id inside that block, TypeScript would show a red underline because AddTaskAction does not have an id field.
- Save src/App.tsx and confirm there are no red underlines on the test code.
- Try adding testAction.id inside the if block. You should see a red underline confirming TypeScript rejects it.
- Delete all the temporary test code you just added (the import line, the testAction variable, and the if block).
- Save src/App.tsx. The file should be back to its original state with only the App function.
No red underline when accessing testAction.id?
Make sure you are accessing testAction.id inside the if (testAction.type === 'ADD_TASK') block, not outside it. TypeScript only narrows the type within the conditional block.
Also check that the import statement at the top points to './types' (with the dot-slash). Without the relative path, TypeScript cannot find your types file.
Help me debug why TypeScript is not narrowing my discriminated union.
Your type system is locked in. Every component and function you build from here will reference these types, and the compiler will enforce correctness at every step. Next up, you will build the reducer that processes these actions and updates board state.
Build the Typed Reducer
In the last step, you defined a complete type system for your Kanban board. Now it's time to put those types to work by building the reducer that manages all state changes.
A reducer is a function that takes the current state and an action, then returns a brand new state. Because your actions are a discriminated union, TypeScript can guarantee that your reducer handles every possible action correctly at compile time.
In this step, get ready to:
- Create the board reducer with typed initial state and action handlers.
- Wire the reducer into your App component using useReducer.
- Test the reducer by dispatching an action and verifying the UI updates.
Create the board reducer
The reducer file has two responsibilities: defining the initial state (the sample tasks your board starts with) and the reducer function itself (which processes every action type).
- Open src/reducers/boardReducer.ts in VS Code. This is the empty file inside the reducers directory you created earlier.
- Add the imports and initial state by pasting this code:
import { BoardState, BoardAction } from '../types';
// Sample tasks to populate the board on first load
export const initialState: BoardState = {
tasks: [
{
id: '1',
title: 'Research TypeScript generics',
description: 'Learn how generics enable reusable typed components',
priority: 'high',
status: 'todo',
createdAt: Date.now(),
},
{
id: '2',
title: 'Set up project structure',
description: 'Scaffold Vite + React + TypeScript project',
priority: 'medium',
status: 'in-progress',
createdAt: Date.now(),
},
{
id: '3',
title: 'Define type system',
description: 'Create interfaces and union types for the board',
priority: 'low',
status: 'done',
createdAt: Date.now(),
},
],
};
What does this code do?
The import brings in your BoardState and BoardAction types so TypeScript can enforce the shape of your state and actions.
The initialState constant is typed as BoardState, which means TypeScript will flag an error if any of the sample tasks are missing a required field or have a wrong type. Each task sits in a different column (todo, in-progress, done) so you can immediately see all three columns working.
- Now add the reducer function below the initial state:
export function boardReducer(state: BoardState, action: BoardAction): BoardState {
switch (action.type) {
case 'ADD_TASK': {
// Generate a unique ID and timestamp for the new task
const newTask = {
...action.payload,
id: crypto.randomUUID(),
createdAt: Date.now(),
};
return { tasks: [...state.tasks, newTask] };
}
case 'MOVE_TASK': {
// Update only the matching task's status
return {
tasks: state.tasks.map((task) =>
task.id === action.id ? { ...task, status: action.status } : task
),
};
}
case 'EDIT_TASK': {
// Spread the partial updates onto the matching task
return {
tasks: state.tasks.map((task) =>
task.id === action.id ? { ...task, ...action.updates } : task
),
};
}
case 'DELETE_TASK': {
// Remove the task from the array entirely
return {
tasks: state.tasks.filter((task) => task.id !== action.id),
};
}
default: {
// Exhaustive check: TypeScript errors if a new action type is added but not handled
const _exhaustiveCheck: never = action;
return _exhaustiveCheck;
}
}
}
What does this code do?
The boardReducer function takes the current state and an action, then returns a new state. The switch statement on action.type lets TypeScript automatically narrow which properties are available inside each case.
The default case assigns action to type never. If you ever add a new action type to your union but forget to handle it in the switch, TypeScript will throw a compile-time error on this line. This is called an exhaustive check.
- Save src/reducers/boardReducer.ts.
- Confirm VS Code shows no red underlines in the file. If the type annotations are correct, no errors appear.
Seeing red underlines in boardReducer.ts?
Check that the import path is '../types' (with two dots). The file lives inside src/reducers/, so it needs to go up one level to reach src/types/.
Also verify that your src/types/index.ts exports both BoardState and BoardAction.
Help me debug red underlines in my boardReducer file
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
import { BoardState, BoardAction } from '../types';
export const initialState: BoardState = {
tasks: [
{
id: '1',
title: 'Research TypeScript generics',
description: 'Learn how generics enable reusable typed components',
priority: 'high',
status: 'todo',
createdAt: Date.now(),
},
{
id: '2',
title: 'Set up project structure',
description: 'Scaffold Vite + React + TypeScript project',
priority: 'medium',
status: 'in-progress',
createdAt: Date.now(),
},
{
id: '3',
title: 'Define type system',
description: 'Create interfaces and union types for the board',
priority: 'low',
status: 'done',
createdAt: Date.now(),
},
],
};
export function boardReducer(state: BoardState, action: BoardAction): BoardState {
switch (action.type) {
case 'ADD_TASK': {
const newTask = {
...action.payload,
id: crypto.randomUUID(),
createdAt: Date.now(),
};
return { tasks: [...state.tasks, newTask] };
}
case 'MOVE_TASK': {
return {
tasks: state.tasks.map((task) =>
task.id === action.id ? { ...task, status: action.status } : task
),
};
}
case 'EDIT_TASK': {
return {
tasks: state.tasks.map((task) =>
task.id === action.id ? { ...task, ...action.updates } : task
),
};
}
case 'DELETE_TASK': {
return {
tasks: state.tasks.filter((task) => task.id !== action.id),
};
}
default: {
const _exhaustiveCheck: never = action;
return _exhaustiveCheck;
}
}
}
Wire up the reducer in App.tsx
The reducer is ready, but nothing is using it yet. React's useReducer hook connects your reducer to a component so the component re-renders whenever the state changes.
- Replace the entire contents of src/App.tsx with the following code:
import { useReducer } from 'react';
import { boardReducer, initialState } from './reducers/boardReducer';
function App() {
// Connect the reducer to get typed state and dispatch
const [state, dispatch] = useReducer(boardReducer, initialState);
return (
<div>
<h1>Kanban Board</h1>
<ul>
{state.tasks.map(task => (
<li key={task.id}>[{task.status}] {task.title}</li>
))}
</ul>
</div>
);
}
export default App;
What does this code do?
The useReducer hook takes your boardReducer function and initialState, then returns two things: the current state (typed as BoardState) and a dispatch function (which only accepts BoardAction objects).
TypeScript infers the types automatically from the reducer's signature. You get full autocomplete on state.tasks and compile-time validation on every dispatch call.
- Save src/App.tsx.
- Switch to your browser at http://localhost:5173. You should see a bulleted list with three items: [todo] Research TypeScript generics, [in-progress] Set up project structure, and [done] Define type system.
Don't see the task list?
Make sure you saved both files (boardReducer.ts and App.tsx). Vite's hot reload picks up changes only after saving.
Check your terminal for compile errors. A common issue is a mismatched import path. The import should be './reducers/boardReducer' (no file extension, single dot for same-level directory).
Help me debug why my task list isn't showing up
Test dispatch actions
Seeing the initial tasks proves the state loads correctly. Now you need to verify that dispatching an action actually updates the state.
- In src/App.tsx, find this line:
<h1>Kanban Board</h1>
- Add this button element directly below it, before the <ul> tag:
<button onClick={() => dispatch({ type: 'ADD_TASK', payload: { title: 'Test', description: '', priority: 'low', status: 'todo' } })}>
Add Test Task
</button>
What does this code do?
This button dispatches an ADD_TASK action with a hardcoded payload. When clicked, the reducer creates a new task with a generated ID and timestamp, then returns the updated state. React re-renders the list with the new task included.
- Save src/App.tsx.
- In your browser, click the Add Test Task button. A fourth item should appear in the list: [todo] Test.
- Click it again. A fifth item appears. Every click dispatches a fresh action through your typed reducer.
Why does TypeScript matter here?
Try changing 'ADD_TASK' to 'ADD_TAKS' (a typo) in the button's onClick. VS Code immediately shows a red underline because that string is not part of the BoardAction union. Revert the typo after confirming.
This is the core benefit of discriminated unions. Without them, a misspelled action string would silently do nothing at runtime. With them, TypeScript catches the mistake before your code ever runs.
Button doesn't add a new task?
Make sure the button is inside the <div> that wraps your return statement. If it's outside the JSX tree, React won't render it.
Check for a console error in your browser's DevTools (Cmd+Option+I on macOS, Ctrl+Shift+I on Windows). A runtime error there will point to the broken line.
Help me fix my Add Test Task button
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
import { useReducer } from 'react';
import { boardReducer, initialState } from './reducers/boardReducer';
function App() {
const [state, dispatch] = useReducer(boardReducer, initialState);
return (
<div>
<h1>Kanban Board</h1>
<button onClick={() => dispatch({ type: 'ADD_TASK', payload: { title: 'Test', description: '', priority: 'low', status: 'todo' } })}>
Add Test Task
</button>
<ul>
{state.tasks.map(task => (
<li key={task.id}>[{task.status}] {task.title}</li>
))}
</ul>
</div>
);
}
export default App;
Your state management layer is locked in. TypeScript guarantees that every action you dispatch has the correct shape, and the exhaustive check ensures you never forget to handle a new action type. Next up, you'll build the visual components that turn this state into a real Kanban board.
Build Typed Components
Your reducer is working and your temporary UI proves that state transitions happen correctly. Now it's time to build the real components that will display your Kanban board.
Each component needs to declare exactly what data it expects (props) and exactly how it responds to user interactions (event handlers). TypeScript enforces these contracts at compile time, so a typo in a prop name or a mismatched callback signature gets caught before the code ever runs in the browser.
In this step, get ready to:
- Create a TaskCard component with typed event handlers for moving and deleting tasks.
- Create a Column component that filters and displays task cards by status.
- Create an AddTaskForm component with typed form state and input handlers.
Create the TaskCard component
The TaskCard is the most interactive component on the board. It needs to handle click events for moving a task left or right between columns, plus a delete action.
- Create a new file called TaskCard.tsx inside the src/components/ directory.
- Add the following code to define the component with its props interface and typed event handlers:
import { Task, Status } from '../types';
// Define the exact shape of props this component accepts
interface TaskCardProps {
task: Task;
onMove: (id: string, status: Status) => void;
onDelete: (id: string) => void;
}
// Status progression order for moving tasks between columns
const statusOrder: Status[] = ['todo', 'in-progress', 'done'];
function TaskCard({ task, onMove, onDelete }: TaskCardProps) {
const currentIndex = statusOrder.indexOf(task.status);
// Typed event handler ensures only button clicks trigger this
const handleMoveLeft = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (currentIndex > 0) {
onMove(task.id, statusOrder[currentIndex - 1]);
}
};
const handleMoveRight = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (currentIndex < statusOrder.length - 1) {
onMove(task.id, statusOrder[currentIndex + 1]);
}
};
const handleDelete = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onDelete(task.id);
};
return (
<div className={`task-card priority-${task.priority}`}>
<div className="task-header">
<h3 className="task-title">{task.title}</h3>
<span className="task-priority">{task.priority}</span>
</div>
<p className="task-description">{task.description}</p>
<div className="task-actions">
<button
onClick={handleMoveLeft}
disabled={currentIndex === 0}
className="btn-move"
>
←
</button>
<button
onClick={handleMoveRight}
disabled={currentIndex === statusOrder.length - 1}
className="btn-move"
>
→
</button>
<button onClick={handleDelete} className="btn-delete">
Delete
</button>
</div>
</div>
);
}
export default TaskCard;
What does this code do?
The TaskCardProps interface tells TypeScript exactly what this component requires. If a parent component forgets to pass onMove or passes the wrong function shape, the compiler flags it immediately.
- statusOrder defines the column progression so the move handlers know which status comes next or previous.
- React.MouseEvent<HTMLButtonElement> narrows the event type so TypeScript knows e.stopPropagation() exists and the target is a button element.
- The disabled prop prevents moving a task beyond the first or last column.
- Save TaskCard.tsx.
- Check that VS Code shows no red underlines anywhere in the file.
Seeing red underlines in TaskCard.tsx?
- Make sure the import path is '../types' with two dots. The components folder is one level deeper than types.
- Check that Status is imported alongside Task. Both are needed for the props interface and the statusOrder array.
Why isn't my TaskCard component compiling?
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
import { Task, Status } from '../types';
interface TaskCardProps {
task: Task;
onMove: (id: string, status: Status) => void;
onDelete: (id: string) => void;
}
const statusOrder: Status[] = ['todo', 'in-progress', 'done'];
function TaskCard({ task, onMove, onDelete }: TaskCardProps) {
const currentIndex = statusOrder.indexOf(task.status);
const handleMoveLeft = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (currentIndex > 0) {
onMove(task.id, statusOrder[currentIndex - 1]);
}
};
const handleMoveRight = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (currentIndex < statusOrder.length - 1) {
onMove(task.id, statusOrder[currentIndex + 1]);
}
};
const handleDelete = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onDelete(task.id);
};
return (
<div className={`task-card priority-${task.priority}`}>
<div className="task-header">
<h3 className="task-title">{task.title}</h3>
<span className="task-priority">{task.priority}</span>
</div>
<p className="task-description">{task.description}</p>
<div className="task-actions">
<button
onClick={handleMoveLeft}
disabled={currentIndex === 0}
className="btn-move"
>
←
</button>
<button
onClick={handleMoveRight}
disabled={currentIndex === statusOrder.length - 1}
className="btn-move"
>
→
</button>
<button onClick={handleDelete} className="btn-delete">
Delete
</button>
</div>
</div>
);
}
export default TaskCard;
Create the Column component
Each column on the board represents one status (To Do, In Progress, Done). The Column component receives all tasks but filters them internally, showing only the ones that belong to its assigned status.
- Create a new file called Column.tsx inside the src/components/ directory.
- Add the following code to define the Column component:
import { Task, Status } from '../types';
import TaskCard from './TaskCard';
// Props interface defines what a column needs to render
interface ColumnProps {
title: string;
status: Status;
tasks: Task[];
onMove: (id: string, status: Status) => void;
onDelete: (id: string) => void;
}
function Column({ title, status, tasks, onMove, onDelete }: ColumnProps) {
// Filter the full task list to only those matching this column's status
const columnTasks = tasks.filter((task) => task.status === status);
return (
<div className="column">
<h2 className="column-title">
{title} <span className="task-count">{columnTasks.length}</span>
</h2>
<div className="column-tasks">
{columnTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onMove={onMove}
onDelete={onDelete}
/>
))}
</div>
</div>
);
}
export default Column;
What does this code do?
The ColumnProps interface reuses the same callback signatures (onMove, onDelete) that TaskCard expects. This means the Column component passes them straight through without needing to wrap or transform them.
The columnTasks filter is typed automatically because tasks is declared as Task[]. TypeScript knows the .filter() result is also a Task[].
- Save Column.tsx.
- Check that VS Code shows no red underlines in the file.
Seeing an import error on TaskCard?
- Make sure TaskCard.tsx is saved in the same src/components/ folder. The import './TaskCard' looks for a sibling file.
- Confirm the file is named exactly TaskCard.tsx with a capital T and C.
Why can't my Column component find TaskCard?
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
import { Task, Status } from '../types';
import TaskCard from './TaskCard';
interface ColumnProps {
title: string;
status: Status;
tasks: Task[];
onMove: (id: string, status: Status) => void;
onDelete: (id: string) => void;
}
function Column({ title, status, tasks, onMove, onDelete }: ColumnProps) {
const columnTasks = tasks.filter((task) => task.status === status);
return (
<div className="column">
<h2 className="column-title">
{title} <span className="task-count">{columnTasks.length}</span>
</h2>
<div className="column-tasks">
{columnTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onMove={onMove}
onDelete={onDelete}
/>
))}
</div>
</div>
);
}
export default Column;
Create the AddTaskForm component
Forms are one of the places where TypeScript shines brightest. Each form field has a different element type, and TypeScript can enforce that your change handler matches the element it is attached to.
- Create a new file called AddTaskForm.tsx inside the src/components/ directory.
- Add the following code to build the form with typed state and event handlers:
import { useState } from 'react';
import { Priority, Status, Task } from '../types';
// The form only provides fields the user fills in, not id or createdAt
interface AddTaskFormProps {
onAdd: (task: Omit<Task, 'id' | 'createdAt'>) => void;
}
function AddTaskForm({ onAdd }: AddTaskFormProps) {
// Typed state hooks ensure each field holds the correct value type
const [title, setTitle] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [priority, setPriority] = useState<Priority>('medium');
// FormEvent<HTMLFormElement> ensures we call preventDefault on a form
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!title.trim()) return;
onAdd({
title: title.trim(),
description: description.trim(),
priority,
status: 'todo' as Status,
});
// Reset fields after submission
setTitle('');
setDescription('');
setPriority('medium');
};
return (
<form className="add-task-form" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Task title"
value={title}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setTitle(e.target.value)
}
required
/>
<input
type="text"
placeholder="Description"
value={description}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDescription(e.target.value)
}
/>
<select
value={priority}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setPriority(e.target.value as Priority)
}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button type="submit">Add Task</button>
</form>
);
}
export default AddTaskForm;
What does this code do?
- useState<string> and useState<Priority> explicitly declare what each piece of state holds. This prevents accidentally assigning a number to a text field or an invalid priority value.
- React.ChangeEvent<HTMLInputElement> is different from React.ChangeEvent<HTMLSelectElement>. TypeScript catches it if you accidentally swap them.
- Omit<Task, 'id' | 'createdAt'> in the onAdd prop means the form only provides user-entered fields. The reducer handles generating the id and createdAt values.
- Save AddTaskForm.tsx.
- Check that VS Code shows no red underlines in the file.
Red underline on the ChangeEvent types?
- Make sure the generic type parameter matches the element. Use HTMLInputElement for <input> and HTMLSelectElement for <select>.
- Confirm that Priority and Status are both imported from '../types'. Both are needed in this file.
Help me fix TypeScript errors in my AddTaskForm component.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
import { useState } from 'react';
import { Priority, Status, Task } from '../types';
interface AddTaskFormProps {
onAdd: (task: Omit<Task, 'id' | 'createdAt'>) => void;
}
function AddTaskForm({ onAdd }: AddTaskFormProps) {
const [title, setTitle] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [priority, setPriority] = useState<Priority>('medium');
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!title.trim()) return;
onAdd({
title: title.trim(),
description: description.trim(),
priority,
status: 'todo' as Status,
});
setTitle('');
setDescription('');
setPriority('medium');
};
return (
<form className="add-task-form" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Task title"
value={title}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setTitle(e.target.value)
}
required
/>
<input
type="text"
placeholder="Description"
value={description}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDescription(e.target.value)
}
/>
<select
value={priority}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setPriority(e.target.value as Priority)
}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button type="submit">Add Task</button>
</form>
);
}
export default AddTaskForm;
All three components are typed and ready to render. Next up, you'll assemble them into the full Kanban board layout and add styling to make it look polished.
Assemble the Board with Generics
You have all the building blocks ready. Your reducer manages state, and your components know how to display and interact with tasks. Now it's time to connect everything into a working Kanban board.
Before wiring the components together, you'll build one more piece: a generic FilterableList component. This component demonstrates how TypeScript generics let you create reusable components that work with any data type while keeping full type safety.
In this step, get ready to:
- Create a generic FilterableList component that works with any data type.
- Assemble all components in App.tsx with typed dispatch handlers.
- Style the board into a polished 3-column layout with priority-colored cards.
Create the generic FilterableList component
A generic component uses a type parameter (like <T>) so it can work with any data type. Instead of hardcoding the component to only handle Task objects, you make it flexible enough to filter and render arrays of anything.
- Create a new file src/components/FilterableList.tsx and add the following code:
import React from 'react';
interface FilterableListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
filterFn: (item: T) => boolean;
}
function FilterableList<T>({
items,
renderItem,
filterFn,
}: FilterableListProps<T>) {
const filteredItems = items.filter(filterFn);
if (filteredItems.length === 0) {
return <p className="empty-message">No items match the filter.</p>;
}
return <div className="filterable-list">{filteredItems.map(renderItem)}</div>;
}
export default FilterableList;
What does this code do?
The <T> in FilterableListProps<T> is a type parameter. When you use this component with FilterableList<Task>, TypeScript substitutes T with Task everywhere, so filterFn and renderItem are guaranteed to receive a Task.
The same component could filter an array of strings, users, or any other type. That reusability without losing type safety is what makes generics powerful.
- Save FilterableList.tsx.
You won't see a change in the browser yet. The next substep wires this component into your app.
Seeing a red underline on the function signature?
Make sure the function uses the syntax function FilterableList<T>({ ... }: FilterableListProps<T>). If you wrote an arrow function, TypeScript in .tsx files can confuse <T> with a JSX tag. A named function declaration avoids this issue.
help me fix a TypeScript generic component error in a .tsx file
✔️ Awesome, I've got everything!
Great. Make sure you've saved FilterableList.tsx before moving on.
ⓧ I'd like to double check the full code
import React from 'react';
interface FilterableListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
filterFn: (item: T) => boolean;
}
function FilterableList<T>({
items,
renderItem,
filterFn,
}: FilterableListProps<T>) {
const filteredItems = items.filter(filterFn);
if (filteredItems.length === 0) {
return <p className="empty-message">No items match the filter.</p>;
}
return <div className="filterable-list">{filteredItems.map(renderItem)}</div>;
}
export default FilterableList;
Assemble everything in App.tsx
Right now App.tsx still has the temporary task list and test button from earlier. You'll replace the entire file with the real board layout that connects your reducer to all four components.
- Replace the entire contents of src/App.tsx with the following code:
import { useReducer } from 'react';
import { boardReducer, initialState } from './reducers/boardReducer';
import { Status, Task } from './types';
import Column from './components/Column';
import AddTaskForm from './components/AddTaskForm';
import FilterableList from './components/FilterableList';
import './App.css';
function App() {
const [state, dispatch] = useReducer(boardReducer, initialState);
// Dispatch ADD_TASK when the form submits a new task
const handleAddTask = (task: Omit<Task, 'id' | 'createdAt'>) => {
dispatch({ type: 'ADD_TASK', payload: task });
};
// Dispatch MOVE_TASK when a card's arrow button is clicked
const handleMoveTask = (id: string, status: Status) => {
dispatch({ type: 'MOVE_TASK', id, status });
};
// Dispatch DELETE_TASK when a card's delete button is clicked
const handleDeleteTask = (id: string) => {
dispatch({ type: 'DELETE_TASK', id });
};
return (
<div className="app">
<header className="app-header">
<h1>Kanban Board</h1>
</header>
<AddTaskForm onAdd={handleAddTask} />
<div className="board">
<Column
title="To Do"
status="todo"
tasks={state.tasks}
onMove={handleMoveTask}
onDelete={handleDeleteTask}
/>
<Column
title="In Progress"
status="in-progress"
tasks={state.tasks}
onMove={handleMoveTask}
onDelete={handleDeleteTask}
/>
<Column
title="Done"
status="done"
tasks={state.tasks}
onMove={handleMoveTask}
onDelete={handleDeleteTask}
/>
</div>
<section className="high-priority-section">
<h2>High Priority Tasks</h2>
<FilterableList<Task>
items={state.tasks}
filterFn={(task) => task.priority === 'high'}
renderItem={(task) => (
<div key={task.id} className="high-priority-item">
<strong>{task.title}</strong> \u2014 {task.status}
</div>
)}
/>
</section>
</div>
);
}
export default App;
What's happening here?
- handleAddTask, handleMoveTask, and handleDeleteTask are thin wrappers that dispatch typed actions to the reducer. TypeScript verifies that each dispatch payload matches its discriminated union case.
- Three Column components each receive the full task array and a status prop. Each column internally filters to show only its own tasks.
- FilterableList<Task> passes the Task type explicitly so TypeScript knows filterFn and renderItem both receive a Task object.
- Save App.tsx.
- Switch to your browser. You should see the header, the add task form inputs, three columns (To Do, In Progress, Done), and the sample tasks distributed across them. Styling will be rough since App.css is still empty.
Seeing a blank page or import errors?
- Check that your import paths match the file names exactly. './components/FilterableList' must point to the file you just created.
- Make sure FilterableList.tsx is saved inside the src/components/ directory, not at the project root.
- If the terminal shows a compile error, read the first error message carefully. It usually names the exact file and line number.
help me debug import errors in my Kanban board App.tsx
✔️ Awesome, I've got everything!
Great. Double check that you can see your three columns and the sample tasks before moving on to styling.
ⓧ I'd like to double check the full code
import { useReducer } from 'react';
import { boardReducer, initialState } from './reducers/boardReducer';
import { Status, Task } from './types';
import Column from './components/Column';
import AddTaskForm from './components/AddTaskForm';
import FilterableList from './components/FilterableList';
import './App.css';
function App() {
const [state, dispatch] = useReducer(boardReducer, initialState);
const handleAddTask = (task: Omit<Task, 'id' | 'createdAt'>) => {
dispatch({ type: 'ADD_TASK', payload: task });
};
const handleMoveTask = (id: string, status: Status) => {
dispatch({ type: 'MOVE_TASK', id, status });
};
const handleDeleteTask = (id: string) => {
dispatch({ type: 'DELETE_TASK', id });
};
return (
<div className="app">
<header className="app-header">
<h1>Kanban Board</h1>
</header>
<AddTaskForm onAdd={handleAddTask} />
<div className="board">
<Column
title="To Do"
status="todo"
tasks={state.tasks}
onMove={handleMoveTask}
onDelete={handleDeleteTask}
/>
<Column
title="In Progress"
status="in-progress"
tasks={state.tasks}
onMove={handleMoveTask}
onDelete={handleDeleteTask}
/>
<Column
title="Done"
status="done"
tasks={state.tasks}
onMove={handleMoveTask}
onDelete={handleDeleteTask}
/>
</div>
<section className="high-priority-section">
<h2>High Priority Tasks</h2>
<FilterableList<Task>
items={state.tasks}
filterFn={(task) => task.priority === 'high'}
renderItem={(task) => (
<div key={task.id} className="high-priority-item">
<strong>{task.title}</strong> \u2014 {task.status}
</div>
)}
/>
</section>
</div>
);
}
export default App;
Style the Kanban board
The components render correctly but look unstyled. This CSS gives the board a clean 3-column flexbox layout with priority-based card border colors so you can instantly distinguish high, medium, and low priority tasks.
- Open src/App.css and replace its contents with the following styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f7fa;
color: #333;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.app-header {
text-align: center;
margin-bottom: 2rem;
}
.app-header h1 {
font-size: 2rem;
color: #1a1a2e;
}
.add-task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.add-task-form input,
.add-task-form select {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.9rem;
}
.add-task-form input[type="text"] {
flex: 1;
min-width: 150px;
}
.add-task-form button {
padding: 0.5rem 1.5rem;
background-color: #4a90d9;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.add-task-form button:hover {
background-color: #357abd;
}
.board {
display: flex;
gap: 1.5rem;
margin-bottom: 2rem;
}
.column {
flex: 1;
background-color: #e8ecf1;
border-radius: 8px;
padding: 1rem;
min-height: 400px;
}
.column-title {
font-size: 1.1rem;
margin-bottom: 1rem;
color: #444;
}
.task-count {
background-color: #c8d6e5;
border-radius: 50%;
padding: 0.1rem 0.5rem;
font-size: 0.8rem;
}
.column-tasks {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-card {
background-color: white;
border-radius: 6px;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border-left: 4px solid #ccc;
}
.task-card.priority-high {
border-left-color: #e74c3c;
}
.task-card.priority-medium {
border-left-color: #f39c12;
}
.task-card.priority-low {
border-left-color: #27ae60;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.task-title {
font-size: 0.95rem;
font-weight: 600;
}
.task-priority {
font-size: 0.75rem;
text-transform: uppercase;
padding: 0.15rem 0.5rem;
border-radius: 4px;
background-color: #f0f0f0;
}
.task-description {
font-size: 0.85rem;
color: #666;
margin-bottom: 0.75rem;
}
.task-actions {
display: flex;
gap: 0.5rem;
}
.btn-move {
padding: 0.25rem 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
cursor: pointer;
font-size: 0.85rem;
}
.btn-move:hover:not(:disabled) {
background-color: #e0e0e0;
}
.btn-move:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-delete {
padding: 0.25rem 0.5rem;
border: 1px solid #e74c3c;
border-radius: 4px;
background-color: #fff;
color: #e74c3c;
cursor: pointer;
font-size: 0.85rem;
margin-left: auto;
}
.btn-delete:hover {
background-color: #e74c3c;
color: white;
}
.high-priority-section {
background-color: #fff;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.high-priority-section h2 {
margin-bottom: 1rem;
font-size: 1.1rem;
color: #e74c3c;
}
.high-priority-item {
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
}
.filterable-list {
display: flex;
flex-direction: column;
}
.empty-message {
color: #999;
font-style: italic;
}
What do these styles do?
- .board { display: flex } creates the 3-column layout. Each .column uses flex: 1 to share equal width.
- .task-card.priority-high applies a red left border, .priority-medium gets orange, and .priority-low gets green. These class names come from the dynamic priority-${task.priority} string in TaskCard.tsx.
- The form uses display: flex with flex-wrap: wrap so inputs sit in a row and wrap gracefully on smaller screens.
- Save App.css and switch to your browser.
- You should see a polished Kanban board with three grey columns side by side. The "Research TypeScript generics" card in the To Do column has a red left border (high priority). The "Set up project structure" card in the In Progress column has an orange border (medium). The "Define type system" card in Done has a green border (low).
- Try clicking the right arrow on the To Do card to move it to In Progress. Try adding a new task using the form at the top. The High Priority Tasks section at the bottom updates automatically.
Cards look unstyled or stacked vertically in one column?
- Make sure the import './App.css' line is present in App.tsx. Without it, none of the CSS classes apply.
- Check that the class name board in your JSX matches .board in your CSS. Class names are case-sensitive.
- If priority colors are missing, verify that TaskCard.tsx uses the template string task-card priority-${task.priority} for its className.
help me debug why my Kanban board styles aren't showing up
✔️ Awesome, I've got everything!
Great. Make sure both App.tsx and App.css are saved.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f7fa;
color: #333;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.app-header {
text-align: center;
margin-bottom: 2rem;
}
.app-header h1 {
font-size: 2rem;
color: #1a1a2e;
}
.add-task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.add-task-form input,
.add-task-form select {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.9rem;
}
.add-task-form input[type="text"] {
flex: 1;
min-width: 150px;
}
.add-task-form button {
padding: 0.5rem 1.5rem;
background-color: #4a90d9;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.add-task-form button:hover {
background-color: #357abd;
}
.board {
display: flex;
gap: 1.5rem;
margin-bottom: 2rem;
}
.column {
flex: 1;
background-color: #e8ecf1;
border-radius: 8px;
padding: 1rem;
min-height: 400px;
}
.column-title {
font-size: 1.1rem;
margin-bottom: 1rem;
color: #444;
}
.task-count {
background-color: #c8d6e5;
border-radius: 50%;
padding: 0.1rem 0.5rem;
font-size: 0.8rem;
}
.column-tasks {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-card {
background-color: white;
border-radius: 6px;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border-left: 4px solid #ccc;
}
.task-card.priority-high {
border-left-color: #e74c3c;
}
.task-card.priority-medium {
border-left-color: #f39c12;
}
.task-card.priority-low {
border-left-color: #27ae60;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.task-title {
font-size: 0.95rem;
font-weight: 600;
}
.task-priority {
font-size: 0.75rem;
text-transform: uppercase;
padding: 0.15rem 0.5rem;
border-radius: 4px;
background-color: #f0f0f0;
}
.task-description {
font-size: 0.85rem;
color: #666;
margin-bottom: 0.75rem;
}
.task-actions {
display: flex;
gap: 0.5rem;
}
.btn-move {
padding: 0.25rem 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
cursor: pointer;
font-size: 0.85rem;
}
.btn-move:hover:not(:disabled) {
background-color: #e0e0e0;
}
.btn-move:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-delete {
padding: 0.25rem 0.5rem;
border: 1px solid #e74c3c;
border-radius: 4px;
background-color: #fff;
color: #e74c3c;
cursor: pointer;
font-size: 0.85rem;
margin-left: auto;
}
.btn-delete:hover {
background-color: #e74c3c;
color: white;
}
.high-priority-section {
background-color: #fff;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.high-priority-section h2 {
margin-bottom: 1rem;
font-size: 1.1rem;
color: #e74c3c;
}
.high-priority-item {
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
}
.filterable-list {
display: flex;
flex-direction: column;
}
.empty-message {
color: #999;
font-style: italic;
}
Secret mission
Add Type-Safe localStorage Persistence
Your Kanban board works great, but every time you refresh the browser, all your tasks reset to the defaults. Can you build a generic persistence layer using TypeScript generics and type guards to safely save and load state from localStorage?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files, pause the dev server to free up resources, or delete everything entirely.
Resources you used:
- The kanban-board project folder on your Desktop.
- The Vite dev server running in your terminal.
- Browser localStorage data stored under the key kanban-board-state.
Keep everything running
No action needed. Choose this if you're still actively building or want to continue experimenting with your Kanban board.
- Your dev server stays running at http://localhost:5173.
- Your project files remain in the kanban-board folder on your Desktop.
- Your tasks persist in the browser's localStorage between sessions.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal and system resources. Your project files stay on disk for when you return.
- Switch to the terminal where the dev server is running.
- Press Ctrl+C to stop the Vite dev server.
- Your project files stay in the kanban-board folder on your Desktop. To restart later, open the folder in your terminal and run npm run dev.
Delete - I don't want to use this again
Remove all project files and stored data to start completely fresh.
- Switch to the terminal where the dev server is running and press Ctrl+C to stop it.
- Delete the project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf kanban-board
On Windows, run this instead:
cd ~\Desktop
rmdir /s /q kanban-board
- Clear the localStorage data by opening your browser's DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and running:
localStorage.removeItem('kanban-board-state')
- Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir ~\Desktop (Windows). You should no longer see kanban-board in the output.
Nice Work!
Nice Work!
Nice work! You just built a fully functional Kanban task board with React and TypeScript from scratch.
You've learned how to:
- Designed a type system using interfaces, literal types, and discriminated unions to model your application's data.
- Implemented type-safe state management with useReducer and exhaustive action handling that catches invalid actions at compile time.
- Built a generic reusable component (FilterableList) demonstrating how TypeScript generics make components work across different data types.
- Secret Mission: Added type-safe localStorage persistence using generics and type guards to validate data at runtime boundaries.
Ready to quiz yourself?