Skip to content

Polymer 15 Project

DodaTech 13 min read

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

  1. Tightly coupling components to global state - Use store subscriptions with cleanup.

  2. Not handling loading and error states - Every data-fetching view needs loading, error, and empty states.

  3. Over-engineering state management - Simple observable store is sufficient for most apps.

  4. Missing route guards and redirects - Protect authenticated routes.

  5. Not optimizing bundle for deployment - Use code splitting and performance budgets.

Practice Questions

  1. How does the store pattern work with LitElement components?
  2. How do you handle route authentication in a LitElement SPA?
  3. How do you compose components to build a page?
  4. How do you handle form submission and API integration?
  5. How do you organize tests for a multi-component application?

FAQ

Should I use a full state management library?

For most LitElement apps, a simple observable store is sufficient. Consider Redux or MobX for complex apps.

How do I handle authentication tokens?

Store tokens in localStorage or sessionStorage. Include in API request headers via the service layer.

How do I optimize initial load time?

Use code splitting for route pages, lazy load non-critical components, and preload critical CSS.

How do I handle real-time updates?

Use WebSocket or Server-Sent Events in the API service. Push updates to the store.

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