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
newkeyword needed - just callTodoStore()
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
addEventListenerbut 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:
addTodoadds 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:
- Has a root DOM element (container)
- Subscribes to the store for reactive updates
- Renders itself when state changes
- Handles its own events
- 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/apifor 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
fillFormis 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
onEditcallback 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
clicklistener on container instead of many on individual buttons - Single
changelistener for checkboxes - Much more efficient - no need to re-attach listeners after each render
- Listeners check
event.targetto determine which element was clicked
Three Click Actions:
- Edit button: Calls
onEdit(todo)to fill form - Delete button: Confirms and calls
store.removeTodo() - 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
thisbinding 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
- Query form and table body elements from DOM
- Create store instance with
TodoStore() - Create form component, passing form element and store
- Create table component, passing:
- Table body element
- Store
- Callback function
(todo) => todoForm.fillForm(todo)
- Call
todoStore.init()to load data from API
Callback Pattern
- Table receives
todoForm.fillFormasonEditcallback - 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>