Polymer 15 Project
title: "Polymer Project — Building a Complete Application with LitElement" description: "Build a complete Polymer project: a task management application using LitElement components, page.js routing, state management, service layer, testing, and deployment-ready production build." weight: 47 date: 2026-06-28 lastmod: 2026-06-28 tags: ["frameworks", "polymer"]
This project brings together everything from previous lessons to build a complete, production-ready task management application with LitElement.
## Project Overview
Build a **TaskFlow** application — a task management dashboard with user authentication, task CRUD, filtering, search, theming, and persistent storage.
## Application Architecture
```mermaid
flowchart TD
A[TaskFlow App] --> B[App Shell]
B --> C[Router]
B --> D[Theme Provider]
B --> E[State Manager]
C --> F[Login Page]
C --> G[Dashboard Page]
C --> H[Task Detail Page]
C --> I[Settings Page]
G --> J[Task List]
G --> K[Filter Bar]
G --> L[Search]
H --> M[Task Form]
H --> N[Comments]
I --> O[Theme Toggle]
style A fill:#e6f3ff,stroke:#4a90d9,stroke-width:2px
Project Structure
taskflow/
index.html
src/
app.js -- App shell and router
state.js -- Simple state manager
api.js -- API service layer
styles/
tokens.css -- Design tokens
global.css -- Global styles
components/
task-list.js -- Task list component
task-card.js -- Individual task card
task-form.js -- Task create/edit form
filter-bar.js -- Filter and sort controls
search-input.js -- Debounced search
user-avatar.js -- User avatar component
theme-toggle.js -- Theme switcher
modal-dialog.js -- Modal for task detail
pagination.js -- Pagination controls
pages/
login-page.js -- Login view
dashboard.js -- Main dashboard
task-detail.js -- Task detail/edit page
settings.js -- Settings page
not-found.js -- 404 page
services/
auth.js -- Authentication service
task-service.js -- Task CRUD service
test/
components/
task-list.test.js
task-card.test.js
pages/
dashboard.test.js
rollup.config.js
package.json
Step 1 — State Manager
// src/state.js
class SimpleState {
constructor(initial = {}) {
this._state = initial;
this._listeners = new Map();
this._idCounter = 0;
}
get(key) { return this._state[key]; }
set(key, value) {
this._state[key] = value;
this._notify(key, value);
}
update(key, updater) {
this.set(key, updater(this._state[key]));
}
subscribe(key, callback) {
const id = this._idCounter++;
if (!this._listeners.has(key)) this._listeners.set(key, new Map());
this._listeners.get(key).set(id, callback);
return () => this._listeners.get(key)?.delete(id);
}
_notify(key, value) {
this._listeners.get(key)?.forEach(cb => cb(value));
}
}
export const store = new SimpleState({
user: null,
tasks: [],
filter: 'all',
searchQuery: '',
theme: 'light',
loading: false,
error: null
});
Expected output: Simple observable state. Components subscribe to specific keys. Changes trigger re-renders via requestUpdate.
Step 2 — API Service Layer
// src/services/api.js
const API_BASE = '/api';
async function request(endpoint, options = {}) {
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers
};
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(error.message || 'Request failed');
}
return response.json();
}
export const api = {
get: (url) => request(url),
post: (url, data) => request(url, { method: 'POST', body: JSON.stringify(data) }),
put: (url, data) => request(url, { method: 'PUT', body: JSON.stringify(data) }),
delete: (url) => request(url, { method: 'DELETE' })
};
Expected output: Centralized API client with auth token injection and standardized error handling.
Step 3 — Auth Service
// src/services/auth.js
import { api } from './api.js';
import { store } from '../state.js';
class AuthService {
async login(email, password) {
store.set('loading', true);
store.set('error', null);
try {
const { user, token } = await api.post('/auth/login', { email, password });
localStorage.setItem('auth_token', token);
store.set('user', user);
return user;
} catch (err) {
store.set('error', err.message);
throw err;
} finally {
store.set('loading', false);
}
}
async logout() {
try {
await api.post('/auth/logout');
} finally {
localStorage.removeItem('auth_token');
store.set('user', null);
}
}
async checkAuth() {
const token = localStorage.getItem('auth_token');
if (!token) return false;
try {
const user = await api.get('/auth/me');
store.set('user', user);
return true;
} catch {
localStorage.removeItem('auth_token');
return false;
}
}
}
export const authService = new AuthService();
Expected output: Auth service manages login, logout, and session check. Store updates trigger UI changes.
Step 4 — App Shell with Routing
// src/app.js
import { LitElement, html } from 'lit';
import page from 'page/page.mjs';
import { store } from './state.js';
import { authService } from './services/auth.js';
import './components/theme-toggle.js';
import './pages/login-page.js';
import './pages/dashboard.js';
import './pages/task-detail.js';
import './pages/settings.js';
import './pages/not-found.js';
class TaskFlowApp extends LitElement {
static properties = {
user: { type: Object },
currentPage: { type: String },
loading: { type: Boolean }
};
constructor() {
super();
this.user = null;
this.currentPage = 'loading';
this.loading = true;
this._unsubs = [
store.subscribe('user', user => { this.user = user; }),
store.subscribe('loading', loading => { this.loading = loading; })
];
this._init();
}
async _init() {
const isAuth = await authService.checkAuth();
this._setupRoutes(isAuth);
}
_setupRoutes(isAuth) {
page('/', () => {
if (!isAuth) { page.redirect('/login'); return; }
this.currentPage = 'dashboard';
});
page('/login', () => { this.currentPage = 'login'; });
page('/tasks/:id', (ctx) => {
if (!isAuth) { page.redirect('/login'); return; }
this.currentPage = 'task-detail';
store.set('currentTaskId', ctx.params.id);
});
page('/settings', () => {
if (!isAuth) { page.redirect('/login'); return; }
this.currentPage = 'settings';
});
page('*', () => { this.currentPage = '404'; });
page();
}
disconnectedCallback() {
super.disconnectedCallback();
this._unsubs.forEach(u => u());
}
_renderPage() {
switch (this.currentPage) {
case 'login': return html`<login-page></login-page>`;
case 'dashboard': return html`<dashboard-page></dashboard-page>`;
case 'task-detail': return html`<task-detail-page></task-detail-page>`;
case 'settings': return html`<settings-page></settings-page>`;
case 'loading': return html`<div class="loading">Loading...</div>`;
default: return html`<not-found-page></not-found-page>`;
}
}
render() {
return html`
${this.user ? html`<header>
<h1>TaskFlow</h1>
<nav>
<a href="/">Dashboard</a>
<a href="/settings">Settings</a>
<button @click=${authService.logout}>Logout</button>
</nav>
<theme-toggle></theme-toggle>
</header>` : ''}
<main>${this._renderPage()}</main>
`;
}
}
customElements.define('taskflow-app', TaskFlowApp);
Expected output: App shell handles auth state, routing, navigation, and page rendering. Store subscriptions sync user state.
Step 5 — Task List Component
// src/components/task-list.js
import { LitElement, html, css } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { classMap } from 'lit/directives/class-map.js';
import { store } from '../state.js';
import './task-card.js';
class TaskList extends LitElement {
static styles = css`
.list { display: flex; flex-direction: column; gap: 8px; }
.empty { text-align: center; padding: 48px; color: #999; }
`;
static properties = { tasks: { type: Array } };
constructor() { super(); this.tasks = []; this._unsub = store.subscribe('tasks', t => this.tasks = t); }
disconnectedCallback() { super.disconnectedCallback(); this._unsub(); }
render() {
if (!this.tasks.length) return html`<div class="empty">No tasks found</div>`;
return html`
<div class="list">
${repeat(this.tasks, t => t.id, task => html`
<task-card .task=${task}></task-card>
`)}
</div>
`;
}
}
customElements.define('task-list', TaskList);
Expected output: Task list subscribes to store. repeat directive optimizes re-rendering by key.
Step 6 — Task Card Component
// src/components/task-card.js
import { LitElement, html, css } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
class TaskCard extends LitElement {
static styles = css`
:host { display: block; }
.card {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; border: 1px solid var(--border-color, #e0e0e0);
border-radius: var(--border-radius-md, 8px);
background: var(--color-surface, white);
transition: box-shadow 0.2s;
cursor: pointer;
}
.card:hover { box-shadow: var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.12)); }
.done .title { text-decoration: line-through; color: var(--color-text-secondary, #999); }
.checkbox { width: 20px; height: 20px; border: 2px solid var(--color-primary, #1a237e); border-radius: 4px; cursor: pointer; }
.checkbox.checked { background: var(--color-primary, #1a237e); }
.title { flex: 1; font-size: var(--font-size-md, 14px); }
.priority { padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.high { background: #ffebee; color: #c62828; }
.medium { background: #fff3e0; color: #e65100; }
.low { background: #e8f5e9; color: #2e7d32; }
`;
static properties = { task: { type: Object } };
_toggleDone() {
this.dispatchEvent(new CustomEvent('toggle-task', {
detail: { id: this.task.id }, bubbles: true, composed: true
}));
}
_openDetail() {
this.dispatchEvent(new CustomEvent('open-task', {
detail: { id: this.task.id }, bubbles: true, composed: true
}));
}
render() {
const { title, done, priority } = this.task;
return html`
<div class="card ${classMap({ done })}" @click=${this._openDetail}>
<div class="checkbox ${classMap({ checked: done })}"
@click=${e => { e.stopPropagation(); this._toggleDone(); }}>
</div>
<span class="title">${title}</span>
<span class="priority ${priority}">${priority}</span>
</div>
`;
}
}
customElements.define('task-card', TaskCard);
Expected output: Task card shows checkbox, title, and priority badge. Clicking the card opens detail. Checkbox stops propagation.
Step 7 — Filter Bar
// src/components/filter-bar.js
import { LitElement, html, css } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
class FilterBar extends LitElement {
static styles = css`
:host { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.filter { padding: 6px 12px; border: 1px solid var(--border-color, #e0e0e0); border-radius: var(--border-radius-sm, 4px); cursor: pointer; font-size: var(--font-size-sm, 12px); background: var(--color-surface, white); }
.filter.active { background: var(--color-primary, #1a237e); color: white; border-color: var(--color-primary); }
select { padding: 6px 12px; border: 1px solid var(--border-color, #e0e0e0); border-radius: var(--border-radius-sm, 4px); font-size: var(--font-size-sm, 12px); background: var(--color-surface, white); }
`;
static properties = { currentFilter: { type: String }, currentSort: { type: String } };
constructor() { super(); this.currentFilter = 'all'; this.currentSort = 'created'; }
_setFilter(filter) {
this.currentFilter = filter;
this.dispatchEvent(new CustomEvent('filter-change', { detail: { filter } }));
}
_setSort(e) {
this.currentSort = e.target.value;
this.dispatchEvent(new CustomEvent('sort-change', { detail: { sort: e.target.value } }));
}
render() {
const filters = ['all', 'active', 'done', 'high', 'medium', 'low'];
return html`
${filters.map(f => html`
<button class="filter ${classMap({ active: this.currentFilter === f })}"
@click=${() => this._setFilter(f)}>${f}</button>
`)}
<select @change=${this._setSort} .value=${this.currentSort}>
<option value="created">Created</option>
<option value="dueDate">Due Date</option>
<option value="priority">Priority</option>
</select>
`;
}
}
customElements.define('filter-bar', FilterBar);
Expected output: Filter buttons dispatch filter-change. Sort dropdown dispatches sort-change. Active filter is highlighted.
Step 8 — Theme Toggle
// src/components/theme-toggle.js
import { LitElement, html, css } from 'lit';
import { store } from '../state.js';
class ThemeToggle extends LitElement {
static styles = css`
button {
padding: 8px; border: none; border-radius: 50%; width: 36px; height: 36px;
cursor: pointer; background: var(--color-surface); color: var(--color-text);
font-size: 18px; display: flex; align-items: center; justify-content: center;
border: 1px solid var(--border-color, #e0e0e0);
}
`;
static properties = { theme: { type: String } };
constructor() { super(); this.theme = 'light'; this._unsub = store.subscribe('theme', t => this.theme = t); }
disconnectedCallback() { super.disconnectedCallback(); this._unsub(); }
_toggle() {
const newTheme = this.theme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
store.set('theme', newTheme);
}
render() { return html`<button @click=${this._toggle}>${this.theme === 'light' ? 'D' : 'L'}</button>`; }
}
customElements.define('theme-toggle', ThemeToggle);
Expected output: Theme toggle button switches data-theme attribute and persists to localStorage.
Step 9 — Dashboard Page
// src/pages/dashboard.js
import { LitElement, html, css } from 'lit';
import page from 'page/page.mjs';
import { store } from '../state.js';
import { api } from '../services/api.js';
import '../components/task-list.js';
import '../components/filter-bar.js';
import '../components/search-input.js';
import '../components/pagination.js';
class DashboardPage extends LitElement {
static styles = css`
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.add-btn { padding: 8px 16px; background: var(--color-primary, #1a237e); color: white; border: none; border-radius: var(--border-radius-sm, 4px); cursor: pointer; }
`;
static properties = { tasks: { type: Array }, filter: { type: String }, sort: { type: String }, search: { type: String }, page: { type: Number }, total: { type: Number } };
constructor() {
super();
this.tasks = [];
this.filter = 'all';
this.sort = 'created';
this.search = '';
this.page = 1;
this.total = 0;
this._unsubs = [
store.subscribe('tasks', t => this.tasks = t)
];
}
disconnectedCallback() { super.disconnectedCallback(); this._unsubs.forEach(u => u()); }
_onFilterChange(e) { this.filter = e.detail.filter; this.page = 1; this._fetchTasks(); }
_onSortChange(e) { this.sort = e.detail.sort; this._fetchTasks(); }
_onSearch(e) { this.search = e.detail.query; this.page = 1; this._fetchTasks(); }
_onPageChange(e) { this.page = e.detail.page; this._fetchTasks(); }
async _fetchTasks() {
store.set('loading', true);
const params = new URLSearchParams({ filter: this.filter, sort: this.sort, q: this.search, page: this.page });
const { tasks, total } = await api.get(`/tasks?${params}`);
store.set('tasks', tasks);
store.set('loading', false);
this.total = total;
}
async firstUpdated() { await this._fetchTasks(); }
render() {
return html`
<div class="header">
<h2>Tasks</h2>
<button class="add-btn" @click=${() => page.redirect('/tasks/new')}>Add Task</button>
</div>
<div class="toolbar">
<filter-bar @filter-change=${this._onFilterChange} @sort-change=${this._onSortChange}></filter-bar>
<search-input @search=${this._onSearch}></search-input>
</div>
<task-list .tasks=${this.tasks}></task-list>
<pagination .page=${this.page} .total=${this.total} @page-change=${this._onPageChange}></pagination>
`;
}
}
customElements.define('dashboard-page', DashboardPage);
Expected output: Dashboard orchestrates filter/search/pagination. Fetches tasks from API on mount and filter change.
Step 10 — Task Form
// src/components/task-form.js
import { LitElement, html, css } from 'lit';
import { api } from '../services/api.js';
import { store } from '../state.js';
class TaskForm extends LitElement {
static styles = css`
:host { display: block; }
.form { display: flex; flex-direction: column; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 4px; }
label { font-size: var(--font-size-sm, 12px); font-weight: 600; color: var(--color-text-secondary); }
input, textarea, select { padding: 8px 12px; border: 1px solid var(--border-color, #e0e0e0); border-radius: var(--border-radius-sm, 4px); font-size: var(--font-size-md, 14px); background: var(--color-surface, white); color: var(--color-text); }
.error { color: #c62828; font-size: 12px; }
.actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
`;
static properties = { task: { type: Object }, saving: { type: Boolean }, error: { type: String } };
constructor() {
super();
this.task = { title: '', description: '', priority: 'medium', dueDate: '' };
this.saving = false;
this.error = '';
}
_updateField(field, value) { this.task = { ...this.task, [field]: value }; }
async _submit(e) {
e.preventDefault();
if (!this.task.title.trim()) { this.error = 'Title is required'; return; }
this.saving = true;
this.error = '';
try {
const saved = this.task.id
? await api.put(`/tasks/${this.task.id}`, this.task)
: await api.post('/tasks', this.task);
store.update('tasks', tasks => this.task.id
? tasks.map(t => t.id === saved.id ? saved : t)
: [...tasks, saved]
);
this.dispatchEvent(new CustomEvent('saved', { detail: saved }));
} catch (err) {
this.error = err.message;
} finally {
this.saving = false;
}
}
render() {
return html`
<form class="form" @submit=${this._submit}>
<div class="field">
<label>Title</label>
<input .value=${this.task.title} @input=${e => this._updateField('title', e.target.value)} required>
</div>
<div class="field">
<label>Description</label>
<textarea .value=${this.task.description} @input=${e => this._updateField('description', e.target.value)} rows="4"></textarea>
</div>
<div class="field">
<label>Priority</label>
<select .value=${this.task.priority} @change=${e => this._updateField('priority', e.target.value)}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="field">
<label>Due Date</label>
<input type="date" .value=${this.task.dueDate} @change=${e => this._updateField('dueDate', e.target.value)}>
</div>
${this.error ? html`<div class="error">${this.error}</div>` : ''}
<div class="actions">
<button type="submit" ?disabled=${this.saving}>${this.saving ? 'Saving...' : 'Save'}</button>
</div>
</form>
`;
}
}
customElements.define('task-form', TaskForm);
Expected output: Task form handles create and edit. Updates store optimistically on save.
Step 11 — Integration and Testing
// test/pages/dashboard.test.js
import { html, fixture, expect } from '@open-wc/testing';
import { store } from '../../src/state.js';
import '../../src/pages/dashboard.js';
describe('DashboardPage', () => {
beforeEach(() => {
store.set('tasks', [
{ id: 1, title: 'Test Task', done: false, priority: 'high' },
{ id: 2, title: 'Done Task', done: true, priority: 'low' }
]);
});
it('renders task list from store', async () => {
const el = await fixture(html`<dashboard-page></dashboard-page>`);
const list = el.shadowRoot.querySelector('task-list');
expect(list).to.exist;
expect(list.tasks).to.have.length(2);
});
it('filters by active', async () => {
const el = await fixture(html`<dashboard-page></dashboard-page>`);
el.filter = 'active';
expect(el.tasks.filter(t => !t.done)).to.have.length(1);
});
});
Expected output: Tests verify component rendering and store integration. Store provides predictable state for tests.
Common Mistakes
Tightly coupling components to global state - Use store subscriptions with cleanup.
Not handling loading and error states - Every data-fetching view needs loading, error, and empty states.
Over-engineering state management - Simple observable store is sufficient for most apps.
Missing route guards and redirects - Protect authenticated routes.
Not optimizing bundle for deployment - Use code splitting and performance budgets.
Practice Questions
- How does the store pattern work with LitElement components?
- How do you handle route authentication in a LitElement SPA?
- How do you compose components to build a page?
- How do you handle form submission and API integration?
- How do you organize tests for a multi-component application?
FAQ
What's Next
You have built a complete LitElement application. Explore Polymer Introduction to review the fundamentals, or build your own project using these patterns.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro