JavascriptAdvanced
Local Storage Notes
Build a notes app where users can create, edit, and delete notes that persist in localStorage.
Local Storage Notes
Problem
Build a notes app where the user can create, edit, and delete notes. All notes are saved to localStorage so they survive page refreshes. Each note shows when it was last updated.
Page loads
→ Existing notes loaded from localStorage
→ If no notes — show empty state
User clicks "New Note"
→ A blank note appears in edit mode
User types a title and content
→ Note is auto-saved as they type (debounced)
User clicks another note
→ Previous note saved, new note opens in editor
User clicks delete on a note
→ Note removed from list and localStorage
Page refresh
→ All notes still there exactly as leftLogic
- Load notes from localStorage on page load
- Render the notes list in the sidebar
- Clicking a note opens it in the editor
- Clicking "New Note" creates a blank note and opens it
- Auto-save the note as the user types — debounced 600ms
- Deleting a note removes it from the array and localStorage
- Show a formatted "last updated" timestamp on each note
Flow
HTML Structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Notes App</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: sans-serif;
display: flex;
height: 100vh;
background: #f5f5f5;
}
/* left sidebar */
.sidebar {
width: 260px;
background: #fff;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
/* sidebar header */
.sidebar-header {
padding: 20px 16px 14px;
border-bottom: 1px solid #e0e0e0;
}
.sidebar-header h2 {
font-size: 1rem;
color: #111;
margin-bottom: 12px;
}
/* new note button */
#new-note-btn {
width: 100%;
padding: 9px;
font-size: 0.9rem;
font-weight: 600;
background: #111;
color: #fff;
border: none;
border-radius: 7px;
cursor: pointer;
}
#new-note-btn:hover {
opacity: 0.85;
}
/* scrollable notes list */
#notes-list {
flex: 1;
overflow-y: auto;
padding: 10px 8px;
}
/* each note item in sidebar */
.note-item {
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 4px;
position: relative;
transition: background 0.15s;
}
.note-item:hover {
background: #f5f5f5;
}
/* active note highlighted */
.note-item.active {
background: #f0f0f0;
}
.note-item-title {
font-size: 0.9rem;
font-weight: 600;
color: #222;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 3px;
/* leave room for delete button */
padding-right: 24px;
}
.note-item-preview {
font-size: 0.78rem;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-item-date {
font-size: 0.72rem;
color: #bbb;
margin-top: 3px;
}
/* delete button on hover */
.note-delete-btn {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
cursor: pointer;
color: #ccc;
font-size: 0.9rem;
display: none;
padding: 2px 4px;
}
.note-item:hover .note-delete-btn {
display: block;
}
.note-delete-btn:hover {
color: #e74c3c;
}
/* right editor area */
.editor {
flex: 1;
display: flex;
flex-direction: column;
padding: 32px 40px;
overflow-y: auto;
}
/* note title input */
#note-title {
width: 100%;
font-size: 1.6rem;
font-weight: 700;
border: none;
outline: none;
background: none;
color: #111;
margin-bottom: 8px;
padding: 0;
}
#note-title::placeholder {
color: #ddd;
}
/* last updated label */
#note-date {
font-size: 0.8rem;
color: #bbb;
margin-bottom: 20px;
}
/* divider between title and content */
.divider {
border: none;
border-top: 1px solid #e8e8e8;
margin-bottom: 20px;
}
/* note content textarea */
#note-content {
flex: 1;
width: 100%;
border: none;
outline: none;
background: none;
font-size: 1rem;
color: #444;
resize: none;
line-height: 1.7;
font-family: inherit;
}
#note-content::placeholder {
color: #ddd;
}
/* auto-save indicator */
#save-indicator {
font-size: 0.78rem;
color: #bbb;
text-align: right;
margin-top: 12px;
min-height: 16px;
}
/* empty state */
#empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
color: #ccc;
text-align: center;
gap: 10px;
}
#empty-state p {
font-size: 0.95rem;
}
</style>
</head>
<body>
<!-- left sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<h2>📝 Notes</h2>
<button id="new-note-btn">+ New Note</button>
</div>
<div id="notes-list"></div>
</div>
<!-- right editor — shown when a note is open -->
<div class="editor" id="editor" style="display:none">
<input type="text" id="note-title" placeholder="Note title..." />
<div id="note-date"></div>
<hr class="divider" />
<textarea id="note-content" placeholder="Start writing..."></textarea>
<div id="save-indicator"></div>
</div>
<!-- empty state — shown when no note is selected -->
<div id="empty-state">
<p>No note selected.</p>
<p>Click <strong>+ New Note</strong> to get started.</p>
</div>
<script src="script.js"></script>
</body>
</html>Solution
// Step 1 — select elements
const newNoteBtn = document.querySelector("#new-note-btn");
const notesList = document.querySelector("#notes-list");
const editor = document.querySelector("#editor");
const emptyState = document.querySelector("#empty-state");
const noteTitleInput = document.querySelector("#note-title");
const noteContentInput = document.querySelector("#note-content");
const noteDateEl = document.querySelector("#note-date");
const saveIndicator = document.querySelector("#save-indicator");
// Step 2 — load notes from localStorage
// parse the JSON string back to an array — default to empty array
let notes = JSON.parse(localStorage.getItem("notes")) || [];
// Step 3 — track the id of the currently open note
let activeNoteId = null;
// Step 4 — render sidebar and open first note on load
renderSidebar();
if (notes.length > 0) {
openNote(notes[0].id);
}
// Step 5 — new note button
newNoteBtn.addEventListener("click", () => {
// create a new blank note
const newNote = {
id: Date.now(), // unique id from timestamp
title: "",
content: "",
updatedAt: new Date().toISOString()
};
// add to the beginning of the array so it shows at the top
notes.unshift(newNote);
saveToStorage();
renderSidebar();
openNote(newNote.id);
// focus the title so user can start typing immediately
noteTitleInput.focus();
});
// Step 6 — debounce function to delay auto-save
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// Step 7 — auto-save when user types — debounced 600ms
const autoSave = debounce(() => {
if (!activeNoteId) return;
const note = notes.find((n) => n.id === activeNoteId);
if (!note) return;
// update note from editor values
note.title = noteTitleInput.value;
note.content = noteContentInput.value;
note.updatedAt = new Date().toISOString();
saveToStorage();
renderSidebar();
updateDateDisplay(note.updatedAt);
// briefly show "Saved" indicator
saveIndicator.textContent = "✓ Saved";
setTimeout(() => saveIndicator.textContent = "", 1500);
}, 600);
// attach auto-save to both inputs
noteTitleInput.addEventListener("input", autoSave);
noteContentInput.addEventListener("input", autoSave);
// Step 8 — open a note in the editor
function openNote(id) {
const note = notes.find((n) => n.id === id);
if (!note) return;
activeNoteId = id;
// show editor, hide empty state
editor.style.display = "flex";
emptyState.style.display = "none";
// fill editor with note content
noteTitleInput.value = note.title;
noteContentInput.value = note.content;
updateDateDisplay(note.updatedAt);
saveIndicator.textContent = "";
// highlight active note in sidebar
renderSidebar();
}
// Step 9 — render the sidebar note list
function renderSidebar() {
if (notes.length === 0) {
notesList.innerHTML = `<p style="text-align:center;color:#ccc;font-size:0.85rem;padding:20px">No notes yet</p>`;
return;
}
notesList.innerHTML = notes
.map((note) => {
const isActive = note.id === activeNoteId;
const title = note.title || "Untitled";
const preview = note.content.split("\n")[0] || "No content";
const date = formatDate(note.updatedAt);
return `
<div class="note-item ${isActive ? "active" : ""}" data-id="${note.id}">
<div class="note-item-title">${title}</div>
<div class="note-item-preview">${preview}</div>
<div class="note-item-date">${date}</div>
<button class="note-delete-btn" data-id="${note.id}" title="Delete note">✕</button>
</div>
`;
})
.join("");
}
// Step 10 — event delegation on the notes list
// handles both note clicks (open) and delete button clicks
notesList.addEventListener("click", (e) => {
// check if delete button was clicked first
const deleteBtn = e.target.closest(".note-delete-btn");
if (deleteBtn) {
e.stopPropagation(); // prevent note from opening
const id = Number(deleteBtn.dataset.id);
deleteNote(id);
return;
}
// otherwise check if a note item was clicked
const noteItem = e.target.closest(".note-item");
if (noteItem) {
const id = Number(noteItem.dataset.id);
openNote(id);
}
});
// Step 11 — delete a note
function deleteNote(id) {
notes = notes.filter((n) => n.id !== id);
saveToStorage();
// if the deleted note was open — close the editor
if (activeNoteId === id) {
activeNoteId = null;
editor.style.display = "none";
emptyState.style.display = "flex";
// open the next available note if any
if (notes.length > 0) {
openNote(notes[0].id);
}
}
renderSidebar();
}
// Step 12 — save notes array to localStorage
function saveToStorage() {
localStorage.setItem("notes", JSON.stringify(notes));
}
// helper — updates the date display in the editor
function updateDateDisplay(isoString) {
noteDateEl.textContent = `Last edited ${formatDate(isoString)}`;
}
// helper — formats ISO date string to a readable format
function formatDate(isoString) {
const date = new Date(isoString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays === 1) return "Yesterday";
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}Code Execution
Trace through clicking "New Note":
| Step | Code | Result |
|---|---|---|
| Create note | { id: 1710000000000, title: "", content: "", updatedAt: "..." } | blank note |
| Add to array | notes.unshift(newNote) | appears at top |
| Save | localStorage.setItem("notes", JSON.stringify(notes)) | persisted |
| Render sidebar | new note shown as "Untitled" | visible |
| Open note | editor fills with blank values | editor visible |
| Focus title | noteTitleInput.focus() | cursor in title |
Trace through typing title "Meeting Notes" (auto-save fires after 600ms):
| Step | Code | Result |
|---|---|---|
| input fires | autoSave() called | debounce timer starts |
| 600ms pass | timer fires | auto-save runs |
| Update note | note.title = "Meeting Notes" | title updated |
| Update time | note.updatedAt = new Date().toISOString() | timestamp updated |
| Save to storage | localStorage.setItem(...) | persisted |
| Render sidebar | sidebar shows "Meeting Notes" | updated |
| Show indicator | saveIndicator.textContent = "✓ Saved" | shows for 1.5s |
Trace through deleting a note:
| Step | Code | Result |
|---|---|---|
| Click delete btn | deleteBtn found | e.stopPropagation() called |
| Read id | Number(deleteBtn.dataset.id) | note id |
| Filter | notes.filter(n => n.id !== id) | note removed |
| Save | localStorage.setItem(...) | removed from storage |
| Check active | activeNoteId === id | open next note or show empty state |
Output
Page loads with saved notes → sidebar shows all notes, first one opens
Click "New Note" → blank note opens, title focused
Type title and content → auto-saves after 600ms, "✓ Saved" flashes
Click another note → switches to that note in editor
Delete a note → removed from sidebar and localStorage
Refresh page → all remaining notes still there