Skip to main content

JavaScript Components with Store Pattern

Introduction

In this exercise, you will build a todo CRUD application using a component-based architecture with centralized state management. Instead of writing procedural code that directly manipulates the DOM, you'll create reusable components that automatically update when the application state changes.

What You'll Learn

By completing this exercise, you will:

  • Understand the component pattern for organizing UI code
  • Implement a centralized store for state management
  • Use the observer pattern to make components reactive
  • Build reusable, self-contained UI components
  • Structure a modern JavaScript application without a framework

Why Components and Store?

Traditional DOM manipulation code becomes hard to maintain:

// Traditional approach - scattered state and updates
let todos = [];
function addTodo() {
todos.push(newTodo);
// Manual DOM update in multiple places
updateTable();
updateForm();
}

With components and a store:

// Component approach - centralized state
store.addTodo(newTodo);
// All subscribed components automatically update!

Benefits:

  • Single source of truth - all state lives in one place
  • Automatic updates - components subscribe to state changes
  • Reusable components - easy to use the same component multiple times
  • Easier debugging - track all state changes in one location
  • Testable - components and store can be tested independently

Application Architecture

┌─────────────────────────────────────────┐
│ Store │
│ - State (todos, isLoading, error) │
│ - Methods (subscribe, addTodo, etc.) │
│ - Notifies all subscribers on change │
└──────────────┬──────────────────────────┘
│ subscribes
┌───────┴────────┐
│ │
┌────▼─────┐ ┌─────▼────┐
│TodoForm │ │TodoTable │
│Component │ │Component │
└──────────┘ └──────────┘

Project Structure

project/
├── index.html
├── todo.store.js # Centralized state management (factory function)
├── todo.api.js # API communication
├── todo.form.js # Form component for adding/editing todos
├── todo.table.js # Table component for displaying todos
├── app.js # Application initialization
└── details/
└── index.html # Todo details page

Key Architectural Decisions

  • Factory functions instead of classes for simplicity
  • Separate API module for clean separation of concerns
  • Closure-based encapsulation for private state
  • Optimistic updates for better user experience
  • Observer pattern for reactive updates
  • Event delegation for efficient event handling
  • Explicit initialization with init() for clear component lifecycle
  • Bootstrap for styling instead of custom CSS
  • Form-based editing instead of inline editing
  • Navigation to details page for viewing todo details

Part 1: Understanding the Store Pattern

The store holds all application state and notifies components when state changes.

Core Concepts

1. Centralized State All data lives in one object:

const state = {
todos: [],
isLoading: false,
error: null
};

2. State is Read-Only (via Closure) Components can't directly modify state. They must call store methods:

// ❌ Bad - can't do this! State is private in closure
// todos.push(newTodo); // Not accessible!

// ✅ Good - use store methods
store.addTodo(newTodo);

3. Observer Pattern Components subscribe to state changes:

const unsubscribe = store.subscribe(render);
// This function will be called whenever state changes

// Component can unsubscribe when destroyed
store.unsubscribe(render);
// Or use the returned cleanup function
unsubscribe();

Benefits of This Pattern

  • Predictable state changes - only store methods can modify state
  • Easy debugging - log all state changes in one place
  • Time-travel debugging - store history of all state changes
  • Undo/Redo - easier to implement with centralized state

Part 2: Create the Store

Let's build the store using a factory function pattern with closures for data encapsulation.

Step 1: Create the Store Module

Create todo.store.js:

// todo.store.js
import { fetchTodos, createTodo, updateTodo, deleteTodo } from "./todo.api.js";

export function TodoStore() {
// Private state - enclosed in closure
let todos = [];
let isLoading = false;
let error = null;
const subscribers = [];

// Subscribe to state changes - returns unsubscribe function
const subscribe = (callback) => {
subscribers.push(callback);
// Immediately call with current state
callback({ todos, isLoading, error });

// Return unsubscribe function
return () => {
const index = subscribers.indexOf(callback);
if (index > -1) {
subscribers.splice(index, 1);
}
};
};

// Unsubscribe from state changes
const unsubscribe = (callback) => {
const index = subscribers.indexOf(callback);
if (index > -1) {
subscribers.splice(index, 1);
}
};

// Notify all subscribers of state changes
const notify = () => {
subscribers.forEach(callback => {
callback({ todos, isLoading, error });
});
};

// Load todos from API
async function loadTodos(isSilent = false) {
error = null;
if (!isSilent) {
isLoading = true;
notify();
}
try {
todos = await fetchTodos();
} catch (err) {
error = {
message: "Failed to fetch todos. Please try again.",
type: "FetchError"
};
} finally {
isLoading = false;
}
notify();
}

// Add new todo with optimistic update
async function addTodo(todo) {
error = null;
// Create temporary ID for optimistic update
const tempId = -Date.now();
const optimisticTodo = { ...todo, id: tempId };
todos = [...todos, optimisticTodo];
notify();

try {
const newTodo = await createTodo(todo);
// Replace temp todo with real one from server
todos = todos.map(t => t.id === tempId ? newTodo : t);
error = null;
} catch (err) {
error = {
message: "Failed to create todo. Please try again.",
type: "CreateError"
};
// Rollback on error
todos = todos.filter(t => t.id !== tempId);
}
notify();
}

// Update existing todo with optimistic update
async function changeTodo(id, todo) {
error = null;
const previousTodos = [...todos];
// Optimistically update UI
todos = todos.map(t =>
t.id === Number(id) ? { ...todo, id: Number(id) } : t
);
notify();

try {
await updateTodo(id, todo);
error = null;
} catch (err) {
error = {
message: "Failed to update todo. Please try again.",
type: "UpdateError"
};
// Rollback on error
todos = previousTodos;
}
notify();
}

// Remove todo with optimistic update
async function removeTodo(id) {
error = null;
const previousTodos = [...todos];
// Optimistically update UI
todos = todos.filter(todo => todo.id !== Number(id));
notify();

try {
await deleteTodo(id);
error = null;
} catch (err) {
error = {
message: "Failed to delete todo. Please try again.",
type: "DeleteError"
};
// Rollback on error
todos = previousTodos;
}
notify();
}

// Get todo by ID
const getTodoById = (id) => {
return todos.find(todo => todo.id === Number(id));
};

// Initialize store
const init = async () => {
await loadTodos();
};

// Public API
return {
subscribe,
unsubscribe,
init,
addTodo,
changeTodo,
removeTodo,
getTodoById
};
}

Step 2: Understanding the Store Code

Factory Function Pattern

  • Returns an object with public methods
  • Uses closures to keep state private
  • No new keyword needed - just call TodoStore()

Key Concepts:

subscribe(callback)

  • Adds a listener function to be called on state changes
  • Immediately calls callback with current state
  • Returns an unsubscribe function for easy cleanup
  • Similar to addEventListener but for state

unsubscribe(callback)

  • Removes a listener function from subscribers
  • Pass the same callback reference used in subscribe
  • Important for cleanup when components are destroyed

notify()

  • Calls all subscribed listeners with current state
  • Passes state as a plain object: { todos, isLoading, error }
  • This is what makes components reactive!

init()

  • Initializes the store by loading data from API
  • Called once when the application starts
  • Returns a promise that resolves when data is loaded

getTodoById(id)

  • Helper method to find a todo by its ID
  • Used by components that need to access a specific todo
  • Returns the todo object or undefined if not found

Optimistic Updates

  • UI updates immediately (optimistic)
  • If API call fails, changes are rolled back
  • Provides better user experience
  • Example: addTodo adds temp todo, then replaces with real one

Error Handling

  • Each method catches errors and sets error state
  • Errors are included in state notifications
  • Components can display error messages
  • Previous state is restored on failure

Part 3: Understanding Components

A component is a self-contained piece of UI. Each component:

  1. Has a root DOM element (container)
  2. Subscribes to the store for reactive updates
  3. Renders itself when state changes
  4. Handles its own events
  5. Returns public methods (like destroy)

Component Pattern (Factory Function)

export function MyComponent(container, store) {

const resetContainer = () => {
container.innerHTML = '';
};

const destroy = () => {
resetContainer();
store.unsubscribe(render);
container.removeEventListener('click', onClick);
};

// Render function - updates the DOM
const render = (state) => {
resetContainer();
container.innerHTML = `
<div>State: ${JSON.stringify(state)}</div>
`;
};

// Event handler using event delegation
const onClick = (event) => {
// Handle clicks on child elements
const action = event.target.dataset.action;
if (action === 'doSomething') {
store.doSomething();
}
};

// Initialize component
const init = () => {
store.subscribe(render);
container.addEventListener('click', onClick);
};

init();

// Return public API
return {
destroy
};
}

Key Points:

  • Factory function returns an object with public methods
  • Event delegation - single listener on container instead of many on children
  • Explicit init() - clear initialization sequence
  • store.unsubscribe(render) - pass the render function to unsubscribe
  • Render is called automatically by store when state changes

Part 4: Create the API Module

Let's create a separate module for all API communication. This keeps network logic separate from state management.

Create todo.api.js:

// todo.api.js

// export const BASE_URL = "https://jsonplaceholder.typicode.com";
export const BASE_URL = "http://localhost:8080/api";

const BASE_URL_TODOS = `${BASE_URL}/todos`;

/**
* Fetch todos from the API
* @returns {Promise<Array>} Array of todo objects
*/
export async function fetchTodos() {
const response = await fetch(`${BASE_URL_TODOS}`);
if (!response.ok) {
throw new Error('Failed to fetch todos');
}
return response.json();
}

/**
* Create a new todo
* @param {Object} todo - Todo object to create
* @returns {Promise<Object>} Created todo with id from server
*/
export async function createTodo(todo) {
const response = await fetch(`${BASE_URL_TODOS}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(todo),
});
if (!response.ok) {
throw new Error('Failed to create todo');
}
return response.json();
}

/**
* Update an existing todo
* @param {number} id - Todo ID
* @param {Object} todo - Todo object with updated fields
* @returns {Promise<Object>} Updated todo
*/
export async function updateTodo(id, todo) {
const response = await fetch(`${BASE_URL_TODOS}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(todo),
});
if (!response.ok) {
throw new Error('Failed to update todo');
}
return response.json();
}

/**
* Delete a todo
* @param {number} id - Todo ID to delete
* @returns {Promise<void>}
*/
export async function deleteTodo(id) {
const response = await fetch(`${BASE_URL_TODOS}/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete todo');
}
}

Understanding the API Module

Base URL Configuration

  • Primary: http://localhost:8080/api for local backend
  • Fallback: JSONPlaceholder (commented) for testing without backend
  • Easy to switch between local and remote APIs

Named Exports

  • Each function is exported individually
  • Import with: import { fetchTodos, createTodo } from './todo.api.js'
  • Clear, descriptive function names

Why Separate Functions?

  • Easy to import only what you need
  • Simple to test individual functions
  • No object wrapper needed
  • Follows functional programming principles

Key Points:

  • All API logic is in one place (separation of concerns)
  • Each function is async and returns a Promise
  • Errors are thrown and handled by the store
  • Uses RESTful conventions (GET, POST, PUT, DELETE)

Part 5: Build the TodoForm Component

This component handles both adding new todos and editing existing ones. It uses the same form for both operations.

Create todo.form.js:

// todo.form.js
export function TodoForm(element, store) {

const resetForm = () => {
element.reset();
element.querySelector("input[name='id']").value = "";
document.querySelector("#cancelEditBtn").classList.add("hidden");
document.querySelector("[type='submit']").textContent = "Add Todo";
};

const destroy = () => {
element.removeEventListener("submit", onSubmit);
element.querySelector("#cancelEditBtn").removeEventListener("click", resetForm);
store.unsubscribe(handleStoreChange);
};

const onSubmit = async (event) => {
event.preventDefault();

const formData = new FormData(element);
const title = formData.get("title");
const userId = formData.get("userId");
const completed = formData.get("completed") === "on";
const id = formData.get("id");

const todo = {
title,
userId: Number(userId),
completed
};

if (id) {
// Edit mode - update existing todo
todo.id = Number(id);
store.changeTodo(id, todo);
} else {
// Create mode - add new todo
store.addTodo(todo);
}

resetForm();
};

const setLoading = (loading) => {
const submitBtn = element.querySelector("[type='submit']");
submitBtn.disabled = loading;
};

const handleStoreChange = (data) => {
setLoading(data.isLoading);

if (data.error && (data.error.type === "CreateError" || data.error.type === "UpdateError")) {
alert(`Error: ${data.error?.message}`);
const submitBtn = element.querySelector("[type='submit']");
submitBtn.disabled = false;
}
};

const fillForm = (todo) => {
element.querySelector("input[name='title']").value = todo.title;
element.querySelector("input[name='userId']").value = todo.userId;
element.querySelector("input[name='completed']").checked = todo.completed;
element.querySelector("input[name='id']").value = todo.id;

document.querySelector("#cancelEditBtn").classList.remove("hidden");
document.querySelector("[type='submit']").textContent = "Update Todo";
};

const init = () => {
element.addEventListener("submit", onSubmit);
element.querySelector("#cancelEditBtn").addEventListener("click", resetForm);
store.subscribe(handleStoreChange);
};

init();

return {
destroy,
fillForm
};
}

Understanding TodoForm

Dual Mode Form

  • Create mode: Default state, adds new todos
  • Edit mode: Triggered by fillForm(), updates existing todo
  • Hidden ID field tracks which mode we're in
  • Cancel button appears in edit mode

Key Features:

resetForm()

  • Clears all form fields
  • Switches back to create mode
  • Hides cancel button
  • Called after successful submission or cancel

fillForm(todo)

  • Public method exposed to other components
  • Populates form with todo data
  • Switches to edit mode
  • Shows cancel button and changes submit text

onSubmit(event)

  • Handles both create and update operations
  • Checks if ID exists to determine mode
  • Calls appropriate store method
  • Resets form on success

handleStoreChange(data)

  • Subscribes to store updates
  • Disables submit button during loading
  • Shows error alerts if create/update fails
  • Component reacts to store state

Event Delegation Pattern

  • Single submit listener on form element
  • Single click listener on cancel button
  • Listeners removed in destroy for proper cleanup

Why This Pattern?

  • Single form for both create and update (DRY)
  • No separate edit modal needed
  • Clear visual feedback (button text changes)
  • Easy to cancel editing
  • Store handles all API calls and loading states

Key Points:

  • Form doesn't display store data (todos list)
  • Only sends data TO the store and reacts to loading/errors
  • fillForm is called by table component when edit button clicked
  • Clean separation: form handles UI, store handles data

Part 6: Build the TodoTable Component

This component displays todos in a table and handles editing and deletion. Clicking on a row navigates to the details page.

Create todo.table.js:

// todo.table.js
export function TodoTable(element, store, onEdit) {

const resetTable = () => {
element.innerHTML = "";
};

const destroy = () => {
resetTable();
store.unsubscribe(render);
element.removeEventListener("click", onClick);
element.removeEventListener("change", onChange);
};

const render = ({ todos, isLoading, error }) => {
resetTable();

if (error && error.type === "FetchError") {
element.innerHTML = `<tr><td colspan="4">${error.message}</td></tr>`;
return;
}

if (error && error.type === "DeleteError") {
alert(`Error: ${error.message}`);
}

if (isLoading) {
element.innerHTML = `<tr><td colspan="4">Loading...</td></tr>`;
return;
}

todos.forEach(todo => {
const tr = document.createElement("tr");
tr.setAttribute("data-id", todo.id);
tr.innerHTML = /*html*/ `
<td>${todo.id}</td>
<td>${todo.title}</td>
<td>${todo.userId}</td>
<td>
<input
type="checkbox"
${todo.completed ? 'checked' : ''}
data-action="toggle"
class="completed-checkbox"
>
</td>
<td>
<div class="gap-2 flex">
<button data-action="edit" class="btn btn-warning">Edit</button>
<button data-action="delete" class="btn btn-danger">Delete</button>
</div>
</td>
`;
element.appendChild(tr);
});
};

const onClick = async (event) => {
const id = event.target.closest("tr")?.dataset.id;
if (id === undefined) return;

if (event.target.getAttribute("data-action") === "delete") {
if (!confirm('Are you sure you want to delete this todo?')) {
return;
}
await store.removeTodo(id);
return;
}

if (event.target.getAttribute("data-action") === "edit") {
const todo = store.getTodoById(id);
onEdit(todo);
return;
}

// Navigate to details page
window.location.href = `details/?id=${id}`;
};

const onChange = async (event) => {
if (event.target.getAttribute("data-action") === "toggle") {
const row = event.target.closest("tr");
if (!row || !row.dataset.id) return;
const id = parseInt(row.dataset.id);
const completed = event.target.checked;

// Get current todo and update with all fields
const todo = store.getTodoById(id);
await store.changeTodo(id, { ...todo, completed });
}
};

const init = () => {
store.subscribe(render);
element.addEventListener("click", onClick);
element.addEventListener("change", onChange);
}

init();

return {
destroy
};
}

Understanding TodoTable

Callback Pattern

  • Accepts onEdit callback as third parameter
  • Called when edit button is clicked
  • Passes todo object to the callback
  • This allows form component to fill itself with todo data

Reactive Component

  • Subscribes to store on initialization
  • Automatically re-renders when state changes
  • All state comes from the store

Event Delegation Pattern

  • Single click listener on container instead of many on individual buttons
  • Single change listener for checkboxes
  • Much more efficient - no need to re-attach listeners after each render
  • Listeners check event.target to determine which element was clicked

Three Click Actions:

  1. Edit button: Calls onEdit(todo) to fill form
  2. Delete button: Confirms and calls store.removeTodo()
  3. Row click: Navigates to details/?id={id} page

Navigation

  • Clicking anywhere on row (except buttons) navigates to details
  • Uses query parameter to pass todo ID
  • Simple page navigation with window.location.href

Checkbox Toggle

  • Updates todo's completed status
  • Gets full todo object and spreads it to preserve all fields
  • Store handles optimistic update

Factory Function Benefits

  • All functions in closure scope
  • No this binding issues
  • Clean event handler references
  • Uses store.unsubscribe(render) in destroy

Key Improvements

  • resetTable() helper for cleaning up container
  • Explicit init() for clear initialization
  • Event delegation reduces memory usage and improves performance
  • No need to re-attach listeners after render
  • Form-based editing instead of inline editing (simpler, clearer UX)

Part 7: Initialize the Application

Create app.js to wire everything together:

// app.js
import { TodoForm } from "./todo.form.js";
import { TodoStore } from "./todo.store.js";
import { TodoTable } from "./todo.table.js";

document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("#todoForm");
const table = document.querySelector("#todoTableBody");
const todoStore = TodoStore();

const todoForm = TodoForm(form, todoStore);
const todoTable = TodoTable(table, todoStore, (todo) => todoForm.fillForm(todo));

todoStore.init();
});

Understanding the App Module

DOMContentLoaded Event

  • Waits for HTML to be fully loaded before running
  • Ensures all DOM elements exist before querying them
  • Standard pattern for vanilla JS apps

Initialization Flow

  1. Query form and table body elements from DOM
  2. Create store instance with TodoStore()
  3. Create form component, passing form element and store
  4. Create table component, passing:
    • Table body element
    • Store
    • Callback function (todo) => todoForm.fillForm(todo)
  5. Call todoStore.init() to load data from API

Callback Pattern

  • Table receives todoForm.fillForm as onEdit callback
  • When edit button clicked, table calls: onEdit(todo)
  • This calls todoForm.fillForm(todo), populating the form
  • Clean component communication without tight coupling

Component Lifecycle

  • Each component's init() runs automatically in constructor
  • Components subscribe to store in their init()
  • Store's init() loads data, triggering all component renders

Why This Pattern?

  • Simpler than factory function wrapping
  • Direct, straightforward initialization
  • Easy to understand flow
  • Store passed explicitly (dependency injection)
  • Components stay decoupled via callback

Part 8: HTML Structure

Create index.html:

<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<style>
body {
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
}

form {
padding: 20px;
border: 1px solid #ccc;
width: 400px;
display: flex;
flex-direction: column;
gap: 1em;
margin-bottom: 2rem;
}

.hidden {
display: none;
}

tbody tr {
cursor: pointer;
}

tbody tr td button {
pointer-events: all;
}
</style>
<title>Todo CRUD with Components & Store</title>
</head>

<body>
<h1 class="text-center">Todo CRUD</h1>

<form id="todoForm">
<input type="hidden" id="todoId" name="id">
<input type="text" id="todoTitle" name="title" placeholder="Enter todo title" required>
<input type="number" id="userId" name="userId" placeholder="Enter user ID" required>
<div class="form-check">
<input type="checkbox" id="todoCompleted" name="completed" class="form-check-input">
<label class="form-check-label" for="todoCompleted">Completed</label>
</div>
<button type="button" id="cancelEditBtn" class="btn btn-secondary hidden">Cancel Edit</button>
<button type="submit" class="btn btn-primary">Add Todo</button>
</form>

<table class="table">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Title</th>
<th scope="col">User ID</th>
<th scope="col">Completed</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="todoTableBody">
</tbody>
</table>

<script type="module" src="./app.js"></script>
</body>

</html>