Skip to content

About

Zero-dependency SPA — custom History API router, Proxy-based reactive store, and native Web Components. Built to understand what frameworks abstract away.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

Multi-Step Form — Vanilla Web Components SPA

A fully functional multi-step subscription form built with zero dependencies — no React, no Vue, no bundler. Just native browser APIs used the way frameworks use them internally.

Originally a Frontend Mentor UI challenge. I used it as an excuse to build a mini-framework from scratch and understand what tools like React and Vue actually abstract away.

Demo

App flow: live validation, monthly/yearly re-pricing, add-on selection, and the reactive summary total


Live Demo

View Demo · View Challenge


What's Inside

Custom Router (/services/Router.js)

Built on the History API (pushState / popstate). Handles:

  • Client-side navigation without page reloads
  • Back/forward browser button support via the popstate event
  • Active link state synced to the current route
  • An addToHistory flag that separates "navigate" from "render" — so popstate can rerender without double-pushing to the history stack

Reactive Store (/services/Store.js)

A Proxy-based global store that fires custom DOM events on every state mutation. This is the same mental model behind Vue 3's reactivity system — intercept set, notify subscribers.

// Setting a value automatically fires a custom event
app.store.selectedPlan = { name: 'arcade', price: '$9/mo', yearly: false };
// → dispatches `selectedPlanChanged` on window
// → any component listening re-renders itself

Mutation has one important constraint: array mutations must use = assignment, not .push() / .splice(), or the Proxy trap won't fire. This is documented inline in the code.

Data Persistence (/services/handelData.js)

Three functions — loadData, updateData, clearData — handle syncing the store to localStorage. State survives page refresh and is cleared on form completion.

CSS Loader (/services/CssLoader.js)

A small utility class that fetches and injects component-scoped CSS into Shadow DOM roots via fetch(). Keeps styles encapsulated without a build step.

Web Components (/components/)

Each UI step is a native HTMLElement subclass registered via customElements.define. They use Shadow DOM for style encapsulation and <template> elements for HTML stamping.

Component Route Responsibility
<personal-info> / Name, email, phone with live validation
<select-plan> /select-plan Plan selection + monthly/yearly toggle
<add-ons> /add-ons Optional add-on selection, price-aware
<summary-page> /summary Order review, total calculation, confirm gate
<confirm-page> /completed Confirmation screen, clears store
<content-header> — Shared slot-based header (title + description)
<navigation-buttons> — Shared back/next navigation, delegates to router

Architecture

window.app
├── app.store    → Proxy-based reactive store
└── app.router   → History API router

Store mutation
  └── fires CustomEvent on window
        └── components subscribe via addEventListener
              └── re-render themselves

There is no virtual DOM, no diffing, no framework scheduler. Components listen to store events and update their own Shadow DOM directly.


Form Flow

/ (personal-info)
  → /select-plan
    → /add-ons
      → /summary
        → /completed

State is accumulated in the store at each step and persisted to localStorage. On the summary page, the Confirm button is disabled until all three data keys (personalInfo, selectedPlan, addOns) are present and valid.


Validation

PersonalInfo validates inputs in real time using regex:

  • Name — letters, accented characters, hyphens, apostrophes
  • Email — standard RFC 5321 pattern
  • Phone — Egyptian mobile format (01[0|1|2|5]XXXXXXXX)

Error states are applied as CSS classes on the parent element, keeping validation logic separate from styling.


What I'd Do Differently Today

This project is a few years old. Knowing what I know now:

  • Route map instead of switch — a { '/path': 'component-name' } object would scale better than the switch statement in the router
  • Generic store events — store:changed:${property} instead of hardcoded event names per property
  • Remove window.app — global namespace coupling is a design smell; a proper module singleton pattern would be cleaner
  • CSS custom properties — theming through design tokens rather than per-component raw values

These are deliberate tradeoffs I made at the time (or didn't know to make). I've left the code as-is to keep it honest.


Running Locally

No build step required. Just serve the files over HTTP — browsers block fetch() on file:// protocol.

# Using Python
python -m http.server 3000

# Using Node
npx serve .

Then open http://localhost:3000.


Tech

  • Native Web Components (HTMLElement, customElements, Shadow DOM, <template>)
  • History API (pushState, popstate)
  • ES Modules (native import/export, no bundler)
  • Proxy for reactive state
  • fetch() for CSS loading
  • localStorage for persistence
  • Zero npm packages

About

Zero-dependency SPA — custom History API router, Proxy-based reactive store, and native Web Components. Built to understand what frameworks abstract away.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages