Skip to main content

๐Ÿ› ๏ธ Weekend Project: Build a Modern JavaScript App with Webpack, Babel & Tests

This is the project where you stop writing loose scripts and start building a real application the way a professional team does. You'll scaffold a Task Manager with npm, split it into ES6 modules, bundle it with Webpack 5 (Vite offered as an alternative), transpile it with Babel 7, and lock its behaviour down with Jest unit tests. By Sunday you'll have a tested app and a production build you could deploy.

Week 3 · Weekend Project · Tooling Capstone

๐ŸŽฏ Learning Objectives

By completing this project, you will be able to:

  • Scaffold a JavaScript project with npm init and install dev dependencies you actually understand
  • Split an app into ES6 modules using export/import, backed by ES6 classes
  • Configure Webpack 5 and Babel 7 to bundle and transpile source into a browser-ready build
  • Write and run Jest unit tests, and read the pass/fail output to drive your code
  • Wire a small UI to a service layer so business logic stays out of the DOM code
  • Produce an optimized production build with hashed filenames and source maps

Estimated Time: 5โ€“7 hours across the weekend

Project: A tested, bundled Task Manager app with add/complete/filter/persist features.

In This Project

The Goal

Build a Task Manager โ€” add tasks, mark them complete, filter by status, and persist them across reloads โ€” but the app itself is only half the point. The real deliverable is the workflow: a project you scaffolded, modularized, tested, and bundled. This is the shape of every professional JavaScript codebase, from a startup side-project to a framework app.

Everything you write lives as clean source in src/. A build step transforms it into a bundle the browser loads. Keep this pipeline in mind โ€” each stage below is one box in it:

graph LR A["src/ ES6 modules
import / export"] --> B[Babel 7
transpiles modern JS] B --> C[Webpack 5
bundles & resolves] C --> D["dist/ bundle.js
+ index.html"] D --> E[Browser runs the app] A --> F[Jest
runs unit tests] F -->|green| C F -->|red| A

Notice the loop at the bottom: Jest reads the same source your bundle does. Green tests are your permission to build; a red test sends you back to the module โ€” not to the browser to click around and guess. That feedback loop is what "modern tooling" really buys you.

๐Ÿ“– Bundler vs transpiler

A transpiler (Babel) rewrites modern JavaScript down to syntax older browsers understand. A bundler (Webpack/Vite) follows your import statements and stitches every module into as few files as possible, handing each to Babel on the way through. Babel makes the code compatible; Webpack makes it deliverable.

Prerequisites

This is the Week 3 capstone, so it assumes the "Modern JavaScript & Tooling" week is fresh. Before you start, make sure you're comfortable with:

  • ES6 modules โ€” export default, named export, and matching import
  • Classes โ€” constructor, methods, and static methods
  • Array methods โ€” map, filter, find from Week 2
  • npm basics โ€” what package.json, node_modules, and a dev dependency are
  • The TDD mindset โ€” write a failing test, make it pass, refactor

You'll need Node.js 18+ installed (it brings npm with it), a code editor, and a terminal. Check with node --version and npm --version first.

Required Features Checklist

These are the non-negotiables โ€” all achievable with this week's tooling, no framework required. Tick each off as you go.

โœ… Must-have features

  • โ˜ A project scaffolded with npm init and npm run scripts for start, build, test
  • โ˜ Source split into ES6 modules โ€” a Task model and a TaskManager service as ES6 classes, plus UI components
  • โ˜ Add, complete/uncomplete, delete, and filter tasks (all / active / completed)
  • โ˜ Tasks persist across reloads via localStorage
  • โ˜ A Webpack 5 (or Vite) config with a dev server and a production build
  • โ˜ Babel 7 transpilation wired into the build
  • โ˜ A Jest suite that passes for the model and the service

Project Structure

Here's the layout you're building toward. The golden rule: everything you author lives in src/; dist/ is generated by the build and never edited by hand (git-ignore it). Config files sit at the root where the tools look for them.

task-manager/
โ”œโ”€โ”€ package.json          <-- scripts + dependencies
โ”œโ”€โ”€ webpack.config.js     <-- how to bundle
โ”œโ”€โ”€ babel.config.js       <-- how to transpile
โ”œโ”€โ”€ jest.config.js        <-- how to test
โ”œโ”€โ”€ .gitignore            <-- ignores node_modules/ and dist/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ index.html        <-- template Webpack injects the bundle into
โ”‚   โ”œโ”€โ”€ js/
โ”‚   โ”‚   โ”œโ”€โ”€ models/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ Task.js          <-- the data (ES6 class)
โ”‚   โ”‚   โ”œโ”€โ”€ services/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ TaskManager.js   <-- the logic + persistence
โ”‚   โ”‚   โ”œโ”€โ”€ components/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ TaskForm.js      <-- UI: add a task
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ TaskList.js      <-- UI: render tasks
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ TaskFilter.js    <-- UI: filter buttons
โ”‚   โ”‚   โ””โ”€โ”€ app.js              <-- entry point that wires it together
โ”‚   โ”œโ”€โ”€ css/
โ”‚   โ”‚   โ””โ”€โ”€ styles.css
โ”‚   โ””โ”€โ”€ __tests__/
โ”‚       โ”œโ”€โ”€ Task.test.js
โ”‚       โ””โ”€โ”€ TaskManager.test.js
โ””โ”€โ”€ dist/                 <-- GENERATED by `npm run build` (git-ignored)

Each module has one job: Task knows what a task is, TaskManager knows how to store and query tasks, and the components know how to draw them. That separation is exactly what makes the app testable โ€” you can unit-test TaskManager with no browser in sight, because it never touches the DOM.

Stage 1 โ€” Scaffold with npm

Every modern JS project starts the same way: create a folder, initialize npm, and install your tools as dev dependencies (needed to build the app, not to run the shipped bundle). Run these in your terminal:

# Create the project and enter it
mkdir task-manager && cd task-manager

# Create package.json with sensible defaults (the -y skips the prompts)
npm init -y

# Webpack 5 + its CLI and dev server
npm install --save-dev webpack webpack-cli webpack-dev-server

# Babel 7: core, the modern-JS preset, and the Webpack loader
npm install --save-dev @babel/core @babel/preset-env babel-loader

# Jest, plus babel-jest so tests understand ES modules
npm install --save-dev jest babel-jest jest-environment-jsdom

# Webpack plugins/loaders: HTML injection + CSS handling
npm install --save-dev html-webpack-plugin css-loader style-loader mini-css-extract-plugin

Then create the source folders in one go, and add the scripts you'll run every day to package.json:

mkdir -p src/js/models src/js/services src/js/components src/css src/__tests__
{
  "name": "task-manager",
  "version": "1.0.0",
  "scripts": {
    "start": "webpack serve --mode development",
    "build": "webpack --mode production",
    "test": "jest",
    "test:watch": "jest --watch"
  }
}

๐Ÿ“– --save-dev vs --save

Tools that only run during development โ€” bundlers, transpilers, test runners โ€” go in devDependencies with --save-dev. Libraries whose code actually ships inside your bundle go in dependencies (plain --save). Getting this split right keeps your production install lean.

Stage 2 โ€” Configure the Toolchain

Three small config files teach your tools how to behave. Create each at the project root.

Webpack โ€” webpack.config.js

Webpack needs an entry (where to start following imports), an output (where to write the bundle), and rules (how to handle each file type). Exporting a function that receives argv.mode lets one config serve dev and production both.

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';

  return {
    entry: './src/js/app.js',           // start here and follow every import
    output: {
      path: path.resolve(__dirname, 'dist'),
      // Content-hash the filename in prod so browsers cache-bust correctly
      filename: isProduction ? '[name].[contenthash].js' : '[name].js',
      clean: true                        // wipe dist/ before each build (Webpack 5 built-in)
    },
    module: {
      rules: [
        // Every .js file except third-party code gets handed to Babel:
        { test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' },
        // Inject <style> tags in dev; extract to a .css file in prod:
        { test: /\.css$/, use: [
            isProduction ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader'
        ] }
      ]
    },
    plugins: [
      // Webpack injects the hashed bundle <script> into this template for you:
      new HtmlWebpackPlugin({ template: './src/index.html' }),
      isProduction && new MiniCssExtractPlugin({ filename: '[name].[contenthash].css' })
    ].filter(Boolean),                   // drop the `false` when not producing
    devServer: { static: './dist', hot: true, open: true },  // hot reload + auto-open
    devtool: isProduction ? 'source-map' : 'eval-source-map'  // debuggable stack traces
  };
};

โš ๏ธ A Webpack 5 modernization

Older tutorials install clean-webpack-plugin separately โ€” Webpack 5 replaces it with the built-in output.clean: true shown above, so skip the extra package. (Webpack 5 also no longer auto-polyfills Node core modules, the classic "worked in Webpack 4" gotcha, though this browser-only project won't hit it.)

Babel โ€” babel.config.js

One preset does almost everything: @babel/preset-env reads your target browsers and transpiles exactly as much as it must. The same config is picked up by both babel-loader (build) and babel-jest (tests), so your tests run the same transpiled code your app does.

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      // "Support browsers with >0.25% share that aren't dead" โ€” a sane default.
      targets: '> 0.25%, not dead'
    }]
  ]
};

Jest โ€” jest.config.js

Jest runs in Node, which has no document or localStorage. The jsdom environment fakes a browser so your DOM code and localStorage persistence can be tested without a real page. The moduleNameMapper line stops a CSS import inside a tested module from crashing by mapping it to an empty stub.

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',              // simulate window, document, localStorage
  moduleNameMapper: {
    '\\.(css|scss)$': '<rootDir>/src/__mocks__/styleMock.js'  // stub file: module.exports = {}
  }
};

Stage 3 โ€” Build Feature Modules

Now the application logic โ€” and the best part is that none of it touches the DOM. That's deliberate: pure logic is easy to reason about and trivial to test. Two modules carry the app.

The Task model

A Task is a small ES6 class: it holds the data and knows a few things about itself (whether it's overdue, how to serialize). The toJSON/fromJSON pair is what lets us store tasks as text and rebuild them later.

// src/js/models/Task.js
export default class Task {
  constructor(title, description = '', options = {}) {
    // A collision-resistant id (modern engines also offer crypto.randomUUID()):
    this.id = Date.now().toString(36) + Math.random().toString(36).slice(2);
    this.title = title;
    this.description = description;
    this.completed = false;
    this.createdAt = new Date();
    this.completedAt = null;
    this.dueDate = options.dueDate || null;
    this.priority = options.priority || 'normal';
  }

  complete()   { this.completed = true;  this.completedAt = new Date(); }
  uncomplete() { this.completed = false; this.completedAt = null; }

  isOverdue() {
    if (!this.dueDate || this.completed) return false;
    return new Date() > this.dueDate;
  }

  // Turn the task into a plain, storable object (Dates โ†’ ISO strings).
  toJSON() {
    return { ...this,
      createdAt:   this.createdAt.toISOString(),
      completedAt: this.completedAt ? this.completedAt.toISOString() : null,
      dueDate:     this.dueDate ? this.dueDate.toISOString() : null
    };
  }

  // Rebuild a real Task (with methods + Date objects) from stored JSON.
  static fromJSON(json) {
    const task = new Task(json.title, json.description, {
      dueDate: json.dueDate ? new Date(json.dueDate) : null,
      priority: json.priority
    });
    Object.assign(task, {
      id: json.id,
      completed: json.completed,
      createdAt: new Date(json.createdAt),
      completedAt: json.completedAt ? new Date(json.completedAt) : null
    });
    return task;
  }
}

๐Ÿ’ก slice(2), not the old substr(2)

Older code writes Math.random().toString(36).substr(2), but String.prototype.substr() is a legacy, deprecated method. Reach for slice(2) instead โ€” it drops the leading "0." and gives you the random tail with one fewer deprecation flag.

The TaskManager service

The service owns the array of tasks, every operation on it, and persistence. Because it's a plain class with no UI, it's the easiest thing in the app to test โ€” which is exactly what Stage 5 does.

// src/js/services/TaskManager.js
import Task from '../models/Task';

export default class TaskManager {
  constructor() {
    this.tasks = [];
    this.loadTasks();          // rehydrate from localStorage on startup
  }

  loadTasks() {
    const saved = localStorage.getItem('tasks');
    // Map each stored plain object back into a real Task instance.
    if (saved) this.tasks = JSON.parse(saved).map(Task.fromJSON);
  }

  saveTasks() {
    localStorage.setItem('tasks', JSON.stringify(this.tasks));
  }

  addTask(title, description = '', options = {}) {
    const task = new Task(title, description, options);
    this.tasks.push(task);
    this.saveTasks();
    return task;               // return it so the caller can chain, e.g. .complete()
  }

  removeTask(id) {
    this.tasks = this.tasks.filter(task => task.id !== id);
    this.saveTasks();
  }

  getTask(id) { return this.tasks.find(task => task.id === id); }

  getAllTasks() { return [...this.tasks]; }   // a copy, so callers can't mutate our array

  getFilteredTasks(filter = 'all') {
    switch (filter) {
      case 'active':    return this.tasks.filter(t => !t.completed);
      case 'completed': return this.tasks.filter(t => t.completed);
      default:          return this.getAllTasks();
    }
  }
}

Stage 4 โ€” Wire the UI

With the logic done, the UI is thin. Each component is a class that owns a DOM element and calls back to the app when the user acts โ€” it never touches TaskManager directly. The entry point (app.js) is the wiring between them.

The three components

Each component owns a DOM element and a callback, following one shape โ€” build the element, attach a listener, call back to the app. TaskForm is the template for all three:

// src/js/components/TaskForm.js
export default class TaskForm {
  constructor(onSubmit) {
    this.onSubmit = onSubmit;                 // callback into the app
    this.element = document.createElement('form');
    this.element.className = 'task-form';
    this.element.innerHTML = `
      <input type="text" name="title" placeholder="Task title" required>
      <select name="priority" aria-label="Priority">
        <option value="low">Low</option>
        <option value="normal" selected>Normal</option>
        <option value="high">High</option>
      </select>
      <button type="submit">Add Task</button>`;

    this.element.addEventListener('submit', (e) => {
      e.preventDefault();
      const data = new FormData(e.target);
      this.onSubmit({ title: data.get('title'), priority: data.get('priority') });
      e.target.reset();
    });
  }
}

The other two follow suit. TaskList.render(tasks) builds an <li> per task with a checkbox and a delete button, wiring each to onToggle(task.id) / onDelete(task.id). TaskFilter is three buttons that call onFilterChange('all' | 'active' | 'completed') and highlight the active one.

The entry point โ€” app.js

This is the file Webpack starts from. It imports every module (including the CSS โ€” Webpack turns that import into a real stylesheet), constructs the service and components, and defines the handlers that glue them.

// src/js/app.js
import TaskManager from './services/TaskManager';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import TaskFilter from './components/TaskFilter';
import '../css/styles.css';                 // Webpack handles this import, not the browser

class TaskApp {
  constructor(root) {
    this.taskManager = new TaskManager();
    this.currentFilter = 'all';

    this.form   = new TaskForm(this.handleAdd.bind(this));
    this.list   = new TaskList(this.handleToggle.bind(this), this.handleDelete.bind(this));
    this.filter = new TaskFilter(this.handleFilter.bind(this));

    root.append(this.form.element, this.filter.element, this.list.element);
    this.render();
  }

  handleAdd({ title, priority }) {
    this.taskManager.addTask(title, '', { priority });
    this.render();
  }

  handleToggle(id) {
    const task = this.taskManager.getTask(id);
    task.completed ? task.uncomplete() : task.complete();
    this.taskManager.saveTasks();
    this.render();
  }

  handleDelete(id) { this.taskManager.removeTask(id); this.render(); }
  handleFilter(filter) { this.currentFilter = filter; this.render(); }

  render() {
    this.list.render(this.taskManager.getFilteredTasks(this.currentFilter));
  }
}

// Boot once the DOM exists.
document.addEventListener('DOMContentLoaded', () => {
  new TaskApp(document.getElementById('app'));
});

The src/index.html template is deliberately bare โ€” a standard <head> plus a body containing just <main id="app"></main> where the components mount. Crucially it has no <script> tag: HtmlWebpackPlugin injects the bundle with its correct hashed filename for you at build time.

โš ๏ธ innerHTML and user input

Building list items with template strings like ${task.title} is concise, but injecting unescaped user input into innerHTML is an XSS risk โ€” a task titled <img onerror=โ€ฆ> could run code. For production, build nodes with document.createElement and set textContent, or escape the values. Worth knowing now, even if you keep the simple version this weekend.

Stage 5 โ€” Add Jest Tests

Here's the payoff for keeping logic out of the DOM: you can test the whole "brain" of the app with no browser. A Jest test imports a module, calls it, and asserts with expect(...). Run npm test and Jest finds every *.test.js file automatically.

Testing the Task model

// src/__tests__/Task.test.js
import Task from '../js/models/Task';

describe('Task', () => {
  test('creates a task with sensible defaults', () => {
    const task = new Task('Buy groceries', 'Milk and eggs');
    expect(task.id).toBeDefined();
    expect(task.title).toBe('Buy groceries');
    expect(task.completed).toBe(false);
    expect(task.createdAt).toBeInstanceOf(Date);
  });

  test('complete() flips the flag and stamps a time', () => {
    const task = new Task('Test');
    task.complete();
    expect(task.completed).toBe(true);
    expect(task.completedAt).toBeInstanceOf(Date);
  });

  test('toJSON/fromJSON round-trips a task', () => {
    const restored = Task.fromJSON(new Task('Round trip', '', { priority: 'high' }).toJSON());
    expect(restored.priority).toBe('high');
    expect(restored.createdAt).toBeInstanceOf(Date);   // rebuilt as a real Date, not a string
  });
});

Testing the TaskManager service

beforeEach gives every test a clean slate โ€” a fresh manager and an empty localStorage โ€” so tests never leak into each other.

// src/__tests__/TaskManager.test.js
import TaskManager from '../js/services/TaskManager';

describe('TaskManager', () => {
  let manager;

  beforeEach(() => {
    localStorage.clear();       // jsdom gives us a working localStorage
    manager = new TaskManager();
  });

  test('addTask stores a task; removeTask deletes it by id', () => {
    const task = manager.addTask('Write tests');
    expect(manager.getAllTasks()).toHaveLength(1);
    manager.removeTask(task.id);
    expect(manager.getAllTasks()).toHaveLength(0);
  });

  test('getFilteredTasks separates active from completed', () => {
    manager.addTask('Done').complete();
    manager.addTask('Todo');
    expect(manager.getFilteredTasks('active')).toHaveLength(1);
    expect(manager.getFilteredTasks('completed')).toHaveLength(1);
  });

  test('tasks persist to localStorage across instances', () => {
    manager.addTask('Persisted');
    const reloaded = new TaskManager();   // simulates a page refresh
    expect(reloaded.getAllTasks()).toHaveLength(1);
  });
});

Expected output of npm test

PASS  src/__tests__/Task.test.js
PASS  src/__tests__/TaskManager.test.js

Test Suites: 2 passed, 2 total
Tests:       7 passed, 7 total

๐Ÿ‹๏ธ Exercise: Test-drive a new method

Goal: Add a clearCompleted() method to TaskManager that deletes every completed task โ€” but write the test first, watch it fail, then make it pass.

๐Ÿ’ก Hint

Write a test that adds two tasks, completes one, calls manager.clearCompleted(), and expects getAllTasks() to have length 1. Then implement the method with a filter and a saveTasks().

โœ… Solution
// test โ€” write this FIRST; it should fail before the method exists
test('clearCompleted removes only completed tasks', () => {
  manager.addTask('Keep');
  manager.addTask('Remove').complete();
  manager.clearCompleted();
  expect(manager.getAllTasks().map(t => t.title)).toEqual(['Keep']);
});

// implementation (add to TaskManager)
clearCompleted() {
  this.tasks = this.tasks.filter(task => !task.completed);
  this.saveTasks();
}

Stage 6 โ€” Production Build

During development, npm start launches webpack-dev-server with hot reload at http://localhost:8080. When you're ready to ship, npm run build produces an optimized bundle in dist/.

npm start        # dev: hot-reloading server, unminified, opens the browser
npm test         # run the suite (do this before every build)
npm run build    # production: minified, hashed filenames, CSS extracted, source maps

The two modes differ on purpose. Development favours speed and debuggability โ€” unminified output, plain main.js, CSS via <style> tags, fast eval-source-map rebuilds. Production favours a small, cacheable payload โ€” minified code, content-hashed filenames, CSS extracted to its own file, and accurate source-maps.

After a successful build, dist/ holds a self-contained site โ€” an index.html, a hashed JS bundle, and a hashed CSS file. Drag that folder onto Netlify, GitHub Pages, or Vercel and your app is live.

๐Ÿ“– Prefer Vite? Same project, less config

Webpack teaches you what a bundler does. But many new projects reach for Vite instead โ€” near-zero config and a near-instant dev server via esbuild and native ES modules (Babel isn't even needed for modern targets). The exact same src/ modules and Jest tests work; you just swap the tooling:

npm create vite@latest task-manager -- --template vanilla
cd task-manager && npm install
npm run dev       # instant dev server (Vite's `npm start`)
npm run build     # production bundle in dist/ (then `npm run preview` to check it)

Stretch Goals

Finished the required build with time to spare? Level it up โ€” pick whichever excites you; none are needed to pass the rubric.

  • ๐Ÿ” Search โ€” add searchTasks(query) and a search input that re-renders as you type
  • โœ๏ธ Edit in place โ€” click a task title to edit it (a good excuse to add an updateTask(id, changes) method)
  • ๐Ÿท๏ธ Categories/tags โ€” a new field on Task plus a filter dimension
  • ๐Ÿ“Š Coverage report โ€” run npm test -- --coverage and push every module above 80%
  • ๐Ÿš€ Deploy it โ€” ship dist/ to Netlify or Vercel and share the URL
  • โšก Migrate to Vite โ€” port to the Vite setup above and compare dev-server speed

Start with search โ€” the pattern mirrors getFilteredTasks. Write the failing test first, then add a searchTasks(query) method that lower-cases the query and returns tasks whose title or description .includes() it, and wire an input event on a search box to re-render with the results.

Self-Check Rubric

Before you call this done, grade yourself against the rubric. Aim to answer "yes" to everything in the first two columns โ€” the stretch column is bonus.

AreaMeets expectations (required)Exceeds (stretch)
Scaffolding package.json with start/build/test scripts; deps as devDependencies .gitignore excludes node_modules/ and dist/; a README documents setup
Modules Code split into model, service, and components with import/export Clear single-responsibility per file; no logic leaking into the UI layer
Bundler & Babel npm start serves the app; npm run build emits a working dist/ Hashed filenames + extracted CSS in prod; or a working Vite port
Features Add, complete/uncomplete, delete, filter, and persist all work Search, edit-in-place, or categories added
Tests npm test is all green for the model and the service A new method was test-driven; coverage report above 80%
Quality No console errors; production build loads and runs Deployed live with a shareable URL; XSS-safe rendering

๐Ÿงช Final testing checklist

  • โ˜ npm test passes with zero failing tests
  • โ˜ npm start opens the app; adding a task renders it immediately
  • โ˜ Completing a task strikes it through; the filter buttons show the right subset
  • โ˜ Refreshing the page keeps your tasks (localStorage persistence works)
  • โ˜ npm run build finishes with no errors and creates dist/
  • โ˜ No red errors in the console; dist/ and node_modules/ are git-ignored

Summary

๐ŸŽ‰ What You Built

  • A project scaffolded with npm and driven by npm run scripts
  • An app split into ES6 modules โ€” a Task model, a TaskManager service, and thin UI components
  • A Webpack 5 + Babel 7 toolchain that bundles and transpiles source into a browser-ready build (with a Vite alternative in your back pocket)
  • A green Jest suite that tests the app's logic with no browser required
  • An optimized production build in dist/, ready to deploy

This project is proof that Week 3 stuck. You went from writing scripts to engineering an application โ€” modularized, tested, bundled, shippable. That scaffold-modularize-test-build loop is the backbone of every professional JavaScript codebase, and exactly the ground a framework builds on.

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

Week 3 is complete โ€” you can now build, test, and ship a modern JavaScript app from scratch. Next week we put this tooling to work behind a framework. First up: What is React and why use it? โ€” where the component thinking you just practised (a TaskForm, a TaskList) is exactly what React formalizes, on a build setup much like the one you wired by hand.

๐ŸŽ‰ You finished Week 3!

You've got a tested, bundled app and a professional workflow to match. That's three real projects in your portfolio now โ€” and you're ready for React.