Learn how to persist data to localStorage and sessionStorage in Eleva.
A notes app that saves to localStorage automatically.
app.component("NotesApp", {
setup({ signal }) {
// Load notes from localStorage on init
const savedNotes = localStorage.getItem("eleva-notes");
const notes = signal(savedNotes ? JSON.parse(savedNotes) : []);
const currentNote = signal({ id: null, title: "", content: "" });
const isEditing = signal(false);
// Save to localStorage whenever notes change
notes.watch((newNotes) => {
localStorage.setItem("eleva-notes", JSON.stringify(newNotes));
});
function createNote() {
currentNote.value = { id: null, title: "", content: "" };
isEditing.value = true;
}
function editNote(note) {
currentNote.value = { ...note };
isEditing.value = true;
}
function saveNote() {
const note = currentNote.value;
if (!note.title.trim()) return;
if (note.id) {
// Update existing note
notes.value = notes.value.map(n =>
n.id === note.id ? { ...note, updatedAt: Date.now() } : n
);
} else {
// Create new note
notes.value = [...notes.value, {
...note,
id: Date.now(),
createdAt: Date.now(),
updatedAt: Date.now()
}];
}
isEditing.value = false;
currentNote.value = { id: null, title: "", content: "" };
}
function deleteNote(id) {
if (confirm("Delete this note?")) {
notes.value = notes.value.filter(n => n.id !== id);
}
}
function cancelEdit() {
isEditing.value = false;
currentNote.value = { id: null, title: "", content: "" };
}
function formatDate(timestamp) {
return new Date(timestamp).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric"
});
}
return {
notes, currentNote, isEditing,
createNote, editNote, saveNote, deleteNote, cancelEdit, formatDate
};
},
template: (ctx) => `
<div class="notes-app">
<header>
<h1>My Notes</h1>
<button @click="createNote">+ New Note</button>
</header>
${ctx.isEditing.value ? `
<div class="note-editor">
<input
type="text"
placeholder="Note title..."
value="${ctx.currentNote.value.title}"
@input="(e) => currentNote.value = { ...currentNote.value, title: e.target.value }"
/>
<textarea
placeholder="Write your note..."
@input="(e) => currentNote.value = { ...currentNote.value, content: e.target.value }"
>${ctx.currentNote.value.content}</textarea>
<div class="editor-actions">
<button class="save-btn" @click="saveNote">Save</button>
<button class="cancel-btn" @click="cancelEdit">Cancel</button>
</div>
</div>
` : `
<div class="notes-list">
${ctx.notes.value.length === 0 ? `
<p class="no-notes">No notes yet. Create your first note!</p>
` : ctx.notes.value.map(note => `
<div key="${note.id}" class="note-card">
<h3>${note.title}</h3>
<p>${note.content.substring(0, 100)}${note.content.length > 100 ? "..." : ""}</p>
<div class="note-meta">
<span>Updated: ${ctx.formatDate(note.updatedAt)}</span>
</div>
<div class="note-actions">
<button @click="() => editNote(${JSON.stringify(note).replace(/"/g, '"')})">Edit</button>
<button @click="() => deleteNote(${note.id})">Delete</button>
</div>
</div>
`).join("")}
</div>
`}
</div>
`,
style: `
.notes-app { max-width: 600px; margin: 0 auto; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
header h1 { margin: 0; }
header button { padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
.note-editor { background: #f8f9fa; padding: 20px; border-radius: 8px; }
.note-editor input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 15px; font-size: 1.1rem; }
.note-editor textarea { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 4px; min-height: 200px; resize: vertical; }
.editor-actions { margin-top: 15px; display: flex; gap: 10px; }
.save-btn { padding: 10px 20px; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; }
.cancel-btn { padding: 10px 20px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; }
.no-notes { text-align: center; color: #666; padding: 40px; }
.note-card { border: 1px solid #eee; padding: 20px; border-radius: 8px; margin-bottom: 15px; }
.note-card h3 { margin: 0 0 10px 0; }
.note-card p { color: #666; margin: 0 0 10px 0; }
.note-meta { font-size: 12px; color: #999; margin-bottom: 10px; }
.note-actions button { margin-right: 10px; padding: 5px 15px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; }
`
});
Key Concepts:
signal.watch() to auto-save on changesPersist userβs theme preference across sessions.
app.component("ThemeSwitcher", {
setup({ signal }) {
// Load theme from localStorage or default to system preference
const savedTheme = localStorage.getItem("theme");
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = signal(savedTheme || (systemPrefersDark ? "dark" : "light"));
// Apply theme on change
theme.watch((newTheme) => {
localStorage.setItem("theme", newTheme);
document.documentElement.setAttribute("data-theme", newTheme);
});
// Apply initial theme
document.documentElement.setAttribute("data-theme", theme.value);
function setTheme(newTheme) {
theme.value = newTheme;
}
function toggleTheme() {
theme.value = theme.value === "light" ? "dark" : "light";
}
return { theme, setTheme, toggleTheme };
},
template: (ctx) => `
<div class="theme-switcher">
<h3>Theme Settings</h3>
<div class="theme-options">
<button
class="${ctx.theme.value === 'light' ? 'active' : ''}"
@click="() => setTheme('light')"
>
βοΈ Light
</button>
<button
class="${ctx.theme.value === 'dark' ? 'active' : ''}"
@click="() => setTheme('dark')"
>
π Dark
</button>
</div>
<button class="toggle-btn" @click="toggleTheme">
Toggle Theme
</button>
<p class="current-theme">Current: <strong>${ctx.theme.value}</strong></p>
<div class="demo-content">
<h4>Demo Content</h4>
<p>This content will change based on the selected theme.</p>
</div>
</div>
`,
style: (ctx) => `
.theme-switcher { max-width: 400px; margin: 0 auto; padding: 20px; }
.theme-options { display: flex; gap: 10px; margin-bottom: 20px; }
.theme-options button {
flex: 1; padding: 15px; border: 2px solid #ddd;
background: white; border-radius: 8px; cursor: pointer; font-size: 1rem;
}
.theme-options button.active { border-color: #007bff; background: #f0f7ff; }
.toggle-btn { width: 100%; padding: 12px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
.current-theme { text-align: center; margin-top: 15px; }
.demo-content { margin-top: 20px; padding: 20px; background: var(--bg-secondary, #f8f9fa); border-radius: 8px; }
/* Theme variables - add to your global CSS */
:root[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--text-primary: #212529;
}
:root[data-theme="dark"] {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--text-primary: #ffffff;
}
`
});
Store and display recent search queries.
app.component("SearchWithHistory", {
setup({ signal }) {
const MAX_HISTORY = 10;
const savedHistory = localStorage.getItem("search-history");
const searchHistory = signal(savedHistory ? JSON.parse(savedHistory) : []);
const query = signal("");
const showHistory = signal(false);
searchHistory.watch((history) => {
localStorage.setItem("search-history", JSON.stringify(history));
});
function search(searchQuery = query.value) {
const trimmed = searchQuery.trim();
if (!trimmed) return;
// Add to history (remove duplicate if exists, add to front)
const newHistory = [
trimmed,
...searchHistory.value.filter(h => h !== trimmed)
].slice(0, MAX_HISTORY);
searchHistory.value = newHistory;
query.value = trimmed;
showHistory.value = false;
// Perform actual search
console.log("Searching for:", trimmed);
}
function selectFromHistory(term) {
query.value = term;
search(term);
}
function removeFromHistory(term) {
searchHistory.value = searchHistory.value.filter(h => h !== term);
}
function clearHistory() {
searchHistory.value = [];
}
return {
query, searchHistory, showHistory,
search, selectFromHistory, removeFromHistory, clearHistory
};
},
template: (ctx) => `
<div class="search-with-history">
<div class="search-box">
<input
type="text"
placeholder="Search..."
value="${ctx.query.value}"
@input="(e) => query.value = e.target.value"
@focus="() => showHistory.value = true"
@keyup="(e) => e.key === 'Enter' && search()"
/>
<button @click="() => search()">Search</button>
</div>
${ctx.showHistory.value && ctx.searchHistory.value.length > 0 ? `
<div class="history-dropdown">
<div class="history-header">
<span>Recent Searches</span>
<button @click="clearHistory">Clear All</button>
</div>
<ul>
${ctx.searchHistory.value.map((term, index) => `
<li key="${index}">
<span @click="() => selectFromHistory('${term.replace(/'/g, "\\'")}')">${term}</span>
<button @click="() => removeFromHistory('${term.replace(/'/g, "\\'")}')">Γ</button>
</li>
`).join('')}
</ul>
</div>
` : ''}
</div>
`,
style: `
.search-with-history { position: relative; max-width: 500px; margin: 0 auto; }
.search-box { display: flex; gap: 10px; }
.search-box input { flex: 1; padding: 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; }
.search-box button { padding: 12px 24px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
.history-dropdown { position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-top: 5px; z-index: 10; }
.history-header { display: flex; justify-content: space-between; padding: 10px 15px; border-bottom: 1px solid #eee; font-size: 12px; color: #666; }
.history-header button { background: none; border: none; color: #007bff; cursor: pointer; }
.history-dropdown ul { list-style: none; padding: 0; margin: 0; }
.history-dropdown li { display: flex; justify-content: space-between; padding: 10px 15px; cursor: pointer; }
.history-dropdown li:hover { background: #f8f9fa; }
.history-dropdown li span { flex: 1; }
.history-dropdown li button { background: none; border: none; color: #999; cursor: pointer; }
`
});
Preserve form data during a session (survives page refresh, cleared on browser close).
app.component("SessionForm", {
setup({ signal }) {
// Use sessionStorage instead of localStorage
const savedData = sessionStorage.getItem("draft-form");
const formData = signal(savedData ? JSON.parse(savedData) : {
title: "",
description: "",
category: "",
tags: ""
});
const isDirty = signal(false);
const lastSaved = signal(null);
// Auto-save to sessionStorage
formData.watch((data) => {
sessionStorage.setItem("draft-form", JSON.stringify(data));
isDirty.value = true;
lastSaved.value = new Date().toLocaleTimeString();
});
function updateField(field, value) {
formData.value = { ...formData.value, [field]: value };
}
function submit() {
console.log("Submitting:", formData.value);
// Clear session storage after successful submit
sessionStorage.removeItem("draft-form");
formData.value = { title: "", description: "", category: "", tags: "" };
isDirty.value = false;
alert("Form submitted successfully!");
}
function clearDraft() {
if (confirm("Clear all draft data?")) {
sessionStorage.removeItem("draft-form");
formData.value = { title: "", description: "", category: "", tags: "" };
isDirty.value = false;
}
}
return { formData, isDirty, lastSaved, updateField, submit, clearDraft };
},
template: (ctx) => `
<div class="session-form">
<div class="form-header">
<h3>Create Post</h3>
${ctx.isDirty.value ? `
<span class="draft-indicator">
Draft saved at ${ctx.lastSaved.value}
</span>
` : ''}
</div>
<div class="form-group">
<label>Title</label>
<input
type="text"
value="${ctx.formData.value.title}"
@input="(e) => updateField('title', e.target.value)"
/>
</div>
<div class="form-group">
<label>Description</label>
<textarea
@input="(e) => updateField('description', e.target.value)"
>${ctx.formData.value.description}</textarea>
</div>
<div class="form-group">
<label>Category</label>
<select @change="(e) => updateField('category', e.target.value)">
<option value="">Select a category</option>
${['Technology', 'Design', 'Business', 'Other'].map(cat => `
<option key="${cat}" value="${cat}" ${ctx.formData.value.category === cat ? 'selected' : ''}>
${cat}
</option>
`).join('')}
</select>
</div>
<div class="form-group">
<label>Tags (comma-separated)</label>
<input
type="text"
value="${ctx.formData.value.tags}"
@input="(e) => updateField('tags', e.target.value)"
/>
</div>
<div class="form-actions">
<button @click="clearDraft" class="secondary">Clear Draft</button>
<button @click="submit" class="primary">Submit</button>
</div>
<p class="hint">
π‘ Your draft is automatically saved. Refresh the page to test!
</p>
</div>
`,
style: `
.session-form { max-width: 500px; margin: 0 auto; }
.form-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.draft-indicator { font-size: 12px; color: #28a745; }
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: 500; }
.form-group input, .form-group textarea, .form-group select {
width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;
}
.form-group textarea { min-height: 100px; resize: vertical; }
.form-actions { display: flex; gap: 10px; margin-top: 20px; }
.form-actions button { flex: 1; padding: 12px; border: none; border-radius: 4px; cursor: pointer; }
.form-actions button.primary { background: #007bff; color: white; }
.form-actions button.secondary { background: #6c757d; color: white; }
.hint { margin-top: 20px; padding: 15px; background: #fff3cd; border-radius: 4px; font-size: 14px; }
`
});
Check and display storage usage.
app.component("StorageInfo", {
setup({ signal }) {
const storageInfo = signal({
used: 0,
total: 0,
percentage: 0,
items: []
});
function calculateUsage() {
let totalSize = 0;
const items = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
const size = new Blob([key + value]).size;
totalSize += size;
items.push({ key, size });
}
items.sort((a, b) => b.size - a.size);
// localStorage typically has 5-10MB limit
const estimatedTotal = 5 * 1024 * 1024; // 5MB
storageInfo.value = {
used: totalSize,
total: estimatedTotal,
percentage: (totalSize / estimatedTotal) * 100,
items
};
}
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function removeItem(key) {
if (confirm(`Remove "${key}" from localStorage?`)) {
localStorage.removeItem(key);
calculateUsage();
}
}
function clearAll() {
if (confirm("Clear all localStorage data?")) {
localStorage.clear();
calculateUsage();
}
}
return {
storageInfo, formatBytes, removeItem, clearAll,
onMount: calculateUsage
};
},
template: (ctx) => `
<div class="storage-info">
<h3>LocalStorage Usage</h3>
<div class="usage-bar">
<div class="usage-fill" style="width: ${Math.min(ctx.storageInfo.value.percentage, 100)}%"></div>
</div>
<p class="usage-text">
${ctx.formatBytes(ctx.storageInfo.value.used)} / ${ctx.formatBytes(ctx.storageInfo.value.total)}
(${ctx.storageInfo.value.percentage.toFixed(2)}%)
</p>
<div class="items-list">
<div class="items-header">
<span>Stored Items (${ctx.storageInfo.value.items.length})</span>
<button @click="clearAll">Clear All</button>
</div>
${ctx.storageInfo.value.items.length === 0 ? `
<p class="no-items">No items in localStorage</p>
` : `
<ul>
${ctx.storageInfo.value.items.map(item => `
<li key="${item.key}">
<span class="key">${item.key}</span>
<span class="size">${ctx.formatBytes(item.size)}</span>
<button @click="() => removeItem('${item.key.replace(/'/g, "\\'")}')">Γ</button>
</li>
`).join('')}
</ul>
`}
</div>
</div>
`,
style: `
.storage-info { max-width: 500px; margin: 0 auto; }
.usage-bar { height: 20px; background: #e9ecef; border-radius: 10px; overflow: hidden; margin-bottom: 10px; }
.usage-fill { height: 100%; background: linear-gradient(90deg, #28a745, #ffc107, #dc3545); transition: width 0.3s; }
.usage-text { text-align: center; color: #666; margin-bottom: 20px; }
.items-header { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #ddd; }
.items-header button { background: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; }
.items-list ul { list-style: none; padding: 0; margin: 0; }
.items-list li { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid #eee; }
.items-list li .key { flex: 1; font-family: monospace; }
.items-list li .size { color: #666; margin-right: 10px; }
.items-list li button { background: none; border: none; color: #dc3545; cursor: pointer; font-size: 18px; }
.no-items { text-align: center; color: #666; padding: 20px; }
`
});
| β Back to Patterns | Previous: State Management | Next: Complete Apps β |