Build a MERN CRUD Scheduler
Build a React task scheduler with an Express API and MongoDB persistence.
Introduction
30 Second Summary
A task list feels dependable until one browser refresh wipes out tomorrow's plan. A useful scheduler keeps every change exactly where you left it.
In this project, you will build TaskFlow as a browser-based scheduler for work deadlines. Its React interface will use an Express REST API to store every task in MongoDB Atlas.
What You'll Build
You refresh TaskFlow after updating your schedule and see every saved task exactly where you left it.
By the end of this project, you'll have:
- A due-date scheduler that lets you plan each task with a title, date, priority, and status.
- A persistent CRUD flow that lets you create, reload, edit, complete, reopen, or delete tasks while each change remains correct after refresh.
- Visible loading, empty, validation, and API error feedback that makes the scheduler's current state clear in the browser.
- Secret Mission: Add All, Planned, and Completed filters without changing the API.
Are there any prerequisites?
You'll need Node.js and Visual Studio Code installed on your computer. You'll also need a MongoDB Atlas account plus confidence with full-stack JavaScript.
Before We Start
Before the hands-on work, lock in a focused promise: TaskFlow will persist due-dated tasks in MongoDB Atlas across browser refreshes through create, read, edit, complete, reopen, and delete actions. Authentication, deployment, recurring jobs, and background processing stay outside this project.
Set Up the React Workspace
TaskFlow needs a browser workspace before its scheduler can grow. A running starter gives you a quick check before backend or database complexity arrives.
Visual Studio Code holds the project files. Node.js powers Vite so React can render the first TaskFlow heading.
In this step, get ready to:
- Confirm a Vite-compatible Node.js version.
- Assemble the TaskFlow React starter files.
- Launch the starter through npm.
Prepare the TaskFlow workspace
A workspace keeps every file plus terminal command anchored to the taskflow folder. Vite 8.3.5 requires Node.js 20.19+ or 22.12+.
- Press the Windows key to open the Windows search bar.
- Type Visual Studio Code.
- Press Enter to start Visual Studio Code.
- Select File from the menu bar.
- Select Open Folder....
- Select Desktop in the folder dialog.
- Select New Folder.
- Enter taskflow as the folder name.
The new folder gives every project file a predictable home on your Desktop.
- Select Select Folder.
- Review the folder name if the Workspace Trust dialog appears.
- Select Yes, I trust the authors.
- Select View from the menu bar.
- Select Terminal.
- Confirm the integrated terminal is using PowerShell.
PowerShell starts inside the open taskflow folder. This keeps each upcoming command scoped to your project.
- Check the installed Node.js version by running this command:
node --version
What Does This Command Check?
The --version flag asks Node.js to print its installed version. You can compare that result with Vite's supported versions before installing any packages.
✔️ I see a Vite-compatible version
Your result meets the Vite requirement of Node.js 20.19+ or 22.12+. Your local runtime is ready for TaskFlow.
ⓧ I see an older version
The current project uses Node.js v24.22.1 LTS. Updating gives Vite a supported runtime.
- Open the official Node.js download page.
- Download the Windows installer for v24.22.1 LTS.
- Run the downloaded installer.
- Accept the default installation choices.
Visual Studio Code needs a fresh session before its terminal can detect the updated runtime.
- Close Visual Studio Code after the installation finishes.
- Press the Windows key.
- Type Visual Studio Code.
- Press Enter.
- Return to the taskflow workspace.
- Open the integrated PowerShell terminal from View.
- Repeat the Node.js version check above.
ⓧ Command not found
PowerShell cannot currently find a Node.js installation. Installing the current LTS release adds the runtime plus npm to your computer.
- Open the official Node.js download page.
- Download the Windows installer for v24.22.1 LTS.
- Run the downloaded installer.
- Accept the default installation choices.
A fresh Visual Studio Code session picks up the new command path.
- Close Visual Studio Code after the installation finishes.
- Press the Windows key.
- Type Visual Studio Code.
- Press Enter.
- Return to the taskflow workspace.
- Open the integrated PowerShell terminal from View.
- Repeat the Node.js version check above.
Still Missing the Node.js Version?
- Close every Visual Studio Code window before starting a fresh session.
- Confirm the Node.js installer completed without reporting a failure.
Use this prompt for targeted help: Help me diagnose why node --version does not work in the Visual Studio Code PowerShell terminal after installing Node.js on Windows.
Share the version-check result in the NextWork community if your Windows environment still blocks the command.
Add the React starter files
The package manifest defines the scripts plus exact dependencies used by this project. The remaining files connect the browser page to the React component.
- Right-click the taskflow folder in the Explorer sidebar.
- Select New File.
- Enter package.json.
- Replace the empty file with this package manifest:
{"name":"taskflow-mern","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build","server":"node server/server.js"},"dependencies":{"react":"19.3.1","react-dom":"19.3.1"},"devDependencies":{"@vitejs/plugin-react":"6.1.2","vite":"8.3.5"}}
What Does This Manifest Define?
- The scripts object gives npm the commands for development plus later project stages.
- The dependencies object pins React plus React DOM to 19.3.1.
- The devDependencies object pins Vite to 8.3.5.
- The React plugin is pinned to 6.1.2.
- Save package.json.
- Confirm package.json appears directly under taskflow in the Explorer sidebar.
The first dependency installation can take a minute while npm downloads the pinned packages.
- Install the dependencies from package.json by running this command:
npm install
What Does This Command Install?
npm reads package.json from the current folder. It installs the exact React plus Vite package versions declared in the manifest.
PowerShell prints an installation summary before returning to the prompt. That result confirms the starter dependencies are available locally.
Does the Installation Fail?
- Confirm the PowerShell path ends with the taskflow folder.
- Check package.json for missing quotation marks.
- Repeat the Node.js version check if npm is unavailable.
Use this prompt for targeted help: Help me diagnose npm install in my taskflow folder using this package.json file.
- Right-click the taskflow folder in the Explorer sidebar.
- Select New File.
- Enter vite.config.js.
- Add this Vite configuration:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});
What Does This Configuration Do?
- The imports load Vite's configuration helper plus the React plugin.
- The plugins array enables React support when Vite processes the starter.
- Save vite.config.js.
- Confirm vite.config.js appears directly under taskflow in the Explorer sidebar.
Seeing a Configuration Error?
Check that both import paths match the package manifest. Confirm that plugins contains react().
Use this prompt for targeted help: Help me compare my vite.config.js file with the TaskFlow configuration.
- Right-click the taskflow folder in the Explorer sidebar.
- Select New File.
- Enter index.html.
- Add this browser entry page:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="TaskFlow MERN task scheduler" />
<title>TaskFlow</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
What Does This Page Provide?
- The root element gives React a place to render TaskFlow.
- The module script loads src/main.jsx as the browser entry point.
- The metadata gives the browser the TaskFlow page title plus a mobile-friendly viewport.
- Save index.html.
- Confirm index.html appears directly under taskflow in the Explorer sidebar.
Is the Entry Page Incomplete?
Confirm the page contains an element with id="root". Check that the script path is /src/main.jsx.
Use this prompt for targeted help: Help me check the HTML structure in my TaskFlow index.html file.
- Right-click the taskflow folder in the Explorer sidebar.
- Select New Folder.
- Enter src.
- Right-click the new src folder.
- Select New File.
- Enter main.jsx.
- Add this React entry point:
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './index.css';
createRoot(document.getElementById('root')).render(<App />);
What Does This Entry Point Do?
- The imports load React DOM plus the TaskFlow component plus its stylesheet.
- The createRoot() call connects React to the HTML element named root.
- The render() call displays the App component.
- Save src/main.jsx.
- Confirm main.jsx appears inside src in the Explorer sidebar.
Are the Imports Underlined?
Check that App.jsx plus index.css use relative paths beginning with ./.
Use this prompt for targeted help: Help me check the imports in my TaskFlow src/main.jsx file.
- Right-click the src folder in the Explorer sidebar.
- Select New File.
- Enter App.jsx.
- Add this starter component:
export default function App() {
return <h1>TaskFlow</h1>;
}
What Does This Component Render?
The App component returns the TaskFlow heading. Exporting it as the default lets main.jsx import the component directly.
- Save src/App.jsx.
- Confirm App.jsx appears inside src in the Explorer sidebar.
Is the Component Marked as Invalid?
Confirm the function name uses a capital A. Check that the returned heading closes with </h1>.
Use this prompt for targeted help: Help me check the syntax in my TaskFlow src/App.jsx component.
- Right-click the src folder in the Explorer sidebar.
- Select New File.
- Enter index.css.
- Add this starter page style:
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
}
What Does This Style Change?
- The zero margin removes the browser's default outer spacing.
- The font stack gives the starter a readable system typeface.
- Save src/index.css.
- Confirm index.css appears inside src in the Explorer sidebar.
Is the Stylesheet Marked as Invalid?
Confirm the body rule has one opening brace plus one closing brace. Check that each property ends correctly.
Use this prompt for targeted help: Help me check the CSS syntax in my TaskFlow src/index.css file.
✔️ Awesome, I've got everything!
Great. Save every open file before starting the development server.
ⓧ I'd like to double check the full code
Compare each saved file with these complete starter versions.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="TaskFlow MERN task scheduler" />
<title>TaskFlow</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
{"name":"taskflow-mern","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build","server":"node server/server.js"},"dependencies":{"react":"19.3.1","react-dom":"19.3.1"},"devDependencies":{"@vitejs/plugin-react":"6.1.2","vite":"8.3.5"}}
export default function App() {
return <h1>TaskFlow</h1>;
}
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
}
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './index.css';
createRoot(document.getElementById('root')).render(<App />);
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
});
Launch the TaskFlow starter
The development script starts Vite from the taskflow folder. PowerShell stays occupied while the development server is running.
Before you run the starter, what heading do you expect the browser to show?
- Start the Vite development server by running this command:
npm run dev
What Does This Command Start?
npm runs the dev script from package.json. That script starts Vite plus prints the local development URL.
- Open http://localhost:5173 in your browser.
- Leave the integrated PowerShell terminal running for the next step.
You should see a page with the heading TaskFlow. The browser tab should also show the TaskFlow page title.
Your first browser result is live. The React workspace can now turn saved project code into a local page.
Do You See a Blank Page?
- Confirm PowerShell still shows the running Vite server.
- Check that index.html loads /src/main.jsx.
- Confirm src/main.jsx imports ./App.jsx.
Use this prompt for targeted help: Help me diagnose a blank page after running npm run dev for my TaskFlow React starter.
Your workspace is ready. Next, you will turn this starter heading into a scheduler you can use in the browser.
Build a Temporary Scheduler
TaskFlow currently renders through React. The starter heading cannot collect work or show a schedule.
This step uses React state to hold submitted tasks inside the browser. Each input becomes part of a controlled form whose displayed value always comes from state.
In this step, get ready to:
- Build a controlled form for a task title, due date, and priority.
- Display submitted tasks as cards with priority and status badges.
- Refresh the scheduler to expose the missing persistence layer.
Build the in-memory scheduler
The temporary scheduler stores the form draft plus every submitted task in useState. The submit handler copies the draft into the task list without contacting a server.
- Select src/App.jsx in the VS Code Explorer sidebar.
- Replace the starter component with this scheduler code:
import { useState } from 'react';
const EMPTY_FORM = { title: '', dueDate: '', priority: 'medium' };
export default function App() {
// Store the form draft plus submitted tasks inside this browser session.
const [tasks, setTasks] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
function updateField(event) {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: value }));
}
function handleSubmit(event) {
event.preventDefault();
// Give each temporary task an identifier for React's rendered list.
const task = { ...form, _id: Date.now(), status: 'planned' };
setTasks((current) => [...current, task]);
setForm(EMPTY_FORM);
}
return (
<main className="app-shell">
<header className="hero"><p className="eyebrow">React state project</p><h1>TaskFlow</h1><p>Schedule work by due date and priority.</p></header>
<section className="panel"><h2>Schedule a task</h2><form className="task-form" onSubmit={handleSubmit}><label>Title<input name="title" value={form.title} onChange={updateField} placeholder="Prepare sprint demo" required /></label><label>Due date<input type="date" name="dueDate" value={form.dueDate} onChange={updateField} required /></label><label>Priority<select name="priority" value={form.priority} onChange={updateField}><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select></label><div className="form-actions"><button type="submit">Add task</button></div></form></section>
<section className="panel"><div className="section-heading"><h2>Upcoming tasks</h2><span>{tasks.length} total</span></div>{tasks.length === 0 && <p className="message">No tasks yet. Schedule the first one above.</p>}<div className="task-list">{tasks.map((task) => <article className="task-card" key={task._id}><div><div className="badges"><span className={`badge ${task.priority}`}>{task.priority}</span></div><h3>{task.title}</h3><p>Due {task.dueDate}</p></div><div className="task-actions"><span className="badge status">{task.status}</span></div></article>)}</div></section>
</main>
);
}
What Does This Code Do?
- The tasks state holds task objects submitted during the current browser session.
- The form state supplies the displayed value for each controlled field.
- The updateField() function updates the field whose name matches the changed input.
- The handleSubmit() function appends a temporary task to the existing array.
- The tasks.map() expression turns each submitted task into a card.
- Save src/App.jsx.
- Switch back to the TaskFlow browser tab from earlier.
- Enter Prepare sprint demo in the Title field.
- Enter 2026-12-15 in the Due date field.
- Select High from the Priority field.
- Click Add task.
Your submitted task appears immediately under Upcoming tasks. The task count also increases to 1 total.
Does the Form Stay Empty?
- Confirm that each input has a name matching a key in EMPTY_FORM.
- Confirm that the form uses onSubmit={handleSubmit}.
- Check the browser console for a syntax error in src/App.jsx.
Help me debug the temporary TaskFlow form.
✔️ Awesome, I've got everything!
Great. Your temporary scheduler now accepts task details and renders each submission from browser state.
ⓧ I'd like to double check the full code
import { useState } from 'react';
const EMPTY_FORM = { title: '', dueDate: '', priority: 'medium' };
export default function App() {
// Store the form draft plus submitted tasks inside this browser session.
const [tasks, setTasks] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
function updateField(event) {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: value }));
}
function handleSubmit(event) {
event.preventDefault();
// Give each temporary task an identifier for React's rendered list.
const task = { ...form, _id: Date.now(), status: 'planned' };
setTasks((current) => [...current, task]);
setForm(EMPTY_FORM);
}
return (
<main className="app-shell">
<header className="hero"><p className="eyebrow">React state project</p><h1>TaskFlow</h1><p>Schedule work by due date and priority.</p></header>
<section className="panel"><h2>Schedule a task</h2><form className="task-form" onSubmit={handleSubmit}><label>Title<input name="title" value={form.title} onChange={updateField} placeholder="Prepare sprint demo" required /></label><label>Due date<input type="date" name="dueDate" value={form.dueDate} onChange={updateField} required /></label><label>Priority<select name="priority" value={form.priority} onChange={updateField}><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option></select></label><div className="form-actions"><button type="submit">Add task</button></div></form></section>
<section className="panel"><div className="section-heading"><h2>Upcoming tasks</h2><span>{tasks.length} total</span></div>{tasks.length === 0 && <p className="message">No tasks yet. Schedule the first one above.</p>}<div className="task-list">{tasks.map((task) => <article className="task-card" key={task._id}><div><div className="badges"><span className={`badge ${task.priority}`}>{task.priority}</span></div><h3>{task.title}</h3><p>Due {task.dueDate}</p></div><div className="task-actions"><span className="badge status">{task.status}</span></div></article>)}</div></section>
</main>
);
}
Use this complete file to compare the import, state declarations, event handlers, and rendered scheduler structure.
Style the scheduler interface
The class names in src/App.jsx give CSS specific parts of the scheduler to target. The first styling layer creates the page layout plus the responsive form.
- Select src/index.css in the VS Code Explorer sidebar.
- Replace the existing body rule with this foundation:
/* Establish the page foundation plus the responsive form layout. */
:root { font-family: Inter, system-ui, sans-serif; color: #172033; background: #eef2ff; font-synthesis: none; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, select { font: inherit; }
button { border: 0; border-radius: 0.65rem; padding: 0.7rem 1rem; color: white; background: #4f46e5; cursor: pointer; }
button:hover { filter: brightness(0.95); }
.app-shell { width: min(960px, calc(100% - 2rem)); margin: 0 auto; padding: 2.5rem 0 4rem; }
.hero { margin-bottom: 1.5rem; }
.hero h1 { margin: 0; font-size: clamp(2.4rem, 7vw, 4.5rem); }
.hero p { max-width: 650px; }
.eyebrow { margin-bottom: 0.35rem; color: #4f46e5; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
.panel { margin-top: 1rem; padding: 1.25rem; border: 1px solid #dbe2f1; border-radius: 1rem; background: white; box-shadow: 0 14px 40px rgba(50, 60, 100, 0.08); }
.panel h2 { margin-top: 0; }
.task-form { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem; align-items: end; }
label { display: grid; gap: 0.4rem; color: #475569; font-size: 0.9rem; font-weight: 700; }
input, select { width: 100%; border: 1px solid #cbd5e1; border-radius: 0.65rem; padding: 0.72rem; color: #172033; background: white; }
.form-actions, .task-actions, .badges, .section-heading { display: flex; gap: 0.6rem; align-items: center; }
.form-actions { grid-column: 1 / -1; }
.section-heading { justify-content: space-between; }
.section-heading h2 { margin-bottom: 0; }
What Does This Styling Do?
- The :root rule establishes the page typography plus the background palette.
- The .app-shell rule keeps the scheduler centered inside a readable width.
- The .panel rule separates the form from the schedule with white surfaces.
- The .task-form rule aligns the controlled fields in a responsive grid.
- Save src/index.css.
- Switch back to the TaskFlow browser tab.
You now see a centered app shell with a large TaskFlow heading. The form sits inside a white panel with aligned controls.
Still Seeing the Plain Page?
- Confirm that src/main.jsx still imports ./index.css.
- Confirm that you saved src/index.css.
- Check that each selector starts with the same class name used in src/App.jsx.
Help me find why the TaskFlow styles are missing.
The second styling layer turns each task into a card. It also gives priority plus status values distinct badges.
- Add this code at the end of src/index.css.
/* Turn each submitted task into a readable schedule card. */
.task-list { display: grid; gap: 0.85rem; margin-top: 1rem; }
.task-card { display: flex; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid #dbe2f1; border-radius: 0.85rem; }
.task-card h3 { margin: 0.7rem 0 0.25rem; }
.task-card p { margin: 0; color: #64748b; }
.badge { border-radius: 999px; padding: 0.25rem 0.6rem; font-size: 0.75rem; font-weight: 800; text-transform: capitalize; }
.badge.low { color: #166534; background: #dcfce7; }
.badge.medium { color: #92400e; background: #fef3c7; }
.badge.high { color: #991b1b; background: #fee2e2; }
.badge.status { color: #3730a3; background: #e0e7ff; }
.secondary { color: #334155; background: #e2e8f0; }
.danger { color: #b91c1c; background: #fee2e2; }
.message { margin: 1rem 0 0; border-radius: 0.75rem; padding: 0.8rem; color: #475569; background: #f8fafc; }
.message.error { color: #991b1b; background: #fee2e2; }
@media (max-width: 760px) {
.task-form { grid-template-columns: 1fr; }
.task-card { flex-direction: column; }
.task-actions { flex-wrap: wrap; }
}
How Do the Cards Work?
- The .task-list rule creates consistent space between submitted tasks.
- The .task-card rule separates task details from the status action area.
- The priority selectors assign a distinct color to low, medium, and high values.
- The media query stacks the form plus each card on narrow screens.
- Save src/index.css.
- Switch back to the TaskFlow browser tab.
- Enter Style review in the Title field.
- Enter 2026-12-18 in the Due date field.
- Select Medium from the Priority field.
- Click Add task.
Your new task appears inside a bordered card. You can see a medium-priority badge plus a planned-status badge.
Do the Badges Look Plain?
- Confirm that the priority span includes both badge plus the current priority value.
- Confirm that the status span uses className="badge status".
- Check that the new rules appear after the foundation rules in src/index.css.
Help me debug the TaskFlow task card styles.
✔️ Awesome, I've got everything!
Your scheduler now has a responsive form, styled task cards, status badges, and a clear action area.
ⓧ I'd like to double check the full code
/* Establish the page foundation plus the responsive form layout. */
:root { font-family: Inter, system-ui, sans-serif; color: #172033; background: #eef2ff; font-synthesis: none; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, select { font: inherit; }
button { border: 0; border-radius: 0.65rem; padding: 0.7rem 1rem; color: white; background: #4f46e5; cursor: pointer; }
button:hover { filter: brightness(0.95); }
.app-shell { width: min(960px, calc(100% - 2rem)); margin: 0 auto; padding: 2.5rem 0 4rem; }
.hero { margin-bottom: 1.5rem; }
.hero h1 { margin: 0; font-size: clamp(2.4rem, 7vw, 4.5rem); }
.hero p { max-width: 650px; }
.eyebrow { margin-bottom: 0.35rem; color: #4f46e5; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
.panel { margin-top: 1rem; padding: 1.25rem; border: 1px solid #dbe2f1; border-radius: 1rem; background: white; box-shadow: 0 14px 40px rgba(50, 60, 100, 0.08); }
.panel h2 { margin-top: 0; }
.task-form { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem; align-items: end; }
label { display: grid; gap: 0.4rem; color: #475569; font-size: 0.9rem; font-weight: 700; }
input, select { width: 100%; border: 1px solid #cbd5e1; border-radius: 0.65rem; padding: 0.72rem; color: #172033; background: white; }
.form-actions, .task-actions, .badges, .section-heading { display: flex; gap: 0.6rem; align-items: center; }
.form-actions { grid-column: 1 / -1; }
.section-heading { justify-content: space-between; }
.section-heading h2 { margin-bottom: 0; }
/* Turn each submitted task into a readable schedule card. */
.task-list { display: grid; gap: 0.85rem; margin-top: 1rem; }
.task-card { display: flex; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid #dbe2f1; border-radius: 0.85rem; }
.task-card h3 { margin: 0.7rem 0 0.25rem; }
.task-card p { margin: 0; color: #64748b; }
.badge { border-radius: 999px; padding: 0.25rem 0.6rem; font-size: 0.75rem; font-weight: 800; text-transform: capitalize; }
.badge.low { color: #166534; background: #dcfce7; }
.badge.medium { color: #92400e; background: #fef3c7; }
.badge.high { color: #991b1b; background: #fee2e2; }
.badge.status { color: #3730a3; background: #e0e7ff; }
.secondary { color: #334155; background: #e2e8f0; }
.danger { color: #b91c1c; background: #fee2e2; }
.message { margin: 1rem 0 0; border-radius: 0.75rem; padding: 0.8rem; color: #475569; background: #f8fafc; }
.message.error { color: #991b1b; background: #fee2e2; }
@media (max-width: 760px) {
.task-form { grid-template-columns: 1fr; }
.task-card { flex-direction: column; }
.task-actions { flex-wrap: wrap; }
}
Use this complete stylesheet to compare the page foundation, form layout, task cards, badges, messages, and mobile rules.
Expose the refresh failure
The scheduler now behaves like a working application during one browser session. A refresh tests whether the submitted data exists anywhere beyond the current component state.
- Return to the integrated PowerShell terminal that is running Vite.
- Confirm that the development server is still running.
- Switch back to the TaskFlow browser tab.
- Enter Test refresh persistence in the Title field.
- Enter 2026-12-20 in the Due date field.
- Select High from the Priority field.
- Click Add task.
The task appears immediately as another styled card. The total count increases without a page reload.
Before you refresh, do you think the new task can survive after React creates the component again?
- Refresh the TaskFlow browser tab.
The submitted task disappears. The empty-state message returns because useState([]) creates a fresh in-memory task array when the page loads.
What Does the Disappearing Task Prove?
The form plus task cards work correctly during a browser session. The refresh proves that component state provides temporary memory.
No server or database stores the task yet. This visible gap gives the persistence layer a specific problem to solve.
That disappearing task has done its job. Next, you will give TaskFlow a REST API plus durable storage so the schedule can survive a refresh.
Add the Persistent REST API
The refresh test exposed the limit of React state. Browser memory cannot preserve a schedule between visits.
An Express REST API now moves each task into MongoDB Atlas. Mongoose gives every saved record a validated shape.
In this step, get ready to:
- Configure Atlas access for the TaskFlow API.
- Install the backend packages for Express and Mongoose.
- Build a persistent CRUD API that returns Atlas data as JSON.
Configure MongoDB Atlas access
Atlas requires a database cluster plus two security checks before your API can connect. Your current IP address needs access. Your application also needs its own database user.
- Sign in to MongoDB Atlas in your browser.
- Select the Atlas project you want to use for TaskFlow.
- Check whether the project already contains an M0 Free cluster.
✔️ I have an M0 Free cluster
Your existing Free cluster can store TaskFlow data. Continue with the access-list configuration below.
ⓧ I need an M0 Free cluster
- Start the Atlas cluster creation flow.
- Select M0 as the cluster tier.
- Create the Free cluster.
Free cluster provisioning takes less than 15 seconds. Atlas enables the connection controls when the cluster is ready.
- Select Security in the Atlas sidebar.
- Select Database & Network Access.
- Select IP Access List.
- Click Add IP Address.
- Add your current IP address to the access list.
- Click Confirm.
Atlas now accepts database connections from your current network. This keeps the cluster restricted to an address you trust.
Database credentials deserve care. You will store the password privately before the connection string leaves Atlas.
- Select Database Users.
- Click Add New Database User.
- Select Password as the authentication method.
- Enter a username for the TaskFlow application.
- Enter a strong password for the database user.
- Store the username plus password in your password manager.
- Click Add User.
Your application now has a dedicated database identity. The next action turns that identity into a connection string.
- Return to Clusters.
- Click Connect for your Free cluster.
- Select Standard Connection.
- Select Drivers.
- Choose the Node.js driver from the driver list.
- Copy the application connection string.
- Replace its password placeholder with your database-user password.
- Keep the completed connection string private for the next substep.
What Controls Atlas Access?
The IP access list controls which networks can reach the cluster. The database user controls which application identity can sign in.
The connection string combines the cluster address with that identity. Anyone who obtains the completed string may be able to access your data.
Install the backend packages
The backend needs Express for HTTP routes. It needs Mongoose for database models. The .env file keeps the Atlas connection string in an environment variable.
- Switch back to Visual Studio Code from earlier.
- Select package.json in the Explorer sidebar.
- Replace the file contents with this updated manifest:
{"name":"taskflow-mern","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build","server":"node server/server.js"},"dependencies":{"dotenv":"18.0.9","express":"5.3.0","mongoose":"9.11.4","react":"19.3.1","react-dom":"19.3.1"},"devDependencies":{"@vitejs/plugin-react":"6.1.2","vite":"8.3.5"}}
What Does This Manifest Add?
- Express 5.3.0 provides the server plus REST routing methods.
- Mongoose 9.11.4 provides schemas plus MongoDB document operations.
- dotenv 18.0.9 loads the private Atlas connection string from .env.
- Save package.json.
- Install every package in the updated manifest by running this command in the integrated PowerShell terminal:
npm install
What Does This Command Do?
The command reads package.json. It installs the listed dependencies into the TaskFlow workspace.
That is the backend toolchain ready. PowerShell returns to its prompt after npm finishes installing the three new packages.
Does the Installation Fail?
Confirm that the integrated terminal is still inside the taskflow folder. Confirm that package.json contains valid JSON with no trailing comma.
Check your network connection if npm cannot download a package.
Help me diagnose the TaskFlow npm installation.
✔️ Awesome, I've got everything!
Your saved manifest now includes the frontend packages plus the three backend dependencies.
ⓧ I'd like to double check the full code
{"name":"taskflow-mern","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build","server":"node server/server.js"},"dependencies":{"dotenv":"18.0.9","express":"5.3.0","mongoose":"9.11.4","react":"19.3.1","react-dom":"19.3.1"},"devDependencies":{"@vitejs/plugin-react":"6.1.2","vite":"8.3.5"}}
The Atlas connection string belongs outside your JavaScript source. The server reads it from process.env.ATLAS_URI when it starts.
- Select the taskflow folder in the VS Code Explorer sidebar.
- Create a file named .env inside that folder.
You can now see .env beside package.json in the Explorer sidebar.
- Add the connection-string template by pasting this line into .env:
ATLAS_URI=your-mongodb-atlas-connection-string-here
How Does the Environment Variable Work?
dotenv loads the value after ATLAS_URI= into process.env.ATLAS_URI. The JavaScript file can use the connection without containing the credential itself.
- Replace your-mongodb-atlas-connection-string-here with your completed Atlas connection string.
- Save .env.
Your real connection string now stays in the local environment file. Keep .env closed in any screenshots you share.
Is the Connection String Incomplete?
Confirm that the line begins with ATLAS_URI=. Confirm that the copied string includes your Atlas database username.
Replace the password placeholder inside the string with the database-user password you stored earlier.
Help me check my Atlas environment setup without sharing my credential.
Build the persistent REST API
The server needs a model before it can accept task data. That model enforces required fields plus the allowed priority and status values.
Why Mongoose for This API?
The native MongoDB driver provides lower-level database access. Mongoose keeps this project focused on CRUD behavior through schema validation.
Required fields reject incomplete tasks. Enums reject unsupported priority or status values before they reach the collection.
- Create a folder named server inside the taskflow folder using the Explorer new-folder control.
The server folder now appears beside src in the Explorer sidebar.
- Select the new server folder.
- Create a file named server.js inside it.
The empty server/server.js file is ready for the API foundation.
- Add the imports plus the validated Task model by pasting this code into server/server.js:
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
const app = express();
const port = 3000;
if (!process.env.ATLAS_URI) {
throw new Error('ATLAS_URI is missing from .env');
}
const taskSchema = new mongoose.Schema(
{
title: { type: String, required: true },
dueDate: { type: Date, required: true },
priority: {
type: String,
enum: ['low', 'medium', 'high'],
default: 'medium',
},
status: {
type: String,
enum: ['planned', 'completed'],
default: 'planned',
},
},
{ timestamps: true },
);
const Task = mongoose.model('Task', taskSchema);
What Does the API Foundation Do?
- The imports load the environment file plus the server and database libraries.
- The environment check stops the server when ATLAS_URI is unavailable.
- The schema requires a title plus due date. It limits priority and status to the values supported by TaskFlow.
- The Task model provides document operations for the inferred tasks collection.
- Save server/server.js.
- Check that the imports plus schema load cleanly by running this command:
npm run server
What Does This Check Prove?
npm runs the server script from package.json. A clean return to the PowerShell prompt proves that the dependencies loaded and the schema compiled.
Does the Foundation Stop With an Error?
Confirm that .env sits directly inside taskflow. Confirm that its key is spelled ATLAS_URI.
Confirm that npm finished installing the three backend dependencies.
Help me diagnose the TaskFlow server foundation.
The model defines the database shape. The first routes now expose health, read, and create operations over HTTP.
- Switch back to server/server.js.
- Add the JSON middleware plus the health, read, and create routes below the Task model:
app.use(express.json());
app.get('/api/health', (request, response) => {
response.json({ message: 'TaskFlow API is running' });
});
app.get('/api/tasks', async (request, response) => {
const tasks = await Task.find({});
response.json(tasks);
});
app.post('/api/tasks', async (request, response) => {
const task = await Task.create(request.body);
response.status(201).json(task);
});
How Do These Routes Handle Tasks?
- The JSON middleware parses request bodies before a route reads them.
- The health route returns a small response that confirms the API process is running.
- The read route returns every stored Task document as JSON.
- The create route validates the request body through Mongoose. A successful request returns the saved document with status 201.
- Save server/server.js.
- Check that the new middleware plus routes load cleanly by running this command:
npm run server
What Does This Route Check Prove?
PowerShell returns to its prompt without a JavaScript stack trace. The middleware plus three route handlers are valid enough for Node.js to load.
Do the Routes Fail to Load?
Confirm that each route closes with the same braces plus parentheses shown above. Confirm that the route paths begin with /api/.
Help me find the syntax problem in my first TaskFlow routes.
The remaining resource routes work with one task at a time. They use the document ID in the URL to update or delete the matching record.
- Switch back to server/server.js.
- Add the update plus delete routes below the create route:
app.patch('/api/tasks/:id', async (request, response) => {
const task = await Task.findById(request.params.id);
if (!task) {
return response.status(404).json({ message: 'Task not found' });
}
for (const field of ['title', 'dueDate', 'priority', 'status']) {
if (request.body[field] !== undefined) {
task[field] = request.body[field];
}
}
await task.save();
return response.json(task);
});
app.delete('/api/tasks/:id', async (request, response) => {
const task = await Task.findById(request.params.id);
if (!task) {
return response.status(404).json({ message: 'Task not found' });
}
await task.deleteOne();
return response.json(task);
});
How Do Update and Delete Work?
- The update route finds one task from the URL parameter. It copies only the four supported fields before saving the validated document.
- The delete route finds the same kind of resource. It removes that document from the collection.
- Both routes return status 404 when the requested task does not exist.
- Save server/server.js.
- Check that the resource-specific routes load cleanly by running this command:
npm run server
What Does This CRUD Check Prove?
PowerShell returns to its prompt without a syntax failure. Node.js can now load all four task-operation routes.
Does a Resource Route Break the File?
Check the closing characters around each route handler. Confirm that request.params.id uses the same lowercase spelling in both routes.
Help me compare my PATCH and DELETE routes with the expected TaskFlow structure.
The final server section connects the API to the taskflow database. It also turns validation failures into visible JSON responses.
- Switch back to server/server.js.
- Add static hosting plus error handling and server startup below the delete route:
app.use(express.static('dist'));
app.use((error, request, response, next) => {
const isClientError =
error.name === 'ValidationError' || error.name === 'CastError';
const statusCode = isClientError ? 400 : 500;
const message = isClientError ? error.message : 'Unexpected server error';
console.error(error);
response.status(statusCode).json({ message });
});
await mongoose.connect(process.env.ATLAS_URI, { dbName: 'taskflow' });
app.listen(port, () => {
console.log('TaskFlow is running at http://localhost:3000');
});
How Does the Server Start Safely?
- Static middleware serves the generated dist build when that build exists.
- The four-argument middleware converts Mongoose validation plus ID-casting failures into status 400 responses.
- The connection selects the taskflow database before Express begins listening.
- The listener keeps the API available on port 3000.
- Save server/server.js.
Before you start the server, what do you expect /api/tasks to return from a new Atlas collection?
- Start the completed Express server by running this command:
npm run server
What Does the Startup Command Do?
npm launches server/server.js through the saved package script. The process loads .env before connecting Mongoose to Atlas.
PowerShell shows TaskFlow is running at http://localhost:3000. The process stays active because Express is listening for requests.
- Switch back to your browser.
- Enter http://localhost:3000/api/tasks in the address bar.
- Load the API route.
You will see an empty JSON array if the collection has no tasks. You may see stored task objects if this Atlas collection already contains data.
Does the API Route Fail to Load?
Confirm that PowerShell still shows the running server process. Confirm that your current IP address remains in the Atlas access list.
Check the Atlas database username plus password in your private connection string. Keep the credential out of screenshots and help messages.
Help me diagnose the Atlas connection without exposing my credential.
✔️ Awesome, I've got everything!
Your server now connects to Atlas and exposes health plus complete CRUD routes for the Task resource.
ⓧ I'd like to double check the full code
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
const app = express();
const port = 3000;
if (!process.env.ATLAS_URI) {
throw new Error('ATLAS_URI is missing from .env');
}
const taskSchema = new mongoose.Schema(
{
title: { type: String, required: true },
dueDate: { type: Date, required: true },
priority: {
type: String,
enum: ['low', 'medium', 'high'],
default: 'medium',
},
status: {
type: String,
enum: ['planned', 'completed'],
default: 'planned',
},
},
{ timestamps: true },
);
const Task = mongoose.model('Task', taskSchema);
app.use(express.json());
app.get('/api/health', (request, response) => {
response.json({ message: 'TaskFlow API is running' });
});
app.get('/api/tasks', async (request, response) => {
const tasks = await Task.find({});
response.json(tasks);
});
app.post('/api/tasks', async (request, response) => {
const task = await Task.create(request.body);
response.status(201).json(task);
});
app.patch('/api/tasks/:id', async (request, response) => {
const task = await Task.findById(request.params.id);
if (!task) {
return response.status(404).json({ message: 'Task not found' });
}
for (const field of ['title', 'dueDate', 'priority', 'status']) {
if (request.body[field] !== undefined) {
task[field] = request.body[field];
}
}
await task.save();
return response.json(task);
});
app.delete('/api/tasks/:id', async (request, response) => {
const task = await Task.findById(request.params.id);
if (!task) {
return response.status(404).json({ message: 'Task not found' });
}
await task.deleteOne();
return response.json(task);
});
app.use(express.static('dist'));
app.use((error, request, response, next) => {
const isClientError =
error.name === 'ValidationError' || error.name === 'CastError';
const statusCode = isClientError ? 400 : 500;
const message = isClientError ? error.message : 'Unexpected server error';
console.error(error);
response.status(statusCode).json({ message });
});
await mongoose.connect(process.env.ATLAS_URI, { dbName: 'taskflow' });
app.listen(port, () => {
console.log('TaskFlow is running at http://localhost:3000');
});
Your persistent API is live and returning Atlas data. Next, you will replace the temporary React task array with requests to these routes.
Connect React to MongoDB Data
Your Express REST API already stores task records in MongoDB Atlas. TaskFlow now needs to use that persistent data instead of keeping every task inside the browser.
A refresh currently clears the schedule because React rebuilds its temporary state. You will connect the initial load plus the create form to /api/tasks so the database becomes the source of truth.
In this step, get ready to:
- Load stored tasks from the REST API.
- Create MongoDB-backed tasks from the controlled form.
- Prove that a saved task survives a browser refresh.
Load tasks from the API
The browser Fetch API requests the current task collection after the component appears. A loading state covers the request period while an error state makes a failed response visible.
- Select src/App.jsx in the VS Code Explorer sidebar.
- Replace the existing React import with the first line below.
- Add the constants plus helper functions below the import by copying this code:
import { useEffect, useState } from 'react';
const API_URL = '/api/tasks';
const EMPTY_FORM = {
title: '',
dueDate: '',
priority: 'medium',
status: 'planned',
};
function sortByDueDate(items) {
return [...items].sort((a, b) => a.dueDate.localeCompare(b.dueDate));
}
async function readResponse(response) {
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Request failed');
}
return data;
}
function formatDate(value) {
const localDate = new Date(`${value.slice(0, 10)}T00:00:00`);
return localDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
What Do These Helpers Do?
- The API_URL constant keeps every task request pointed at one REST resource.
- The EMPTY_FORM object provides consistent starting values for a planned task.
- The sortByDueDate() helper returns a new array ordered by each task's due date.
- The readResponse() helper parses JSON before turning unsuccessful HTTP responses into visible errors.
- The formatDate() helper converts a stored date into readable browser text.
- Save src/App.jsx.
- Return to the integrated PowerShell terminal from earlier.
- Check that the helper code still compiles by running this command:
npm run build
Vite completes the production build. Your existing scheduler still compiles with the new API helpers in place.
Does the Build Fail?
Check that the React import contains both useEffect plus useState. Confirm that every helper sits above the App component.
Compare the braces around EMPTY_FORM plus formatDate() with the snippet.
Help me fix the build after adding the API helpers to src/App.jsx.
A React effect synchronizes the component with an external system. Here it performs one initial request while protecting the component from late state updates during cleanup.
- Find the state declarations at the beginning of the App component.
- Replace those declarations with this state block:
const [tasks, setTasks] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [editingId, setEditingId] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
What Does This State Track?
- The tasks array holds records returned by MongoDB.
- The form object keeps every controlled field together.
- The editingId value starts empty because no task is being edited.
- The loading plus error values control request feedback.
- Add this effect directly below the state declarations:
useEffect(() => {
let ignore = false;
async function loadTasks() {
try {
setLoading(true);
setError('');
const response = await fetch(API_URL);
const data = await readResponse(response);
if (!ignore) {
setTasks(sortByDueDate(data));
}
} catch (requestError) {
if (!ignore) {
setError(requestError.message);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
loadTasks();
return () => {
ignore = true;
};
}, []);
How Does the Initial Load Work?
- The empty dependency array runs this effect after the component's initial render.
- The request reads /api/tasks through the same origin that serves the finished React app.
- A successful response replaces the empty task array with due-date-ordered database records.
- The cleanup flag prevents an old request from updating a component that has already been removed.
- Save src/App.jsx.
- Return to the terminal running the Express server.
- Stop the current server by pressing Ctrl+C.
- Rebuild the client plus restart the server by running these commands:
npm run build
npm run server
Why Build Before Starting Express?
The build command writes the React client into dist. Express serves that directory from the same http://localhost:3000 origin used by the API.
This same-origin arrangement lets /api/tasks reach Express without another cross-origin dependency.
- Switch back to the browser from earlier.
- Enter http://localhost:3000 in the address bar.
- Load the page.
You will see any task records already stored in Atlas. An empty collection leaves the scheduler ready for its first persistent task.
Does the Task Load Fail?
Confirm that the Express terminal is still running. Check that the browser uses port 3000 instead of the earlier Vite development port.
Open http://localhost:3000/api/tasks in another browser tab. A JSON response confirms that the API remains reachable.
Help me diagnose the initial GET request from React to /api/tasks.
Create tasks through the API
A controlled form already keeps its field values in React state. The submit handler now sends those values to the API so Mongoose can validate the task before MongoDB stores it.
- Switch back to src/App.jsx in the VS Code Explorer sidebar.
- Add this reset helper below updateField().
function resetForm() {
setForm(EMPTY_FORM);
setEditingId(null);
}
Why Reset the Form?
The API returns the saved task before the form clears. Resetting afterward gives the learner a clean form while preserving the database record in the task list.
- Find the temporary handleSubmit() function that appends a local task.
- Replace the entire function with this API-backed version:
async function handleSubmit(event) {
event.preventDefault();
setError('');
try {
const response = await fetch(
editingId ? `${API_URL}/${editingId}` : API_URL,
{
method: editingId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
},
);
const savedTask = await readResponse(response);
setTasks((current) =>
sortByDueDate(
editingId
? current.map((task) =>
task._id === savedTask._id ? savedTask : task,
)
: [...current, savedTask],
),
);
resetForm();
} catch (requestError) {
setError(requestError.message);
}
}
How Does the Create Request Work?
- The empty editingId selects POST plus the collection URL for every submission in this step.
- The JSON header tells Express how to parse the serialized form body.
- The returned task includes the MongoDB identifier plus the validated field values.
- The state update adds that returned record before sorting the complete array by due date.
- Save src/App.jsx.
- Return to the Express terminal.
- Stop the running server by pressing Ctrl+C.
- Build the updated client plus restart Express by running these commands:
npm run build
npm run server
- Return to http://localhost:3000 in the browser.
- Enter Prepare sprint demo in the Title field.
- Choose a future date in the Due date field.
- Select High from the Priority field.
- Click Add task.
The new task appears as soon as the API returns its stored MongoDB document. That create form now writes through the full browser-to-API-to-database path.
Does the Task Fail to Appear?
Check the message shown inside TaskFlow. A validation response usually points to a missing title or due date.
Confirm that handleSubmit passes JSON.stringify(form) as the request body. Confirm that the header uses Content-Type with the JSON media type.
Help me diagnose the POST request from the TaskFlow form.
The final interface needs request feedback plus MongoDB-aware task cards. The status field also keeps every value in EMPTY_FORM available through the form.
- Find the Priority label inside the form.
- Add this status label directly below it:
<label>
Status
<select name="status" value={form.status} onChange={updateField}>
<option value="planned">Planned</option>
<option value="completed">Completed</option>
</select>
</label>
What Does the Status Field Control?
The select keeps form.status synchronized with the visible choice. New tasks begin as planned because that is the default in EMPTY_FORM.
- Find the task-list section below the form.
- Replace its feedback plus task rendering with this block:
{error && <p className="message error">{error}</p>}
{loading && <p className="message">Loading tasks...</p>}
{!loading && tasks.length === 0 && (
<p className="message">No tasks yet. Schedule the first one above.</p>
)}
<div className="task-list">
{tasks.map((task) => (
<article
className={`task-card ${task.status === 'completed' ? 'completed' : ''}`}
key={task._id}
>
<div>
<div className="badges">
<span className={`badge ${task.priority}`}>{task.priority}</span>
<span className="badge status">{task.status}</span>
</div>
<h3>{task.title}</h3>
<p>Due {formatDate(task.dueDate)}</p>
</div>
</article>
))}
</div>
What Changes in the Task List?
- The feedback messages distinguish loading from an empty database or a failed request.
- Each task card uses its MongoDB _id as the React key.
- The status badge reflects the value returned by the API.
- The due date passes through formatDate() before reaching the page.
Prove tasks survive a refresh
Before you rebuild, do you expect the task you created to remain after a full browser refresh?
✔️ Awesome, I've got everything!
Great. Save src/App.jsx before producing the final build.
ⓧ I'd like to double check the full code
import { useEffect, useState } from 'react';
const API_URL = '/api/tasks';
const EMPTY_FORM = {
title: '',
dueDate: '',
priority: 'medium',
status: 'planned',
};
function sortByDueDate(items) {
return [...items].sort((a, b) => a.dueDate.localeCompare(b.dueDate));
}
async function readResponse(response) {
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Request failed');
}
return data;
}
function formatDate(value) {
const localDate = new Date(`${value.slice(0, 10)}T00:00:00`);
return localDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export default function App() {
const [tasks, setTasks] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [editingId, setEditingId] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let ignore = false;
async function loadTasks() {
try {
setLoading(true);
setError('');
const response = await fetch(API_URL);
const data = await readResponse(response);
if (!ignore) {
setTasks(sortByDueDate(data));
}
} catch (requestError) {
if (!ignore) {
setError(requestError.message);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
loadTasks();
return () => {
ignore = true;
};
}, []);
function updateField(event) {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: value }));
}
function resetForm() {
setForm(EMPTY_FORM);
setEditingId(null);
}
async function handleSubmit(event) {
event.preventDefault();
setError('');
try {
const response = await fetch(
editingId ? `${API_URL}/${editingId}` : API_URL,
{
method: editingId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
},
);
const savedTask = await readResponse(response);
setTasks((current) =>
sortByDueDate(
editingId
? current.map((task) =>
task._id === savedTask._id ? savedTask : task,
)
: [...current, savedTask],
),
);
resetForm();
} catch (requestError) {
setError(requestError.message);
}
}
return (
<main className="app-shell">
<header className="hero">
<p className="eyebrow">MERN REST API project</p>
<h1>TaskFlow</h1>
<p>Schedule work, persist it in MongoDB, and manage every CRUD action.</p>
</header>
<section className="panel">
<h2>{editingId ? 'Edit task' : 'Schedule a task'}</h2>
<form className="task-form" onSubmit={handleSubmit}>
<label>
Title
<input
name="title"
value={form.title}
onChange={updateField}
placeholder="Prepare sprint demo"
required
/>
</label>
<label>
Due date
<input
type="date"
name="dueDate"
value={form.dueDate}
onChange={updateField}
required
/>
</label>
<label>
Priority
<select name="priority" value={form.priority} onChange={updateField}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<label>
Status
<select name="status" value={form.status} onChange={updateField}>
<option value="planned">Planned</option>
<option value="completed">Completed</option>
</select>
</label>
<div className="form-actions">
<button type="submit">{editingId ? 'Save changes' : 'Add task'}</button>
{editingId && (
<button type="button" className="secondary" onClick={resetForm}>
Cancel
</button>
)}
</div>
</form>
</section>
<section className="panel">
<div className="section-heading">
<h2>Upcoming tasks</h2>
<span>{tasks.length} total</span>
</div>
{error && <p className="message error">{error}</p>}
{loading && <p className="message">Loading tasks...</p>}
{!loading && tasks.length === 0 && (
<p className="message">No tasks yet. Schedule the first one above.</p>
)}
<div className="task-list">
{tasks.map((task) => (
<article
className={`task-card ${task.status === 'completed' ? 'completed' : ''}`}
key={task._id}
>
<div>
<div className="badges">
<span className={`badge ${task.priority}`}>{task.priority}</span>
<span className="badge status">{task.status}</span>
</div>
<h3>{task.title}</h3>
<p>Due {formatDate(task.dueDate)}</p>
</div>
</article>
))}
</div>
</section>
</main>
);
}
- Save src/App.jsx.
- Return to the Express terminal.
- Stop the running server by pressing Ctrl+C.
- Create the final production build plus restart Express by running these commands:
npm run build
npm run server
- Refresh http://localhost:3000 in the browser.
The task you created remains visible after the refresh. React has loaded the stored document from MongoDB through GET /api/tasks.
- Schedule another task through the form.
- Refresh the browser again.
Both tasks return in due-date order. That is the persistence gap closed because TaskFlow now reads plus creates records through its API.
Does the Task Disappear After Refresh?
Confirm that you loaded the Express-hosted app at port 3000. Check the terminal for a database or request failure after the refresh.
Open http://localhost:3000/api/tasks to confirm the saved document appears in the JSON response. If it appears there, compare the effect plus task rendering with the full-code reference.
Help me trace why a MongoDB task disappears from TaskFlow after refresh.
Your scheduler now creates plus reloads persistent tasks. Next, you will add edits, status changes, and deletions to complete the CRUD flow.
Complete Update and Delete Operations
Your React scheduler already creates tasks through a REST API. Those saved tasks still have no browser controls for changing or removing them.
You will complete the CRUD flow with edit, status-toggle, and delete requests. Each successful response will update the page immediately before a refresh proves that MongoDB kept the change.
In this step, get ready to:
- Reuse the task form to edit existing records.
- Add controls for completing, reopening, and deleting tasks.
- Demonstrate every CRUD operation across browser refreshes.
Add edit mode to the shared form
A controlled form can serve both create mode and edit mode. The selected task ID determines which mode the form uses.
- Return to src/App.jsx in the VS Code Explorer sidebar.
- Find the updateField function.
- Add this reset helper directly below updateField:
function resetForm() {
setForm(EMPTY_FORM);
setEditingId(null);
}
What Does This Helper Do?
- The setForm call restores the blank task values from EMPTY_FORM.
- The setEditingId call clears the selected task. This returns the shared form to create mode.
- Save src/App.jsx.
- Confirm that resetForm sits between updateField and handleSubmit.
Is the Reset Helper in the Wrong Place?
Keep resetForm inside the App component. Place it after the closing brace of updateField.
Check that the helper has its own closing brace before handleSubmit begins.
Help me place the reset helper correctly.
Form submission now needs two request paths. A new task uses POST. A selected task uses PATCH.
- Find the existing handleSubmit function in src/App.jsx.
- Replace the complete function with this version:
async function handleSubmit(event) {
event.preventDefault();
setError('');
try {
const response = await fetch(
editingId ? `${API_URL}/${editingId}` : API_URL,
{
method: editingId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
},
);
const savedTask = await readResponse(response);
setTasks((current) =>
sortByDueDate(
editingId
? current.map((task) =>
task._id === savedTask._id ? savedTask : task,
)
: [...current, savedTask],
),
);
resetForm();
} catch (requestError) {
setError(requestError.message);
}
}
How Does Submission Choose an Operation?
- The request URL includes editingId when the learner is editing a stored task.
- The request method switches between PATCH and POST based on the same value.
- The state update replaces the matching task after an edit. New tasks still join the existing array.
- The final resetForm call clears the form after either operation succeeds.
- Save src/App.jsx.
- Confirm that the updated client still compiles by running this command in an available integrated PowerShell terminal:
npm run build
What Does This Build Check?
The build parses the updated component. It also creates a fresh production bundle in dist.
The terminal completes the production build. This proves the create and edit request branches compile together.
Does the Build Fail?
- Check that both template literals use backticks around the URL.
- Check that editingId matches the state variable near the top of App.
- Check that every parenthesis in the setTasks call has a matching closing parenthesis.
Help me diagnose the edit submission build.
Entering edit mode copies one stored task into the form. The due date is trimmed to the date format expected by the existing input.
- Find the closing brace of handleSubmit.
- Add this function directly below handleSubmit:
function startEdit(task) {
setEditingId(task._id);
setForm({
title: task.title,
dueDate: task.dueDate.slice(0, 10),
priority: task.priority,
status: task.status,
});
}
How Does Edit Mode Start?
- The selected MongoDB document ID becomes editingId.
- The stored field values replace the blank form state. The learner can now change the existing task.
- Save src/App.jsx.
- Confirm that startEdit appears before the status-toggle function area.
- Find the heading at the top of the task form.
- Replace that heading with this mode-aware line:
<h2>{editingId ? 'Edit task' : 'Schedule a task'}</h2>
What Changes in the Heading?
The heading reads Edit task when a task is selected. It returns to Schedule a task when editingId is empty.
- Find the existing submit button inside the form action area.
- Replace that action area with this block:
<div className="form-actions">
<button type="submit">{editingId ? 'Save changes' : 'Add task'}</button>
{editingId && (
<button type="button" className="secondary" onClick={resetForm}>
Cancel
</button>
)}
</div>
How Does the Form Show Its Mode?
- The submit label changes to Save changes during an edit.
- The Cancel button appears only when editingId contains a selected task ID.
- The cancel action calls resetForm without submitting the form.
- Save src/App.jsx.
- Confirm the edit-mode UI compiles by running this command:
npm run build
What Does This Build Prove?
The production build now includes the shared create and edit form. A successful result confirms that the conditional labels compile.
The terminal completes the build. Your form can now represent both create mode and edit mode.
Add status and delete actions
The remaining HTTP operations target a specific task URL. Status changes use PATCH. Removal uses DELETE.
- Find the closing brace of startEdit.
- Add this status-toggle function directly below startEdit:
async function toggleTask(task) {
setError('');
const nextStatus = task.status === 'completed' ? 'planned' : 'completed';
try {
const response = await fetch(`${API_URL}/${task._id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: nextStatus }),
});
const savedTask = await readResponse(response);
setTasks((current) =>
sortByDueDate(
current.map((item) =>
item._id === savedTask._id ? savedTask : item,
),
),
);
} catch (requestError) {
setError(requestError.message);
}
}
How Does the Toggle Stay Persistent?
- The next status depends on the task's current stored status.
- The request sends only the changed status field to the task-specific API URL.
- The returned task replaces the matching item in state. Sorting preserves the due-date order.
- A failed request stores its message in error for the existing error panel.
- Save src/App.jsx.
- Confirm that toggleTask remains inside the App component.
Deletion waits for the API response before removing the task from browser state. This keeps the screen aligned with the database result.
- Find the closing brace of toggleTask.
- Add this delete function directly below toggleTask:
async function deleteTask(taskId) {
setError('');
try {
const response = await fetch(`${API_URL}/${taskId}`, {
method: 'DELETE',
});
await readResponse(response);
setTasks((current) => current.filter((task) => task._id !== taskId));
if (editingId === taskId) {
resetForm();
}
} catch (requestError) {
setError(requestError.message);
}
}
How Does Deletion Reconcile State?
- The API must return a successful response before the task disappears from the page.
- The state filter keeps every task whose ID differs from the deleted ID.
- Deleting the task currently being edited also resets the form.
- Save src/App.jsx.
- Confirm both new request handlers compile by running this command:
npm run build
What Does This Handler Build Check?
The build checks both task-specific request functions. It also verifies the state update expressions used after successful responses.
The terminal completes the build. Your component now has persistent status and delete behavior ready for browser controls.
Do the Request Handlers Fail to Compile?
- Check that task._id appears in the toggle request URL.
- Check that taskId appears in the delete request URL.
- Check that both functions close before the component returns its JSX.
Help me diagnose the task action handlers.
Each task card now needs controls that call the new handlers. The toggle label reflects the task's current status.
- Find the action area inside each rendered task card.
- Replace its contents with this complete action block:
<div className="task-actions">
<button className="secondary" onClick={() => toggleTask(task)}>
{task.status === 'completed' ? 'Reopen' : 'Complete'}
</button>
<button className="secondary" onClick={() => startEdit(task)}>
Edit
</button>
<button className="danger" onClick={() => deleteTask(task._id)}>
Delete
</button>
</div>
What Do the Card Actions Control?
- The first button completes a planned task. It reopens a completed task.
- The second button passes the full task into startEdit.
- The final button passes the MongoDB document ID into deleteTask.
- Save src/App.jsx.
- Compare your completed client with the full file below.
✔️ Awesome, I've got everything!
Great. Your saved client now contains every pre-filter CRUD interaction.
ⓧ I'd like to double check the full code
import { useEffect, useState } from 'react';
const API_URL = '/api/tasks';
const EMPTY_FORM = {
title: '',
dueDate: '',
priority: 'medium',
status: 'planned',
};
function sortByDueDate(items) {
return [...items].sort((a, b) => a.dueDate.localeCompare(b.dueDate));
}
async function readResponse(response) {
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Request failed');
}
return data;
}
function formatDate(value) {
const localDate = new Date(`${value.slice(0, 10)}T00:00:00`);
return localDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export default function App() {
const [tasks, setTasks] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [editingId, setEditingId] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let ignore = false;
async function loadTasks() {
try {
setLoading(true);
setError('');
const response = await fetch(API_URL);
const data = await readResponse(response);
if (!ignore) {
setTasks(sortByDueDate(data));
}
} catch (requestError) {
if (!ignore) {
setError(requestError.message);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
loadTasks();
return () => {
ignore = true;
};
}, []);
function updateField(event) {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: value }));
}
function resetForm() {
setForm(EMPTY_FORM);
setEditingId(null);
}
async function handleSubmit(event) {
event.preventDefault();
setError('');
try {
const response = await fetch(
editingId ? `${API_URL}/${editingId}` : API_URL,
{
method: editingId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
},
);
const savedTask = await readResponse(response);
setTasks((current) =>
sortByDueDate(
editingId
? current.map((task) =>
task._id === savedTask._id ? savedTask : task,
)
: [...current, savedTask],
),
);
resetForm();
} catch (requestError) {
setError(requestError.message);
}
}
function startEdit(task) {
setEditingId(task._id);
setForm({
title: task.title,
dueDate: task.dueDate.slice(0, 10),
priority: task.priority,
status: task.status,
});
}
async function toggleTask(task) {
setError('');
const nextStatus = task.status === 'completed' ? 'planned' : 'completed';
try {
const response = await fetch(`${API_URL}/${task._id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: nextStatus }),
});
const savedTask = await readResponse(response);
setTasks((current) =>
sortByDueDate(
current.map((item) =>
item._id === savedTask._id ? savedTask : item,
),
),
);
} catch (requestError) {
setError(requestError.message);
}
}
async function deleteTask(taskId) {
setError('');
try {
const response = await fetch(`${API_URL}/${taskId}`, {
method: 'DELETE',
});
await readResponse(response);
setTasks((current) => current.filter((task) => task._id !== taskId));
if (editingId === taskId) {
resetForm();
}
} catch (requestError) {
setError(requestError.message);
}
}
return (
<main className="app-shell">
<header className="hero">
<p className="eyebrow">MERN REST API project</p>
<h1>TaskFlow</h1>
<p>Schedule work, persist it in MongoDB, and manage every CRUD action.</p>
</header>
<section className="panel">
<h2>{editingId ? 'Edit task' : 'Schedule a task'}</h2>
<form className="task-form" onSubmit={handleSubmit}>
<label>
Title
<input
name="title"
value={form.title}
onChange={updateField}
placeholder="Prepare sprint demo"
required
/>
</label>
<label>
Due date
<input
type="date"
name="dueDate"
value={form.dueDate}
onChange={updateField}
required
/>
</label>
<label>
Priority
<select name="priority" value={form.priority} onChange={updateField}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<label>
Status
<select name="status" value={form.status} onChange={updateField}>
<option value="planned">Planned</option>
<option value="completed">Completed</option>
</select>
</label>
<div className="form-actions">
<button type="submit">{editingId ? 'Save changes' : 'Add task'}</button>
{editingId && (
<button type="button" className="secondary" onClick={resetForm}>
Cancel
</button>
)}
</div>
</form>
</section>
<section className="panel">
<div className="section-heading">
<h2>Upcoming tasks</h2>
<span>{tasks.length} total</span>
</div>
{error && <p className="message error">{error}</p>}
{loading && <p className="message">Loading tasks...</p>}
{!loading && tasks.length === 0 && (
<p className="message">No tasks yet. Schedule the first one above.</p>
)}
<div className="task-list">
{tasks.map((task) => (
<article
className={`task-card ${task.status === 'completed' ? 'completed' : ''}`}
key={task._id}
>
<div>
<div className="badges">
<span className={`badge ${task.priority}`}>{task.priority}</span>
<span className="badge status">{task.status}</span>
</div>
<h3>{task.title}</h3>
<p>Due {formatDate(task.dueDate)}</p>
</div>
<div className="task-actions">
<button className="secondary" onClick={() => toggleTask(task)}>
{task.status === 'completed' ? 'Reopen' : 'Complete'}
</button>
<button className="secondary" onClick={() => startEdit(task)}>
Edit
</button>
<button className="danger" onClick={() => deleteTask(task._id)}>
Delete
</button>
</div>
</article>
))}
</div>
</section>
</main>
);
}
Prove the complete CRUD flow
The production build is the version served by Express from http://localhost:3000. Rebuilding places every new interaction into that same-origin app.
Before you run the build, ask yourself whether every new handler is connected well enough to compile.
- Build the completed client by running this command in the available PowerShell terminal:
npm run build
What Should the Final Build Produce?
The command creates the complete pre-filter production client in dist. Express serves those generated files beside the existing API routes.
The terminal completes the production build. Your deployed local client now contains the complete CRUD interface.
- Switch back to http://localhost:3000 in the browser from earlier.
- Refresh the page.
- Create a task with a title, due date, priority, and planned status.
- Refresh the page again.
You will see the same task after the refresh. This confirms that create and read still use MongoDB-backed data.
- Click Edit on the task card.
- Change the task title.
- Click Save changes.
- Refresh the page.
You will see the edited title after the refresh. The form returns to create mode after the update succeeds.
- Click Complete on the task card.
- Refresh the page.
You will see the completed status badge after the refresh. The task title also keeps its completed styling.
- Click Reopen on the task card.
- Refresh the page.
You will see the task return to planned status. The action label changes back to Complete.
What Has This Proved?
The edit and status controls update browser state from each successful API response. The refresh proves that the same changes were saved in MongoDB.
Before you test the error display, ask yourself what the browser can show when the API stops answering.
- Return to the integrated PowerShell terminal running the Express server.
- Press Ctrl+C to stop the server.
- Switch back to the browser.
- Click Complete on the task card.
You will see a red request error message above the task list. The task remains unchanged because the request did not succeed.
- Return to the server terminal.
- Restart the Express server by running this command:
npm run server
What Does Restarting Restore?
The command reconnects the Express app to Atlas. It also resumes the API and production client on http://localhost:3000.
The terminal reports that TaskFlow is running at http://localhost:3000. The browser can now reach the API again.
Does the App Stay Unavailable?
- Confirm that the server terminal remains running after the startup message.
- Confirm that .env still contains your working ATLAS_URI value.
- Refresh http://localhost:3000 after the server reconnects.
Help me restore the TaskFlow server.
Before you delete the task, predict what the list will show after its final record is removed.
- Refresh the browser.
- Click Delete on the task card.
- Delete any remaining task cards.
- Refresh the page once more.
You will see the empty-state message after the final deletion. No deleted task returns after the refresh.
You have completed the full CRUD loop. TaskFlow now creates, reads, updates, and deletes persistent records with visible success and error feedback.
Secret mission
Filter the Schedule by Status
Turn the stored task status into a focused schedule. Add All, Planned, and Completed filters while preserving every persistent CRUD action.
Clean Up Your Resources
Clean Up Your Resources
Choose whether TaskFlow stays active, rests for later, or is deleted. Your MongoDB Atlas Free cluster costs $0/hour, so this project has no ongoing cloud charge.
Resources you used:
- The local taskflow project folder.
- The Express server currently serving TaskFlow at http://localhost:3000.
- A MongoDB Atlas M0 Free cluster containing your TaskFlow data.
- The Atlas database user used by TaskFlow.
- The Atlas IP access-list entry used for your connection.
Keep everything running
No action is needed. Choose this if you are still actively building or demonstrating TaskFlow.
- Leave the Express server running in the integrated PowerShell terminal.
- Keep the taskflow folder on your computer.
- Keep the .env file private.
- Leave the Atlas M0 Free cluster active.
- Keep the TaskFlow database user.
- Keep the IP access-list entry restricted to the address you need.
Pause - I'll come back to this later
Stop the local server to free its port. Your files and persistent Atlas data remain ready for another session.
- Return to the integrated PowerShell terminal from earlier.
- Press Ctrl+C to stop the Express server.
- Refresh http://localhost:3000 in your browser.
- Leave the taskflow folder in place.
- Leave the Atlas M0 Free cluster idle.
- Keep the TaskFlow database user.
- Keep the required IP access-list entry.
The TaskFlow page no longer loads. This confirms that your local server has stopped.
How Does Atlas Pause?
Atlas Free clusters cannot be paused manually. An inactive Free cluster pauses automatically after 30 days.
Delete - I don't want to use this again
This option removes the Atlas data plus the local taskflow folder for a clean start.
Deletion Is Permanent
Deleting the cluster is a big step because its data cannot be recovered. Use this option only if the cluster was created for TaskFlow or contains nothing else you need.
- Return to the integrated PowerShell terminal from earlier.
- Press Ctrl+C to stop the Express server.
- Open the Clusters page in MongoDB Atlas.
- Select the M0 cluster that stores TaskFlow.
- Disable termination protection if it is enabled.
- Open the options menu for the selected cluster.
- Select Terminate.
- Confirm the permanent deletion.
The taskflow database disappears with the cluster. Its tasks collection no longer stores your task documents.
The database user lives at the Atlas project level. The IP access entry does too.
- Open Security in MongoDB Atlas.
- Select Database & Network Access.
- Open Database Users.
- Delete the database user only if it was created for TaskFlow.
- Select IP Access List.
- Remove the IP access-list entry only if you added it for TaskFlow.
The TaskFlow-only database user no longer appears under Database Users. The TaskFlow-only IP entry no longer appears under IP Access List.
This local deletion skips the Recycle Bin. The confirmation gives you one final chance to stop.
- Find the taskflow folder in the VS Code Explorer sidebar.
- Right-click the taskflow folder.
- Choose the option that reveals the folder in File Explorer.
- Close VS Code.
- Select the taskflow folder in File Explorer.
- Press Shift+Delete.
- Confirm the permanent deletion.
The taskflow folder no longer appears in File Explorer. This also removes .env from your computer.
Nice Work!
Nice Work!
You did it! TaskFlow is now a persistent React scheduler backed by an Express REST API that uses Mongoose with MongoDB Atlas. You can now demonstrate the complete CRUD flow through one browser app.
You've learned how to:
- Run a React scheduler with a controlled form that displays due-date-ordered task cards. Show loading, empty, validation, and API error feedback in the browser.
- Build a validated REST API that persists create, read, update, and delete operations through Mongoose in MongoDB Atlas.
- Serve a same-origin production app at http://localhost:3000. Confirm that edits, status changes, and deletions remain correct after refresh.
- Secret Mission: Filter the schedule with All, Planned, and Completed views. Preserve the source task state plus the complete CRUD behavior.
Ready to quiz yourself?