A gift exchange name-drawing app built with vanilla JavaScript — no React, no framework. Instead, I built my own reactive component system from scratch using a central state store and an event-driven architecture inspired by unidirectional data flow.
Try it live: https://gift-exchange-generator.com/
Drawing names from a hat doesn't work when your family is scattered across the country. And having one person draw on everyone's behalf ruins the surprise. This app lets participants be grouped so housemates can't draw each other, and Secret Santa Mode keeps every assignment private — each person gets an email with only their recipient.
- Add participants and optionally group them into households
- Groups prevent members from drawing each other
- Secret Santa Mode emails each participant their assignment privately
- Drag-and-drop reassignment of generated results
- Each participant receives a unique link to submit their wishlist
- Wishlist links, individual item links, and contact/shipping info
- Givers can view their recipient's wishlist through the exchange email
- Look up previous exchanges by email
- Reuse participant lists, groups, and emails without re-entering everything
Like React, the UI is a function of state. A central state module owns all application data and exposes named mutation functions. Every mutation emits a corresponding event — components never modify state directly and never talk to each other.
User Action -> State Mutation -> Event Emitted -> Components React
The state module exposes functions like addParticipant(), removeHouseFromState(), and assignRecipients(). Each one updates the state object and emits a specific event. The emit functions are private — external code can only trigger changes through the named API.
A custom EventEmitter class (20 lines) provides the reactive glue. A singleton stateEvents instance acts as the event bus. Components subscribe during init() and respond only to the events they care about:
// resultsTable.js — subscribes to exactly the events it needs
export function init() {
stateEvents.on(Events.EXCHANGE_STARTED, () => { /* render or remove */ });
stateEvents.on(Events.RECIPIENTS_ASSIGNED, ({ givers }) => { /* populate table */ });
}This keeps components self-sufficient. Adding a new component means subscribing to existing events — no wiring changes needed elsewhere.
The app is organized as multiple pages sharing common modules:
- Exchange page (
src/exchange/) — the main name-drawing wizard - Wishlist edit page (
src/wishlistEdit/) — participant wishlist submission - Wishlist view page (
src/wishlistView.js) — giver views recipient's wishlist - Reuse page (
src/reuse.js) — search and reuse past exchanges
Each page has an HTML entry point in the pages/ directory. A custom Vite plugin (vitePageRoutes.js) auto-discovers pages by globbing pages/**/index.html, rewrites clean URLs in the dev server, and flattens dist/pages/ to top-level directories on build. Adding a new page requires only creating a directory under pages/ with an index.html — no config changes needed.
Dev-only tools (like the email preview page) live in the top-level dev/ directory instead of pages/, so they get dev server routes but are excluded from production builds.
The name-drawing algorithm isn't just random shuffling. It models the problem as a bipartite graph where each participant can be assigned to any recipient outside their exclusion group. It then finds a perfect matching using augmenting paths, guaranteeing a valid assignment exists before presenting results — or reporting that the constraints make one impossible.
The algorithm is pure business logic with no UI imports, following strict separation of concerns.
Netlify Functions handle the server-side work:
- MongoDB Atlas — stores exchanges, user wishlists, and assignments
- Postmark — sends each participant an email with their assigned recipient
- Zod 4 — validates all request bodies and database documents
Email sending uses a Clojure-style multimethod dispatch that selects an implementation by provider name. The "postmark" provider calls the Postmark API; the "dev" provider logs to the console and simulates failures for @fail.test addresses. The provider defaults to "dev" on localhost and "postmark" everywhere else. Adding a new email provider requires only registering a new method — no existing code changes needed.
The project has three test layers:
- Unit tests — Vitest + jsdom. Components tested in isolation by emitting events and asserting on DOM output.
- Integration tests — Contract tests that call real backend handlers with frontend-shaped payloads against MongoMemoryServer, verifying request/response shapes stay in sync.
- E2E tests — Playwright driving a real
netlify devserver backed by MongoMemoryServer, verifying critical user journeys end-to-end.
A custom snackbar notification system handles user-facing errors:
- Missing participants before generation
- Duplicate name detection
- Impossible constraint configurations (e.g., a group larger than half the participants)
git clone https://github.com/arootroatch/ChristmasGiftExchange.git
cd ChristmasGiftExchange
npm installnpm test # all unit + integration tests (watch mode)
npm run unit # unit tests only (watch mode)
npm run integration # integration/contract tests only (watch mode)
npm run e2e # Playwright end-to-end tests
npm run coverage # all tests with coverage reportAdd -- run to any vitest command for a single run instead of watch mode:
npm test -- run
npm run unit -- runLocal development requires the Netlify CLI and a MongoDB instance.
-
Install the Netlify CLI (if you haven't already):
npm i -g netlify-cli
-
Start the dev database in a separate terminal. This launches an in-memory MongoDB via MongoMemoryServer, seeds it with test data, and opens a REPL for querying:
bin/db
The script updates
MONGO_DB_URIin.env.localto point to the in-memory server.You can also connect a REPL to an existing database (dev or production):
npm run repl # prompts for environment (dev or prod) npm run repl dev # connects using .env.local npm run repl prod # connects using .env
-
Start the dev server in another terminal:
npm run dev
This runs
netlify dev, which starts the Vite dev server and proxies serverless function requests. Environment variables are loaded from.env.local. -
Test failed email sending (dev-only):
Use
@fail.testemail addresses (e.g.,alex@fail.test) in the email form. The dev email provider returns these as failures, triggering the failed-emails UI. Edit the address to something else in the retry form and resubmit to test the success path. The Verifalia email verification widget is automatically stripped in dev mode so these addresses aren't blocked. -
Preview email templates (dev-only):
With both
bin/dbandnpm run devrunning, visit:http://localhost:8888/dev/emails/This opens a split-view preview tool with a sidebar listing all email templates. Click any template to see it rendered with real data from the seeded database. Email templates live in
netlify/shared/emails/as JS modules — edit them and refresh to see changes.
Pushes to main trigger a GitHub Actions workflow:
- Unit + integration tests — Vitest with coverage uploaded to Codecov
- E2E tests — Playwright against a
netlify devserver backed by MongoMemoryServer - Staging deploy — Netlify draft deploy (requires both test jobs to pass)
- Production deploy — Manual approval gate via GitHub environment protection rules
Non-production deploys use a separate staging database so preview and branch deploy activity doesn't affect production data.
If you'd like to contribute, please fork the repository and open a pull request to the 'main' branch.



